import { 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; // url -> rkey miniflux: Record; // 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 = {}; for (const f of atList) curA[f.record.url] = f.rkey; const curM: Record = {}; 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)); }