/** * Sync service for rNotes — handles re-fetching, conflict detection, * and merging for imported notes. * * Conflict policy: * - Remote-only-changed → auto-update * - Local-only-changed → keep local * - Both changed → mark conflict (stores remote version in conflictContent) */ import type { NoteItem, SourceRef } from '../schemas'; import { getConverter, hashContent } from './index'; import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap'; export interface SyncResult { action: 'unchanged' | 'updated' | 'conflict' | 'error'; remoteHash?: string; error?: string; updatedContent?: string; // TipTap JSON of remote content updatedPlain?: string; updatedMarkdown?: string; } /** Sync a single Notion note by re-fetching from API. */ export async function syncNotionNote(note: NoteItem, token: string): Promise { if (!note.sourceRef || note.sourceRef.source !== 'notion') { return { action: 'error', error: 'Note is not from Notion' }; } try { const converter = getConverter('notion'); if (!converter) return { action: 'error', error: 'Notion converter not available' }; const result = await converter.import({ pageIds: [note.sourceRef.externalId], accessToken: token, }); if (result.notes.length === 0) { return { action: 'error', error: 'Could not fetch page from Notion' }; } const remote = result.notes[0]; const remoteHash = remote.sourceRef.contentHash || ''; const localHash = note.sourceRef.contentHash || ''; // Compare hashes if (remoteHash === localHash) { return { action: 'unchanged' }; } // Check if local was modified since last sync const currentLocalHash = hashContent( note.contentFormat === 'tiptap-json' ? tiptapToMarkdown(note.content) : note.content ); const localModified = currentLocalHash !== localHash; if (!localModified) { // Only remote changed — auto-update return { action: 'updated', remoteHash, updatedContent: remote.content, updatedPlain: remote.contentPlain, updatedMarkdown: remote.markdown, }; } // Both changed — conflict return { action: 'conflict', remoteHash, updatedContent: remote.content, updatedPlain: remote.contentPlain, updatedMarkdown: remote.markdown, }; } catch (err) { return { action: 'error', error: (err as Error).message }; } } /** Sync a single Google Docs note by re-fetching from API. */ export async function syncGoogleDocsNote(note: NoteItem, token: string): Promise { if (!note.sourceRef || note.sourceRef.source !== 'google-docs') { return { action: 'error', error: 'Note is not from Google Docs' }; } try { const converter = getConverter('google-docs'); if (!converter) return { action: 'error', error: 'Google Docs converter not available' }; const result = await converter.import({ pageIds: [note.sourceRef.externalId], accessToken: token, }); if (result.notes.length === 0) { return { action: 'error', error: 'Could not fetch doc from Google Docs' }; } const remote = result.notes[0]; const remoteHash = remote.sourceRef.contentHash || ''; const localHash = note.sourceRef.contentHash || ''; if (remoteHash === localHash) { return { action: 'unchanged' }; } const currentLocalHash = hashContent( note.contentFormat === 'tiptap-json' ? tiptapToMarkdown(note.content) : note.content ); const localModified = currentLocalHash !== localHash; if (!localModified) { return { action: 'updated', remoteHash, updatedContent: remote.content, updatedPlain: remote.contentPlain, updatedMarkdown: remote.markdown, }; } return { action: 'conflict', remoteHash, updatedContent: remote.content, updatedPlain: remote.contentPlain, updatedMarkdown: remote.markdown, }; } catch (err) { return { action: 'error', error: (err as Error).message }; } } /** Sync file-based notes by re-parsing a ZIP and matching by externalId. */ export async function syncFileBasedNotes( notes: NoteItem[], zipData: Uint8Array, source: 'obsidian' | 'logseq', ): Promise> { const results = new Map(); try { const converter = getConverter(source); if (!converter) { for (const n of notes) results.set(n.id, { action: 'error', error: `${source} converter not available` }); return results; } const importResult = await converter.import({ fileData: zipData }); const remoteMap = new Map(); for (const rn of importResult.notes) { remoteMap.set(rn.sourceRef.externalId, rn); } for (const note of notes) { if (!note.sourceRef) { results.set(note.id, { action: 'error', error: 'No sourceRef' }); continue; } const remote = remoteMap.get(note.sourceRef.externalId); if (!remote) { results.set(note.id, { action: 'unchanged' }); // Not found in ZIP — keep as-is continue; } const remoteHash = remote.sourceRef.contentHash || ''; const localHash = note.sourceRef.contentHash || ''; if (remoteHash === localHash) { results.set(note.id, { action: 'unchanged' }); continue; } const currentLocalHash = hashContent( note.contentFormat === 'tiptap-json' ? tiptapToMarkdown(note.content) : note.content ); const localModified = currentLocalHash !== localHash; if (!localModified) { results.set(note.id, { action: 'updated', remoteHash, updatedContent: remote.content, updatedPlain: remote.contentPlain, updatedMarkdown: remote.markdown, }); } else { results.set(note.id, { action: 'conflict', remoteHash, updatedContent: remote.content, updatedPlain: remote.contentPlain, updatedMarkdown: remote.markdown, }); } } } catch (err) { for (const n of notes) { results.set(n.id, { action: 'error', error: (err as Error).message }); } } return results; }