/** * Sync state persistence — tracks source→target mapping between incremental syncs. * * State is stored as JSON at ~/.config/lark-hirono/sync-state/.json * where is derived from the source+target node tokens. */ import { createHash } from "node:crypto"; import { readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; // ─── Types ────────────────────────────────────────────────────────────── export interface SyncState { version: 1; sourceRoot: string; targetRoot: string; lastSyncTime: string; rootContentHash: string; /** objEditTime of the root node at last sync, for fast change detection */ rootObjEditTime?: string; /** * True once a full sync (tree copy + fixupReferences) has completed at * least once. Absent/false means a prior run was interrupted; the next * run must force fixupReferences and skip orphan pruning. */ firstRunComplete?: boolean; pages: Record; } export interface PageState { targetNodeToken: string; targetObjToken: string; sourceObjToken: string; title: string; objEditTime: string; contentHash: string; lastSynced: string; /** Source image tokens that failed to sync (absent if all succeeded) */ failedImages?: string[]; } // ─── Paths ────────────────────────────────────────────────────────────── const STATE_DIR = join(homedir(), ".config", "lark-hirono", "sync-state"); function stateHash(srcToken: string, dstToken: string): string { return createHash("sha256") .update(`${srcToken}:${dstToken}`) .digest("hex") .slice(0, 16); } export function stateFilePath(srcToken: string, dstToken: string): string { return join(STATE_DIR, `${stateHash(srcToken, dstToken)}.json`); } // ─── Load / Save ──────────────────────────────────────────────────────── export function loadState(srcToken: string, dstToken: string): SyncState | null { const path = stateFilePath(srcToken, dstToken); try { const raw = readFileSync(path, "utf-8"); const data = JSON.parse(raw) as Record; if (data.version !== 1) return null; return data as unknown as SyncState; } catch { return null; } } export function saveState(state: SyncState): void { const path = stateFilePath(state.sourceRoot, state.targetRoot); mkdirSync(STATE_DIR, { recursive: true }); const tmp = path + ".tmp"; writeFileSync(tmp, JSON.stringify(state, null, 2)); renameSync(tmp, path); } // ─── Content Hashing ──────────────────────────────────────────────────── /** Fields to strip before hashing — these change between copies. */ const STRIP_KEYS = new Set(["block_id", "parent_id", "children", "comment_ids"]); function stripBlock(block: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(block)) { if (STRIP_KEYS.has(k)) continue; out[k] = v; } return out; } /** * Compute a SHA-256 hash of block content, ignoring positional/identity fields. * Used to detect whether document content actually changed (vs metadata-only edits). */ export function computeContentHash(blocks: Record[]): string { const stripped = blocks .filter((b) => (b.block_type as number) !== 1) // skip root page block .map(stripBlock); return createHash("sha256") .update(JSON.stringify(stripped)) .digest("hex"); } // ─── Cross-Pair Lookup ────────────────────────────────────────────────── /** Aggregate mappings across every on-disk sync-state pair. */ export interface CrossPairMapping { /** source nodeToken → target nodeToken */ nodeMap: Map; /** source objToken → target objToken */ objMap: Map; } /** Load every valid v1 sync-state file under STATE_DIR. */ export function loadAllStates(): SyncState[] { let files: string[]; try { files = readdirSync(STATE_DIR); } catch { return []; } const out: SyncState[] = []; for (const f of files) { if (!f.endsWith(".json")) continue; try { const raw = readFileSync(join(STATE_DIR, f), "utf-8"); const data = JSON.parse(raw) as Record; if (data.version === 1) out.push(data as unknown as SyncState); } catch { /* skip unreadable */ } } return out; } /** * Merge every on-disk sync-state's page mappings into a single lookup table. * When the same source token appears in multiple states, the entry with the * most recent `lastSynced` wins. * * Pass `exclude` to omit a specific pair (usually the current run's state, * since the caller already has those mappings in-memory). * * Root-page mappings are NOT included — state files don't record obj tokens * for the root, only for child pages. */ export function buildCrossPairMapping( exclude?: { sourceRoot: string; targetRoot: string }, ): CrossPairMapping { const nodeMap = new Map(); const objMap = new Map(); const nodeFresh = new Map(); const objFresh = new Map(); for (const s of loadAllStates()) { if ( exclude && s.sourceRoot === exclude.sourceRoot && s.targetRoot === exclude.targetRoot ) continue; for (const [srcToken, page] of Object.entries(s.pages)) { const ts = page.lastSynced ?? ""; const prevNode = nodeFresh.get(srcToken); if (prevNode === undefined || ts > prevNode) { nodeMap.set(srcToken, page.targetNodeToken); nodeFresh.set(srcToken, ts); } if (page.sourceObjToken && page.targetObjToken) { const prevObj = objFresh.get(page.sourceObjToken); if (prevObj === undefined || ts > prevObj) { objMap.set(page.sourceObjToken, page.targetObjToken); objFresh.set(page.sourceObjToken, ts); } } } } return { nodeMap, objMap }; } // ─── State Builder ────────────────────────────────────────────────────── /** * Create an initial SyncState from a completed first-run sync. */ export function buildInitialState( sourceRoot: string, targetRoot: string, rootContentHash: string, rootObjEditTime?: string, ): SyncState { return { version: 1, sourceRoot, targetRoot, lastSyncTime: new Date().toISOString(), rootContentHash, rootObjEditTime: rootObjEditTime ?? "", firstRunComplete: false, pages: {}, }; }