208 lines
5.8 KiB
TypeScript
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;
|
|
}
|