rspace-online/modules/rnotes/converters/sync.ts

208 lines
5.8 KiB
TypeScript

/**
* 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<SyncResult> {
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<SyncResult> {
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<Map<string, SyncResult>> {
const results = new Map<string, SyncResult>();
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<string, typeof importResult.notes[0]>();
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;
}