Something went wrong. Try again.
Personal save-for-later and Miniflux e-reader proxy for Xteink X4 (wip)
Something went wrong. Try again.
4.1 kB · 143 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144import { readFileSync, writeFileSync, existsSync, renameSync } from "node:fs";import { resolve } from "node:path";import type { AtprotoRepo } from "./atproto.js";import type { MinifluxClient } from "./miniflux.js";
type Snapshot = { atproto: Record<string, string>; // url -> rkey miniflux: Record<string, number>; // url -> feed id};
const EMPTY: Snapshot = { atproto: {}, miniflux: {} };
export class Syncer { private statePath: string; private running = false;
constructor( dataDir: string, private repo: AtprotoRepo, private mf: MinifluxClient, ) { const safeDid = repo.did.replace(/[^a-zA-Z0-9.:-]/g, "_"); this.statePath = resolve(dataDir, `sync-state.${safeDid}.json`); }
async run(): Promise<{ added: number; removed: number }> { if (this.running) return { added: 0, removed: 0 }; this.running = true; try { return await this.runInner(); } finally { this.running = false; } }
private async runInner(): Promise<{ added: number; removed: number }> { const prev = this.readSnapshot();
const [atList, mfList] = await Promise.all([ this.repo.listFeeds(), this.mf.listFeeds(), ]);
const curA: Record<string, string> = {}; for (const f of atList) curA[f.record.url] = f.rkey;
const curM: Record<string, number> = {}; for (const f of mfList) curM[f.feed_url] = f.id;
let added = 0; let removed = 0;
// Deltas vs last snapshot const atAdded = setDiff(Object.keys(curA), Object.keys(prev.atproto)); const atRemoved = setDiff(Object.keys(prev.atproto), Object.keys(curA)); const mfAdded = setDiff(Object.keys(curM), Object.keys(prev.miniflux)); const mfRemoved = setDiff(Object.keys(prev.miniflux), Object.keys(curM));
// atproto additions → miniflux for (const url of atAdded) { if (!(url in curM)) { try { await this.mf.subscribe(url); added++; } catch (e) { console.error(`sync: failed to subscribe ${url}:`, e); } } }
// miniflux additions → atproto for (const url of mfAdded) { if (!(url in curA)) { const feed = mfList.find((f) => f.feed_url === url); try { await this.repo.createFeed(url, { title: feed?.title, siteUrl: feed?.site_url }); added++; } catch (e) { console.error(`sync: failed to create atproto feed ${url}:`, e); } } }
// atproto deletions → miniflux for (const url of atRemoved) { const id = curM[url]; if (id !== undefined) { try { await this.mf.deleteFeed(id); removed++; } catch (e) { console.error(`sync: failed to delete miniflux feed ${url}:`, e); } } }
// miniflux deletions → atproto for (const url of mfRemoved) { const rkey = curA[url]; if (rkey !== undefined) { try { await this.repo.deleteFeed(rkey); removed++; } catch (e) { console.error(`sync: failed to delete atproto feed ${url}:`, e); } } }
// Re-snapshot after mutations so next run sees converged state const [finalAt, finalMf] = await Promise.all([ this.repo.listFeeds(), this.mf.listFeeds(), ]); const nextSnap: Snapshot = { atproto: Object.fromEntries(finalAt.map((f) => [f.record.url, f.rkey])), miniflux: Object.fromEntries(finalMf.map((f) => [f.feed_url, f.id])), }; this.writeSnapshot(nextSnap);
return { added, removed }; }
private readSnapshot(): Snapshot { if (!existsSync(this.statePath)) return EMPTY; try { return JSON.parse(readFileSync(this.statePath, "utf8")) as Snapshot; } catch { return EMPTY; } }
private writeSnapshot(snap: Snapshot): void { const tmp = this.statePath + ".tmp"; writeFileSync(tmp, JSON.stringify(snap, null, 2)); renameSync(tmp, this.statePath); }}
function setDiff(a: string[], bArr: string[]): string[] { const b = new Set(bArr); return a.filter((x) => !b.has(x));}