From 34ece96927a456d8994669026bc3d1e8dc84a08a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 20 Mar 2026 15:53:57 -0700 Subject: [PATCH] feat(rnotes): import converters for Evernote, Roam, file upload & sync New converters: Evernote (.enex), Roam Research (JSON), generic file import, and sync service. Enhanced existing Obsidian, Logseq, Google Docs, and Notion converters. Updated import-export dialog, schemas, and server routes. Co-Authored-By: Claude Opus 4.6 --- bun.lock | 8 + modules/rnotes/components/folk-notes-app.ts | 36 +- .../rnotes/components/import-export-dialog.ts | 360 ++++++++++++++++-- modules/rnotes/converters/evernote.ts | 236 ++++++++++++ modules/rnotes/converters/file-import.ts | 171 +++++++++ modules/rnotes/converters/google-docs.ts | 53 ++- modules/rnotes/converters/index.ts | 17 + modules/rnotes/converters/logseq.ts | 29 +- modules/rnotes/converters/notion.ts | 4 +- modules/rnotes/converters/obsidian.ts | 58 ++- modules/rnotes/converters/roam.ts | 171 +++++++++ modules/rnotes/converters/sync.ts | 207 ++++++++++ modules/rnotes/landing.ts | 6 +- modules/rnotes/mod.ts | 288 +++++++++++++- modules/rnotes/schemas.ts | 9 +- package.json | 2 + 16 files changed, 1569 insertions(+), 86 deletions(-) create mode 100644 modules/rnotes/converters/evernote.ts create mode 100644 modules/rnotes/converters/file-import.ts create mode 100644 modules/rnotes/converters/roam.ts create mode 100644 modules/rnotes/converters/sync.ts diff --git a/bun.lock b/bun.lock index 9f84c12..977170e 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,7 @@ "@tiptap/starter-kit": "^3.20.0", "@tiptap/y-tiptap": "^3.0.2", "@types/qrcode": "^1.5.6", + "@types/turndown": "^5.0.6", "@x402/core": "^2.3.1", "@x402/evm": "^2.5.0", "@xterm/addon-fit": "^0.11.0", @@ -46,6 +47,7 @@ "postgres": "^3.4.5", "qrcode": "^1.5.4", "sharp": "^0.33.0", + "turndown": "^7.2.2", "web-push": "^3.6.7", "y-indexeddb": "^9.0.12", "y-prosemirror": "^1.3.7", @@ -257,6 +259,8 @@ "@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="], + "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], "@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], @@ -577,6 +581,8 @@ "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], @@ -1063,6 +1069,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="], + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 6362d62..a4cf0c4 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -85,6 +85,7 @@ interface Note { fileUrl?: string | null; mimeType?: string | null; duration?: number | null; + source_ref?: { source: string; syncStatus?: string; lastSyncedAt?: number }; created_at: string; updated_at: string; } @@ -2549,11 +2550,25 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const typeBorder = this.getTypeBorderColor(n.type); + // Sync status indicator + let syncBadge = ''; + if (n.source_ref) { + const status = n.source_ref.syncStatus || 'synced'; + const statusColors: Record = { + synced: 'var(--rs-success, #22c55e)', + 'local-modified': '#facc15', + 'remote-modified': 'var(--rs-primary, #6366f1)', + conflict: 'var(--rs-error, #ef4444)', + }; + const color = statusColors[status] || 'var(--rs-text-muted, #888)'; + syncBadge = ``; + } + return `

${this.getNoteIcon(n.type)}
-
${n.is_pinned ? '\u{1F4CC} ' : ""}${this.esc(n.title)}
+
${n.is_pinned ? '\u{1F4CC} ' : ""}${this.esc(n.title)}${syncBadge}
${this.esc(n.content_plain || "")}
${this.formatDate(n.updated_at)} @@ -2696,7 +2711,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF private importExportDialog: ImportExportDialog | null = null; - private openImportExportDialog(tab: 'import' | 'export' = 'import') { + private openImportExportDialog(tab: 'import' | 'export' | 'sync' = 'import') { if (!this.importExportDialog) { // Dynamically import the dialog component import('./import-export-dialog').then(() => { @@ -2704,14 +2719,20 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.importExportDialog.setAttribute('space', this.space); this.shadow.appendChild(this.importExportDialog); - this.importExportDialog.addEventListener('import-complete', () => { - // Refresh notebooks list after import + const refreshAfterChange = () => { if (this.space === 'demo') { this.loadDemoData(); } else { this.loadNotebooks(); + // Also refresh current notebook if one is open + if (this.selectedNotebook) { + this.loadNotebookREST(this.selectedNotebook.id); + } } - }); + }; + + this.importExportDialog.addEventListener('import-complete', refreshAfterChange); + this.importExportDialog.addEventListener('sync-complete', refreshAfterChange); this.showDialog(tab); }); @@ -2720,7 +2741,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } } - private showDialog(tab: 'import' | 'export') { + private showDialog(tab: 'import' | 'export' | 'sync') { if (!this.importExportDialog) return; // Gather notebook list for the dialog @@ -2798,8 +2819,9 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF background: var(--rs-bg-surface-raised); border-radius: 8px; } .note-item__body { flex: 1; min-width: 0; } - .note-item__title { font-size: 14px; font-weight: 600; color: var(--rs-text-primary); } + .note-item__title { font-size: 14px; font-weight: 600; color: var(--rs-text-primary); display: flex; align-items: center; gap: 6px; } .note-item__pin { color: var(--rs-warning); } + .note-sync-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } .note-item__preview { font-size: 12px; color: var(--rs-text-muted); margin-top: 3px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; diff --git a/modules/rnotes/components/import-export-dialog.ts b/modules/rnotes/components/import-export-dialog.ts index d8d89ce..b0fcd86 100644 --- a/modules/rnotes/components/import-export-dialog.ts +++ b/modules/rnotes/components/import-export-dialog.ts @@ -1,9 +1,12 @@ /** - * — Modal dialog for importing/exporting notes. + * — Modal dialog for importing, exporting, and syncing notes. * - * Supports 4 sources: Logseq, Obsidian, Notion, Google Docs. - * File-based (Logseq/Obsidian) use ZIP upload/download. - * API-based (Notion/Google) use OAuth connections. + * Import sources: Files (direct), Obsidian, Logseq, Notion, Google Docs, Evernote, Roam Research. + * Export targets: Obsidian, Logseq, Notion, Google Docs. + * Sync: Re-fetch from API sources (Notion/Google) or re-upload vault ZIPs for file-based. + * + * File-based sources (Obsidian/Logseq/Evernote/Roam) use file upload. + * API-based sources (Notion/Google) use OAuth connections. */ interface NotebookOption { @@ -29,19 +32,22 @@ interface RemotePage { class ImportExportDialog extends HTMLElement { private shadow!: ShadowRoot; private space = ''; - private activeTab: 'import' | 'export' = 'import'; - private activeSource: 'obsidian' | 'logseq' | 'notion' | 'google-docs' = 'obsidian'; + private activeTab: 'import' | 'export' | 'sync' = 'import'; + private activeSource: 'files' | 'obsidian' | 'logseq' | 'notion' | 'google-docs' | 'evernote' | 'roam' = 'files'; private notebooks: NotebookOption[] = []; - private connections: ConnectionStatus = { + private connections: ConnectionStatus & { evernote?: { connected: boolean }; roam?: { connected: boolean } } = { notion: { connected: false }, google: { connected: false }, logseq: { connected: true }, obsidian: { connected: true }, }; + private selectedFiles: File[] = []; private remotePages: RemotePage[] = []; private selectedPages = new Set(); private importing = false; private exporting = false; + private syncing = false; + private syncStatuses: Record = {}; private statusMessage = ''; private statusType: 'info' | 'success' | 'error' = 'info'; private selectedFile: File | null = null; @@ -59,12 +65,14 @@ class ImportExportDialog extends HTMLElement { } /** Open the dialog. */ - open(notebooks: NotebookOption[], tab: 'import' | 'export' = 'import') { + open(notebooks: NotebookOption[], tab: 'import' | 'export' | 'sync' = 'import') { this.notebooks = notebooks; this.activeTab = tab; this.statusMessage = ''; this.selectedPages.clear(); this.selectedFile = null; + this.selectedFiles = []; + this.syncStatuses = {}; this.render(); (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open'); } @@ -125,9 +133,38 @@ class ImportExportDialog extends HTMLElement { this.setStatus('Importing...', 'info'); try { - if (this.activeSource === 'obsidian' || this.activeSource === 'logseq') { + if (this.activeSource === 'files') { + if (this.selectedFiles.length === 0) { + this.setStatus('Please select at least one file', 'error'); + this.importing = false; + return; + } + + const formData = new FormData(); + for (const f of this.selectedFiles) formData.append('files', f); + if (this.targetNotebookId) formData.append('notebookId', this.targetNotebookId); + + const token = localStorage.getItem('encryptid_token') || ''; + const res = await fetch(`/${this.space}/rnotes/api/import/files`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData, + }); + + const data = await res.json(); + if (data.ok) { + this.setStatus(`Imported ${data.imported} note${data.imported !== 1 ? 's' : ''}${data.warnings?.length ? ` (${data.warnings.length} warnings)` : ''}`, 'success'); + this.dispatchEvent(new CustomEvent('import-complete', { detail: data })); + } else { + this.setStatus(data.error || 'Import failed', 'error'); + } + } else if (this.activeSource === 'obsidian' || this.activeSource === 'logseq' || this.activeSource === 'evernote' || this.activeSource === 'roam') { if (!this.selectedFile) { - this.setStatus('Please select a ZIP file', 'error'); + const fileTypeHint: Record = { + obsidian: 'ZIP file', logseq: 'ZIP file', + evernote: '.enex file', roam: 'JSON file', + }; + this.setStatus(`Please select a ${fileTypeHint[this.activeSource] || 'file'}`, 'error'); this.importing = false; return; } @@ -299,33 +336,42 @@ class ImportExportDialog extends HTMLElement { private render() { const isApiSource = this.activeSource === 'notion' || this.activeSource === 'google-docs'; + const isFileSource = this.activeSource === 'files'; + // File-based sources (files, obsidian, logseq, evernote, roam) are always "connected" — no auth needed + const fileBased = ['files', 'obsidian', 'logseq', 'evernote', 'roam']; const sourceConnKey = this.activeSource === 'google-docs' ? 'google' : this.activeSource; - const isConnected = (this.connections as any)[sourceConnKey]?.connected || false; + const isConnected = fileBased.includes(this.activeSource) || (this.connections as any)[sourceConnKey]?.connected || false; this.shadow.innerHTML = `

-

Import / Export

+

${this.activeTab === 'sync' ? 'Sync' : this.activeTab === 'export' ? 'Export' : 'Import'} Notes

+
-
- ${(['obsidian', 'logseq', 'notion', 'google-docs'] as const).map(s => ` + ${this.activeTab !== 'sync' ? (() => { + const sources = this.activeTab === 'export' + ? (['obsidian', 'logseq', 'notion', 'google-docs'] as const) + : (['files', 'obsidian', 'logseq', 'notion', 'google-docs', 'evernote', 'roam'] as const); + return `
+ ${sources.map(s => ` `).join('')} -
+
`; + })() : ''}
- ${this.activeTab === 'import' ? this.renderImportTab(isApiSource, isConnected) : this.renderExportTab(isApiSource, isConnected)} + ${this.activeTab === 'sync' ? this.renderSyncTab() : this.activeTab === 'import' ? this.renderImportTab(isApiSource, isConnected) : this.renderExportTab(isApiSource, isConnected)}
${this.esc(this.statusMessage)} @@ -376,15 +422,47 @@ class ImportExportDialog extends HTMLElement { `; } - // File-based import (Obsidian/Logseq) + // Generic file import + if (this.activeSource === 'files') { + return ` +
+

Drop files to import as notes

+ + +

.md .txt .html .jpg .png .webp — drag & drop supported

+
+
+ + +
+ `; + } + + // File-based source import (Obsidian/Logseq/Evernote/Roam) + const acceptMap: Record = { + obsidian: '.zip', logseq: '.zip', evernote: '.enex,.zip', roam: '.json,.zip', + }; + const hintMap: Record = { + obsidian: 'Upload a ZIP of your Obsidian vault', + logseq: 'Upload a ZIP of your Logseq graph', + evernote: 'Upload an .enex export file', + roam: 'Upload a Roam Research JSON export', + }; return `
-

Upload a ZIP of your ${this.sourceName(this.activeSource)} vault

- +

${hintMap[this.activeSource] || 'Upload a file'}

+ -

or drag & drop a ZIP file here

+

or drag & drop here

@@ -399,9 +477,6 @@ class ImportExportDialog extends HTMLElement { } private renderExportTab(isApiSource: boolean, isConnected: boolean): string { - const formats = this.activeSource === 'notion' || this.activeSource === 'google-docs' - ? '' : ''; - if (isApiSource && !isConnected) { return `
@@ -425,22 +500,173 @@ class ImportExportDialog extends HTMLElement { `; } + private renderSyncTab(): string { + const statusEntries = Object.entries(this.syncStatuses); + const hasApiNotes = statusEntries.some(([_, s]) => s.source === 'notion' || s.source === 'google-docs'); + const hasFileNotes = statusEntries.some(([_, s]) => s.source === 'obsidian' || s.source === 'logseq'); + + return ` +
+ + +
+ + ${this.targetNotebookId ? ` +
+ ${statusEntries.length === 0 + ? '

No imported notes found in this notebook. Import notes first to enable sync.

' + : `

${statusEntries.length} synced note${statusEntries.length !== 1 ? 's' : ''} found

+
+ ${statusEntries.map(([id, s]) => ` +
+ ${s.source} + ${s.hasConflict ? 'conflict' : s.syncStatus || 'synced'} + ${s.lastSyncedAt ? `${this.relativeTime(s.lastSyncedAt)}` : ''} +
+ `).join('')} +
` + } +
+ + ${hasApiNotes ? ` + + ` : ''} + + ${hasFileNotes ? ` +
+

+ File-based sources require re-uploading your vault ZIP: +

+
+ + +
+
+ ` : ''} + ` : ''} + `; + } + + private relativeTime(ts: number): string { + const diff = Date.now() - ts; + if (diff < 60000) return 'just now'; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + return `${Math.floor(diff / 86400000)}d ago`; + } + + private async loadSyncStatus(notebookId: string) { + try { + const res = await fetch(`/${this.space}/rnotes/api/sync/status/${notebookId}`); + if (res.ok) { + const data = await res.json(); + this.syncStatuses = data.statuses || {}; + } + } catch { /* ignore */ } + this.render(); + (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open'); + } + + private async handleSyncApi() { + if (!this.targetNotebookId) return; + this.syncing = true; + this.setStatus('Syncing...', 'info'); + + try { + const token = localStorage.getItem('encryptid_token') || ''; + const res = await fetch(`/${this.space}/rnotes/api/sync/notebook/${this.targetNotebookId}`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + }); + + const data = await res.json(); + if (data.ok) { + const parts: string[] = []; + if (data.synced > 0) parts.push(`${data.synced} updated`); + if (data.conflicts > 0) parts.push(`${data.conflicts} conflicts`); + if (data.errors > 0) parts.push(`${data.errors} errors`); + this.setStatus(parts.length > 0 ? `Sync complete: ${parts.join(', ')}` : 'All notes up to date', 'success'); + this.dispatchEvent(new CustomEvent('sync-complete', { detail: data })); + this.loadSyncStatus(this.targetNotebookId); + } else { + this.setStatus(data.error || 'Sync failed', 'error'); + } + } catch (err) { + this.setStatus(`Sync error: ${(err as Error).message}`, 'error'); + } + + this.syncing = false; + } + + private async handleSyncUpload(file: File) { + if (!this.targetNotebookId) return; + this.syncing = true; + this.setStatus('Syncing from ZIP...', 'info'); + + // Detect source from sync statuses + const sources = new Set(Object.values(this.syncStatuses).map(s => s.source)); + const source = sources.has('obsidian') ? 'obsidian' : sources.has('logseq') ? 'logseq' : ''; + if (!source) { + this.setStatus('Could not determine source type', 'error'); + this.syncing = false; + return; + } + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('notebookId', this.targetNotebookId); + formData.append('source', source); + + const token = localStorage.getItem('encryptid_token') || ''; + const res = await fetch(`/${this.space}/rnotes/api/sync/upload`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData, + }); + + const data = await res.json(); + if (data.ok) { + this.setStatus(`Sync: ${data.synced} updated, ${data.conflicts} conflicts`, 'success'); + this.dispatchEvent(new CustomEvent('sync-complete', { detail: data })); + this.loadSyncStatus(this.targetNotebookId); + } else { + this.setStatus(data.error || 'Sync failed', 'error'); + } + } catch (err) { + this.setStatus(`Sync error: ${(err as Error).message}`, 'error'); + } + + this.syncing = false; + } + private sourceName(s: string): string { const names: Record = { + files: 'Files', obsidian: 'Obsidian', logseq: 'Logseq', notion: 'Notion', 'google-docs': 'Google Docs', + evernote: 'Evernote', + roam: 'Roam', }; return names[s] || s; } private sourceIcon(s: string): string { const icons: Record = { + files: '', obsidian: '', logseq: '', notion: '', 'google-docs': '', + evernote: '', + roam: '', }; return icons[s] || ''; } @@ -458,6 +684,11 @@ class ImportExportDialog extends HTMLElement { this.shadow.querySelectorAll('.tab').forEach(btn => { btn.addEventListener('click', () => { this.activeTab = (btn as HTMLElement).dataset.tab as any; + // Auto-select valid source when switching to export (non-exportable: files, evernote, roam) + if (this.activeTab === 'export' && ['files', 'evernote', 'roam'].includes(this.activeSource)) { + this.activeSource = 'obsidian'; + } + this.statusMessage = ''; this.render(); (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open'); }); @@ -470,6 +701,7 @@ class ImportExportDialog extends HTMLElement { this.remotePages = []; this.selectedPages.clear(); this.selectedFile = null; + this.selectedFiles = []; this.statusMessage = ''; this.render(); (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open'); @@ -481,10 +713,17 @@ class ImportExportDialog extends HTMLElement { const chooseBtn = this.shadow.getElementById('btn-choose-file'); chooseBtn?.addEventListener('click', () => fileInput?.click()); fileInput?.addEventListener('change', () => { - this.selectedFile = fileInput.files?.[0] || null; - if (chooseBtn) chooseBtn.textContent = this.selectedFile?.name || 'Choose File'; - const importBtn = this.shadow.getElementById('btn-import') as HTMLButtonElement; - if (importBtn) importBtn.disabled = !this.selectedFile; + if (this.activeSource === 'files') { + this.selectedFiles = Array.from(fileInput.files || []); + if (chooseBtn) chooseBtn.textContent = this.selectedFiles.length > 0 ? `${this.selectedFiles.length} file${this.selectedFiles.length > 1 ? 's' : ''} selected` : 'Choose Files'; + const importBtn = this.shadow.getElementById('btn-import') as HTMLButtonElement; + if (importBtn) importBtn.disabled = this.selectedFiles.length === 0; + } else { + this.selectedFile = fileInput.files?.[0] || null; + if (chooseBtn) chooseBtn.textContent = this.selectedFile?.name || 'Choose File'; + const importBtn = this.shadow.getElementById('btn-import') as HTMLButtonElement; + if (importBtn) importBtn.disabled = !this.selectedFile; + } }); // Drag & drop @@ -495,8 +734,15 @@ class ImportExportDialog extends HTMLElement { uploadArea.addEventListener('drop', (e) => { e.preventDefault(); uploadArea.classList.remove('dragover'); - const file = (e as DragEvent).dataTransfer?.files[0]; - if (file && file.name.endsWith('.zip')) { + const files = (e as DragEvent).dataTransfer?.files; + if (!files || files.length === 0) return; + if (this.activeSource === 'files') { + this.selectedFiles = Array.from(files); + if (chooseBtn) chooseBtn.textContent = `${this.selectedFiles.length} file${this.selectedFiles.length > 1 ? 's' : ''} selected`; + const importBtn = this.shadow.getElementById('btn-import') as HTMLButtonElement; + if (importBtn) importBtn.disabled = false; + } else { + const file = files[0]; this.selectedFile = file; if (chooseBtn) chooseBtn.textContent = file.name; const importBtn = this.shadow.getElementById('btn-import') as HTMLButtonElement; @@ -541,6 +787,28 @@ class ImportExportDialog extends HTMLElement { // Export button this.shadow.getElementById('btn-export')?.addEventListener('click', () => this.handleExport()); + + // Sync tab listeners + const syncNotebookSelect = this.shadow.getElementById('sync-notebook') as HTMLSelectElement; + syncNotebookSelect?.addEventListener('change', () => { + this.targetNotebookId = syncNotebookSelect.value; + if (this.targetNotebookId) { + this.loadSyncStatus(this.targetNotebookId); + } else { + this.syncStatuses = {}; + this.render(); + (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open'); + } + }); + + this.shadow.getElementById('btn-sync-api')?.addEventListener('click', () => this.handleSyncApi()); + + const syncFileInput = this.shadow.getElementById('sync-file-input') as HTMLInputElement; + this.shadow.getElementById('btn-sync-choose-file')?.addEventListener('click', () => syncFileInput?.click()); + syncFileInput?.addEventListener('change', () => { + const file = syncFileInput.files?.[0]; + if (file) this.handleSyncUpload(file); + }); } private getStyles(): string { @@ -590,7 +858,7 @@ class ImportExportDialog extends HTMLElement { .tab:hover { color: var(--rs-text-primary, #e0e0e0); } .source-bar { - display: flex; gap: 4px; padding: 12px 16px; + display: flex; gap: 4px; padding: 12px 16px; flex-wrap: wrap; border-bottom: 1px solid var(--rs-border-subtle, #2a2a4a); } .source-btn { @@ -692,6 +960,38 @@ class ImportExportDialog extends HTMLElement { .status-info { background: rgba(99,102,241,0.1); color: var(--rs-primary, #6366f1); } .status-success { background: rgba(34,197,94,0.1); color: var(--rs-success, #22c55e); } .status-error { background: rgba(239,68,68,0.1); color: var(--rs-error, #ef4444); } + + .sync-summary { margin: 12px 0; } + .sync-empty { font-size: 12px; color: var(--rs-text-muted, #666); text-align: center; padding: 20px; } + .sync-count { font-size: 12px; color: var(--rs-text-secondary, #aaa); margin: 0 0 8px; } + .sync-list { + max-height: 200px; overflow-y: auto; + border: 1px solid var(--rs-border-subtle, #2a2a4a); border-radius: 8px; + margin-bottom: 14px; + } + .sync-item { + display: flex; align-items: center; gap: 8px; padding: 6px 12px; + border-bottom: 1px solid var(--rs-border-subtle, #1a1a2e); font-size: 12px; + } + .sync-item:last-child { border-bottom: none; } + .sync-source-badge { + padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.5px; + background: rgba(99,102,241,0.15); color: var(--rs-primary, #6366f1); + } + .sync-source-badge.notion { background: rgba(255,255,255,0.08); } + .sync-source-badge.google-docs { background: rgba(66,133,244,0.15); color: #4285f4; } + .sync-source-badge.obsidian { background: rgba(126,100,255,0.15); color: #7e64ff; } + .sync-source-badge.logseq { background: rgba(133,211,127,0.15); color: #85d37f; } + .sync-status { + font-size: 10px; padding: 2px 6px; border-radius: 4px; + } + .sync-status.synced { background: rgba(34,197,94,0.1); color: var(--rs-success, #22c55e); } + .sync-status.conflict { background: rgba(239,68,68,0.1); color: var(--rs-error, #ef4444); } + .sync-status.local-modified { background: rgba(250,204,21,0.1); color: #facc15; } + .sync-status.remote-modified { background: rgba(99,102,241,0.1); color: var(--rs-primary, #6366f1); } + .sync-time { color: var(--rs-text-muted, #666); margin-left: auto; font-size: 10px; } + .sync-upload { margin-top: 12px; } `; } } diff --git a/modules/rnotes/converters/evernote.ts b/modules/rnotes/converters/evernote.ts new file mode 100644 index 0000000..0aefb68 --- /dev/null +++ b/modules/rnotes/converters/evernote.ts @@ -0,0 +1,236 @@ +/** + * Evernote ENEX → rNotes converter. + * + * Import: Parse .enex XML (ENML — strict HTML subset inside ) + * Convert ENML → markdown via Turndown. + * Extract base64 attachments, save to /data/files/uploads/. + * File-based import (.enex), no auth needed. + */ + +import TurndownService from 'turndown'; +import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap'; +import { registerConverter, hashContent } from './index'; +import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; +import type { NoteItem } from '../schemas'; + +const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); + +// Custom Turndown rules for ENML-specific elements +turndown.addRule('enMedia', { + filter: (node) => node.nodeName === 'EN-MEDIA', + replacement: (_content, node) => { + const el = node as Element; + const hash = el.getAttribute('hash') || ''; + const type = el.getAttribute('type') || ''; + if (type.startsWith('image/')) { + return `![image](resource:${hash})`; + } + return `[attachment](resource:${hash})`; + }, +}); + +turndown.addRule('enTodo', { + filter: (node) => node.nodeName === 'EN-TODO', + replacement: (_content, node) => { + const el = node as Element; + const checked = el.getAttribute('checked') === 'true'; + return checked ? '[x] ' : '[ ] '; + }, +}); + +/** Simple XML tag content extractor (avoids needing a full DOM parser on server). */ +function extractTagContent(xml: string, tagName: string): string[] { + const results: string[] = []; + const openTag = `<${tagName}`; + const closeTag = ``; + let pos = 0; + + while (true) { + const start = xml.indexOf(openTag, pos); + if (start === -1) break; + + // Find end of opening tag (handles attributes) + const tagEnd = xml.indexOf('>', start); + if (tagEnd === -1) break; + + const end = xml.indexOf(closeTag, tagEnd); + if (end === -1) break; + + results.push(xml.substring(tagEnd + 1, end)); + pos = end + closeTag.length; + } + + return results; +} + +/** Extract a single tag's text content. */ +function extractSingleTag(xml: string, tagName: string): string { + const results = extractTagContent(xml, tagName); + return results[0]?.trim() || ''; +} + +/** Extract attribute value from a tag. */ +function extractAttribute(xml: string, attrName: string): string { + const match = xml.match(new RegExp(`${attrName}="([^"]*)"`, 'i')); + return match?.[1] || ''; +} + +/** Parse a single element from ENEX. */ +function parseNote(noteXml: string): { + title: string; + content: string; + tags: string[]; + created?: string; + updated?: string; + resources: { hash: string; mime: string; data: Uint8Array; filename?: string }[]; +} { + const title = extractSingleTag(noteXml, 'title') || 'Untitled'; + + // Extract ENML content (inside CDATA) + let enml = extractSingleTag(noteXml, 'content'); + // Strip CDATA wrapper if present + enml = enml.replace(/^\s*\s*$/, ''); + + const tags: string[] = []; + const tagMatches = extractTagContent(noteXml, 'tag'); + for (const t of tagMatches) { + tags.push(t.trim().toLowerCase().replace(/\s+/g, '-')); + } + + const created = extractSingleTag(noteXml, 'created'); + const updated = extractSingleTag(noteXml, 'updated'); + + // Extract resources (attachments) + const resources: { hash: string; mime: string; data: Uint8Array; filename?: string }[] = []; + const resourceBlocks = extractTagContent(noteXml, 'resource'); + for (const resXml of resourceBlocks) { + const mime = extractSingleTag(resXml, 'mime'); + const b64Data = extractSingleTag(resXml, 'data'); + const encoding = extractAttribute(resXml, 'encoding') || 'base64'; + + // Extract recognition hash or compute from data + let hash = ''; + const recognition = extractSingleTag(resXml, 'recognition'); + if (recognition) { + // Try to get hash from recognition XML + const hashMatch = recognition.match(/objID="([^"]+)"/); + if (hashMatch) hash = hashMatch[1]; + } + + // Extract resource attributes + const resAttrs = extractSingleTag(resXml, 'resource-attributes'); + const filename = resAttrs ? extractSingleTag(resAttrs, 'file-name') : undefined; + + if (b64Data && encoding === 'base64') { + try { + // Decode base64 + const cleaned = b64Data.replace(/\s/g, ''); + const binary = atob(cleaned); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + + // Compute MD5-like hash for matching en-media tags + if (!hash) { + hash = simpleHash(bytes); + } + + resources.push({ hash, mime, data: bytes, filename }); + } catch { /* skip malformed base64 */ } + } + } + + return { title, content: enml, tags, created, updated, resources }; +} + +/** Simple hash for resource matching when recognition hash is missing. */ +function simpleHash(data: Uint8Array): string { + let h = 0; + for (let i = 0; i < Math.min(data.length, 1024); i++) { + h = ((h << 5) - h) + data[i]; + h |= 0; + } + return Math.abs(h).toString(16); +} + +const evernoteConverter: NoteConverter = { + id: 'evernote', + name: 'Evernote', + requiresAuth: false, + + async import(input: ImportInput): Promise { + if (!input.fileData) { + throw new Error('Evernote import requires an .enex file'); + } + + const enexXml = new TextDecoder().decode(input.fileData); + const noteBlocks = extractTagContent(enexXml, 'note'); + + if (noteBlocks.length === 0) { + return { notes: [], notebookTitle: 'Evernote Import', warnings: ['No notes found in ENEX file'] }; + } + + const notes: ConvertedNote[] = []; + const warnings: string[] = []; + + for (const noteXml of noteBlocks) { + try { + const parsed = parseNote(noteXml); + + // Build resource hash→filename map for en-media replacement + const resourceMap = new Map(); + for (const res of parsed.resources) { + const ext = res.mime.includes('jpeg') || res.mime.includes('jpg') ? 'jpg' + : res.mime.includes('png') ? 'png' + : res.mime.includes('gif') ? 'gif' + : res.mime.includes('webp') ? 'webp' + : res.mime.includes('pdf') ? 'pdf' + : 'bin'; + const fname = res.filename || `evernote-${res.hash}.${ext}`; + resourceMap.set(res.hash, { filename: fname, data: res.data, mimeType: res.mime }); + } + + // Convert ENML to markdown + let markdown = turndown.turndown(parsed.content); + + // Resolve resource: references to actual file paths + const attachments: { filename: string; data: Uint8Array; mimeType: string }[] = []; + markdown = markdown.replace(/resource:([a-f0-9]+)/g, (_match, hash) => { + const res = resourceMap.get(hash); + if (res) { + attachments.push(res); + return `/data/files/uploads/${res.filename}`; + } + return `resource:${hash}`; + }); + + const tiptapJson = markdownToTiptap(markdown); + const contentPlain = extractPlainTextFromTiptap(tiptapJson); + + notes.push({ + title: parsed.title, + content: tiptapJson, + contentPlain, + markdown, + tags: parsed.tags, + attachments: attachments.length > 0 ? attachments : undefined, + sourceRef: { + source: 'evernote', + externalId: `enex:${parsed.title}`, + lastSyncedAt: Date.now(), + contentHash: hashContent(markdown), + }, + }); + } catch (err) { + warnings.push(`Failed to parse note: ${(err as Error).message}`); + } + } + + return { notes, notebookTitle: 'Evernote Import', warnings }; + }, + + async export(): Promise { + throw new Error('Evernote export is not supported — use Evernote\'s native import'); + }, +}; + +registerConverter(evernoteConverter); diff --git a/modules/rnotes/converters/file-import.ts b/modules/rnotes/converters/file-import.ts new file mode 100644 index 0000000..0b9baf5 --- /dev/null +++ b/modules/rnotes/converters/file-import.ts @@ -0,0 +1,171 @@ +/** + * Generic file import for rNotes. + * + * Handles direct import of individual files: + * - .md / .txt → parse as markdown/text + * - .html → convert via Turndown + * - .jpg / .png / .webp / .gif → create IMAGE note with stored file + * + * All produce ConvertedNote with sourceRef.source = 'manual'. + */ + +import TurndownService from 'turndown'; +import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap'; +import { hashContent } from './index'; +import type { ConvertedNote } from './index'; + +const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); + +/** Dispatch file import by extension / MIME type. */ +export function importFile( + filename: string, + data: Uint8Array, + mimeType?: string, +): ConvertedNote { + const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); + const textContent = () => new TextDecoder().decode(data); + + if (ext === '.md' || ext === '.markdown') { + return importMarkdownFile(filename, textContent()); + } + if (ext === '.txt') { + return importTextFile(filename, textContent()); + } + if (ext === '.html' || ext === '.htm') { + return importHtmlFile(filename, textContent()); + } + if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'].includes(ext)) { + return importImageFile(filename, data, mimeType || guessMime(ext)); + } + + // Default: treat as text + try { + return importTextFile(filename, textContent()); + } catch { + // Binary file — store as FILE note + return importBinaryFile(filename, data, mimeType || 'application/octet-stream'); + } +} + +/** Import a markdown file. */ +export function importMarkdownFile(filename: string, content: string): ConvertedNote { + const title = titleFromFilename(filename); + const tiptapJson = markdownToTiptap(content); + const contentPlain = extractPlainTextFromTiptap(tiptapJson); + + return { + title, + content: tiptapJson, + contentPlain, + markdown: content, + tags: [], + sourceRef: { + source: 'manual', + externalId: `file:${filename}`, + lastSyncedAt: Date.now(), + contentHash: hashContent(content), + }, + }; +} + +/** Import a plain text file — wrap as simple note. */ +export function importTextFile(filename: string, content: string): ConvertedNote { + const title = titleFromFilename(filename); + const tiptapJson = markdownToTiptap(content); + const contentPlain = content; + + return { + title, + content: tiptapJson, + contentPlain, + markdown: content, + tags: [], + sourceRef: { + source: 'manual', + externalId: `file:${filename}`, + lastSyncedAt: Date.now(), + contentHash: hashContent(content), + }, + }; +} + +/** Import an HTML file — convert via Turndown. */ +export function importHtmlFile(filename: string, html: string): ConvertedNote { + const title = titleFromFilename(filename); + const markdown = turndown.turndown(html); + const tiptapJson = markdownToTiptap(markdown); + const contentPlain = extractPlainTextFromTiptap(tiptapJson); + + return { + title, + content: tiptapJson, + contentPlain, + markdown, + tags: [], + sourceRef: { + source: 'manual', + externalId: `file:${filename}`, + lastSyncedAt: Date.now(), + contentHash: hashContent(markdown), + }, + }; +} + +/** Import an image file — create IMAGE note with stored file reference. */ +export function importImageFile(filename: string, data: Uint8Array, mimeType: string): ConvertedNote { + const title = titleFromFilename(filename); + const md = `![${title}](/data/files/uploads/${filename})`; + const tiptapJson = markdownToTiptap(md); + + return { + title, + content: tiptapJson, + contentPlain: title, + markdown: md, + tags: [], + type: 'IMAGE', + attachments: [{ filename, data, mimeType }], + sourceRef: { + source: 'manual', + externalId: `file:${filename}`, + lastSyncedAt: Date.now(), + contentHash: hashContent(String(data.length)), + }, + }; +} + +/** Import a binary/unknown file as a FILE note. */ +function importBinaryFile(filename: string, data: Uint8Array, mimeType: string): ConvertedNote { + const title = titleFromFilename(filename); + const md = `[${filename}](/data/files/uploads/${filename})`; + const tiptapJson = markdownToTiptap(md); + + return { + title, + content: tiptapJson, + contentPlain: title, + markdown: md, + tags: [], + type: 'FILE', + attachments: [{ filename, data, mimeType }], + sourceRef: { + source: 'manual', + externalId: `file:${filename}`, + lastSyncedAt: Date.now(), + contentHash: hashContent(String(data.length)), + }, + }; +} + +function titleFromFilename(filename: string): string { + return filename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' '); +} + +function guessMime(ext: string): string { + const mimes: Record = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + }; + return mimes[ext] || 'application/octet-stream'; +} diff --git a/modules/rnotes/converters/google-docs.ts b/modules/rnotes/converters/google-docs.ts index 2c2514e..231991c 100644 --- a/modules/rnotes/converters/google-docs.ts +++ b/modules/rnotes/converters/google-docs.ts @@ -6,7 +6,7 @@ */ import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap'; -import { registerConverter } from './index'; +import { registerConverter, hashContent } from './index'; import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; import type { NoteItem } from '../schemas'; @@ -32,9 +32,9 @@ async function googleFetch(url: string, token: string, opts: RequestInit = {}): } /** Convert Google Docs structural elements to markdown. */ -function structuralElementToMarkdown(element: any): string { +function structuralElementToMarkdown(element: any, inlineObjects?: Record): string { if (element.paragraph) { - return paragraphToMarkdown(element.paragraph); + return paragraphToMarkdown(element.paragraph, inlineObjects); } if (element.table) { return tableToMarkdown(element.table); @@ -45,8 +45,8 @@ function structuralElementToMarkdown(element: any): string { return ''; } -/** Convert a Google Docs paragraph to markdown. */ -function paragraphToMarkdown(paragraph: any): string { +/** Convert a Google Docs paragraph to markdown (with inline image resolution context). */ +function paragraphToMarkdown(paragraph: any, inlineObjects?: Record): string { const style = paragraph.paragraphStyle?.namedStyleType || 'NORMAL_TEXT'; const elements = paragraph.elements || []; let text = ''; @@ -55,8 +55,19 @@ function paragraphToMarkdown(paragraph: any): string { if (el.textRun) { text += textRunToMarkdown(el.textRun); } else if (el.inlineObjectElement) { - // Inline images — reference only, actual URL requires separate lookup - text += `![image](inline-object)`; + const objectId = el.inlineObjectElement.inlineObjectId; + const obj = inlineObjects?.[objectId]; + if (obj) { + const imageProps = obj.inlineObjectProperties?.embeddedObject?.imageProperties; + const contentUri = imageProps?.contentUri; + if (contentUri) { + text += `![image](${contentUri})`; + } else { + text += `![image](inline-object-${objectId})`; + } + } else { + text += `![image](inline-object)`; + } } } @@ -202,12 +213,13 @@ const googleDocsConverter: NoteConverter = { const doc = await googleFetch(`${DOCS_API_BASE}/documents/${docId}`, token); const title = doc.title || 'Untitled'; - // Convert structural elements to markdown + // Convert structural elements to markdown, passing inlineObjects for image resolution const body = doc.body?.content || []; + const inlineObjects = doc.inlineObjects || {}; const mdParts: string[] = []; for (const element of body) { - const md = structuralElementToMarkdown(element); + const md = structuralElementToMarkdown(element, inlineObjects); if (md) mdParts.push(md); } @@ -215,17 +227,38 @@ const googleDocsConverter: NoteConverter = { const tiptapJson = markdownToTiptap(markdown); const contentPlain = extractPlainTextFromTiptap(tiptapJson); + // Download inline images as attachments + const attachments: { filename: string; data: Uint8Array; mimeType: string }[] = []; + for (const [objectId, obj] of Object.entries(inlineObjects) as [string, any][]) { + const imageProps = obj.inlineObjectProperties?.embeddedObject?.imageProperties; + const contentUri = imageProps?.contentUri; + if (contentUri) { + try { + const res = await fetch(contentUri, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + if (res.ok) { + const data = new Uint8Array(await res.arrayBuffer()); + const ct = res.headers.get('content-type') || 'image/png'; + const ext = ct.includes('jpeg') || ct.includes('jpg') ? 'jpg' : ct.includes('gif') ? 'gif' : ct.includes('webp') ? 'webp' : 'png'; + attachments.push({ filename: `gdocs-${objectId}.${ext}`, data, mimeType: ct }); + } + } catch { /* skip failed image downloads */ } + } + } + notes.push({ title, content: tiptapJson, contentPlain, markdown, tags: [], + attachments: attachments.length > 0 ? attachments : undefined, sourceRef: { source: 'google-docs', externalId: docId, lastSyncedAt: Date.now(), - contentHash: String(body.length), + contentHash: hashContent(markdown), }, }); } catch (err) { diff --git a/modules/rnotes/converters/index.ts b/modules/rnotes/converters/index.ts index f95e731..6e1f562 100644 --- a/modules/rnotes/converters/index.ts +++ b/modules/rnotes/converters/index.ts @@ -7,6 +7,19 @@ import type { NoteItem, SourceRef } from '../schemas'; +// ── Shared utilities ── + +/** Hash content for conflict detection (shared across all converters). */ +export function hashContent(content: string): string { + let hash = 0; + for (let i = 0; i < content.length; i++) { + const char = content.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash |= 0; + } + return Math.abs(hash).toString(36); +} + // ── Shared types ── export interface ConvertedNote { @@ -18,6 +31,8 @@ export interface ConvertedNote { sourceRef: SourceRef; /** Optional note type override */ type?: NoteItem['type']; + /** Extracted attachments (images, etc.) — saved to /data/files/uploads/ */ + attachments?: { filename: string; data: Uint8Array; mimeType: string }[]; } export interface ImportResult { @@ -95,4 +110,6 @@ export function ensureConvertersLoaded(): void { require('./logseq'); require('./notion'); require('./google-docs'); + require('./evernote'); + require('./roam'); } diff --git a/modules/rnotes/converters/logseq.ts b/modules/rnotes/converters/logseq.ts index 318831d..3b869b8 100644 --- a/modules/rnotes/converters/logseq.ts +++ b/modules/rnotes/converters/logseq.ts @@ -7,21 +7,10 @@ import JSZip from 'jszip'; import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap'; -import { registerConverter } from './index'; +import { registerConverter, hashContent } from './index'; import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; import type { NoteItem } from '../schemas'; -/** Hash content for conflict detection. */ -function hashContent(content: string): string { - let hash = 0; - for (let i = 0; i < content.length; i++) { - const char = content.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash |= 0; - } - return Math.abs(hash).toString(36); -} - /** Parse Logseq property:: value lines from the top of a page. */ function parseLogseqProperties(content: string): { properties: Record; body: string } { const lines = content.split('\n'); @@ -54,19 +43,25 @@ function convertOutlinerToMarkdown(content: string): string { for (const line of lines) { // Detect indented bullets: tabs or spaces followed by - - const match = line.match(/^(\t*|\s*)- (.*)$/); + const match = line.match(/^(\s*)- (.*)$/); if (match) { const indent = match[1]; const text = match[2]; - // Calculate nesting level - const level = indent.replace(/ /g, '\t').split('\t').length - 1; + // Calculate nesting level: count tabs, or pairs of spaces + let level = 0; + if (indent.length > 0) { + // Count actual tab characters first + const tabCount = (indent.match(/\t/g) || []).length; + const spaceCount = indent.replace(/\t/g, '').length; + level = tabCount + Math.floor(spaceCount / 2); + } // Check if this looks like a heading (common Logseq pattern) if (level === 0 && text.startsWith('# ')) { result.push(text); - } else if (level === 0 && !text.startsWith('- ')) { - // Top-level bullet → paragraph or list item + } else if (level === 0) { + // Top-level bullet → list item result.push(`- ${text}`); } else { // Nested bullet → indented list item diff --git a/modules/rnotes/converters/notion.ts b/modules/rnotes/converters/notion.ts index 47a9175..99fb149 100644 --- a/modules/rnotes/converters/notion.ts +++ b/modules/rnotes/converters/notion.ts @@ -6,7 +6,7 @@ */ import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap'; -import { registerConverter } from './index'; +import { registerConverter, hashContent } from './index'; import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; import type { NoteItem } from '../schemas'; @@ -354,7 +354,7 @@ const notionConverter: NoteConverter = { source: 'notion', externalId: pageId, lastSyncedAt: Date.now(), - contentHash: String(allBlocks.length), + contentHash: hashContent(markdown), }, }); diff --git a/modules/rnotes/converters/obsidian.ts b/modules/rnotes/converters/obsidian.ts index 17e8cbb..a5fb6c2 100644 --- a/modules/rnotes/converters/obsidian.ts +++ b/modules/rnotes/converters/obsidian.ts @@ -8,21 +8,10 @@ import JSZip from 'jszip'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap'; -import { registerConverter } from './index'; +import { registerConverter, hashContent } from './index'; import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; import type { NoteItem } from '../schemas'; -/** Hash content for conflict detection. */ -function hashContent(content: string): string { - let hash = 0; - for (let i = 0; i < content.length; i++) { - const char = content.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash |= 0; - } - return Math.abs(hash).toString(36); -} - /** Parse YAML frontmatter from an Obsidian markdown file. */ function parseFrontmatter(content: string): { frontmatter: Record; body: string } { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); @@ -83,6 +72,21 @@ const obsidianConverter: NoteConverter = { const warnings: string[] = []; let vaultName = 'Obsidian Import'; + // Build image map from non-.md files in the ZIP for embedded image resolution + const imageMap = new Map(); + const imageExts: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp' }; + zip.forEach((path, file) => { + if (file.dir) return; + const ext = path.substring(path.lastIndexOf('.')).toLowerCase(); + if (imageExts[ext]) { + // Index by basename for ![[filename.png]] lookup + const basename = path.split('/').pop()!; + imageMap.set(basename, { file, mimeType: imageExts[ext] }); + // Also index by full path + imageMap.set(path, { file, mimeType: imageExts[ext] }); + } + }); + // Find markdown files in the ZIP const mdFiles: { path: string; file: JSZip.JSZipObject }[] = []; zip.forEach((path, file) => { @@ -118,6 +122,35 @@ const obsidianConverter: NoteConverter = { let md = convertWikilinks(body); md = convertCallouts(md); + // Resolve embedded images: ![[image.png]] was converted to ![image.png](image.png) + // Also handle original ![[image.png]] syntax in case wikilinks missed it + const attachments: { filename: string; data: Uint8Array; mimeType: string }[] = []; + const imageRefPattern = /!\[([^\]]*)\]\(([^)]+)\)/g; + const resolvedImages = new Set(); + + let match: RegExpExecArray | null; + while ((match = imageRefPattern.exec(md)) !== null) { + const ref = match[2]; + const basename = ref.split('/').pop()!; + const imgEntry = imageMap.get(basename) || imageMap.get(ref); + if (imgEntry && !resolvedImages.has(basename)) { + resolvedImages.add(basename); + const data = await imgEntry.file.async('uint8array'); + attachments.push({ filename: basename, data, mimeType: imgEntry.mimeType }); + } + } + + // Replace image URLs to point to uploaded files + if (attachments.length > 0) { + md = md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (full, alt, ref) => { + const basename = ref.split('/').pop()!; + if (imageMap.has(basename) || imageMap.has(ref)) { + return `![${alt}](/data/files/uploads/${basename})`; + } + return full; + }); + } + const title = frontmatter.title || titleFromPath(path); const tiptapJson = markdownToTiptap(md); const contentPlain = extractPlainTextFromTiptap(tiptapJson); @@ -136,6 +169,7 @@ const obsidianConverter: NoteConverter = { contentPlain, markdown: md, tags: [...new Set(tags)], + attachments: attachments.length > 0 ? attachments : undefined, sourceRef: { source: 'obsidian', externalId: path, diff --git a/modules/rnotes/converters/roam.ts b/modules/rnotes/converters/roam.ts new file mode 100644 index 0000000..5a828b8 --- /dev/null +++ b/modules/rnotes/converters/roam.ts @@ -0,0 +1,171 @@ +/** + * Roam Research JSON → rNotes converter. + * + * Import: Roam JSON export ([{ title, children: [{ string, children }] }]) + * Converts recursive tree → indented markdown bullets. + * Handles Roam syntax: ((block-refs)), {{embed}}, ^^highlight^^, [[page refs]] + */ + +import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap'; +import { registerConverter, hashContent } from './index'; +import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; +import type { NoteItem } from '../schemas'; + +interface RoamBlock { + string?: string; + uid?: string; + children?: RoamBlock[]; + 'create-time'?: number; + 'edit-time'?: number; +} + +interface RoamPage { + title: string; + uid?: string; + children?: RoamBlock[]; + 'create-time'?: number; + 'edit-time'?: number; +} + +/** Convert Roam block tree to indented markdown. */ +function blocksToMarkdown(blocks: RoamBlock[], depth = 0): string { + const lines: string[] = []; + + for (const block of blocks) { + if (!block.string && (!block.children || block.children.length === 0)) continue; + + if (block.string) { + const indent = ' '.repeat(depth); + const text = convertRoamSyntax(block.string); + lines.push(`${indent}- ${text}`); + } + + if (block.children && block.children.length > 0) { + lines.push(blocksToMarkdown(block.children, depth + 1)); + } + } + + return lines.join('\n'); +} + +/** Convert Roam-specific syntax to standard markdown. */ +function convertRoamSyntax(text: string): string { + // [[page references]] → [page references](page references) + text = text.replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)'); + + // ((block refs)) → (ref) + text = text.replace(/\(\(([a-zA-Z0-9_-]+)\)\)/g, '(ref:$1)'); + + // {{embed: ((ref))}} → (embedded ref) + text = text.replace(/\{\{embed:\s*\(\(([^)]+)\)\)\}\}/g, '> (embedded: $1)'); + + // {{[[TODO]]}} and {{[[DONE]]}} + text = text.replace(/\{\{\[\[TODO\]\]\}\}/g, '- [ ]'); + text = text.replace(/\{\{\[\[DONE\]\]\}\}/g, '- [x]'); + + // ^^highlight^^ → ==highlight== (or just **highlight**) + text = text.replace(/\^\^([^^]+)\^\^/g, '**$1**'); + + // **bold** already valid markdown + // __italic__ → *italic* + text = text.replace(/__([^_]+)__/g, '*$1*'); + + return text; +} + +/** Extract tags from Roam page content (inline [[refs]] and #tags). */ +function extractRoamTags(blocks: RoamBlock[]): string[] { + const tags = new Set(); + + function walk(items: RoamBlock[]) { + for (const block of items) { + if (block.string) { + // [[page refs]] + const pageRefs = block.string.match(/\[\[([^\]]+)\]\]/g); + if (pageRefs) { + for (const ref of pageRefs) { + const tag = ref.slice(2, -2).toLowerCase().replace(/\s+/g, '-'); + if (tag.length <= 30) tags.add(tag); // Skip very long refs + } + } + // #tags + const hashTags = block.string.match(/#([a-zA-Z0-9_-]+)/g); + if (hashTags) { + for (const t of hashTags) tags.add(t.slice(1).toLowerCase()); + } + } + if (block.children) walk(block.children); + } + } + + walk(blocks); + return Array.from(tags).slice(0, 20); // Cap tags +} + +const roamConverter: NoteConverter = { + id: 'roam', + name: 'Roam Research', + requiresAuth: false, + + async import(input: ImportInput): Promise { + if (!input.fileData) { + throw new Error('Roam import requires a JSON file'); + } + + const jsonStr = new TextDecoder().decode(input.fileData); + let pages: RoamPage[]; + try { + pages = JSON.parse(jsonStr); + } catch { + throw new Error('Invalid Roam Research JSON format'); + } + + if (!Array.isArray(pages)) { + throw new Error('Expected a JSON array of Roam pages'); + } + + const notes: ConvertedNote[] = []; + const warnings: string[] = []; + + for (const page of pages) { + try { + if (!page.title) continue; + + const children = page.children || []; + const markdown = children.length > 0 + ? blocksToMarkdown(children) + : ''; + + if (!markdown.trim() && children.length === 0) continue; // Skip empty pages + + const tiptapJson = markdownToTiptap(markdown); + const contentPlain = extractPlainTextFromTiptap(tiptapJson); + const tags = extractRoamTags(children); + + notes.push({ + title: page.title, + content: tiptapJson, + contentPlain, + markdown, + tags, + sourceRef: { + source: 'roam', + externalId: page.uid || page.title, + lastSyncedAt: Date.now(), + contentHash: hashContent(markdown), + }, + }); + } catch (err) { + warnings.push(`Failed to parse page "${page.title}": ${(err as Error).message}`); + } + } + + return { notes, notebookTitle: 'Roam Research Import', warnings }; + }, + + async export(): Promise { + throw new Error('Roam Research export is not supported — use Roam\'s native import'); + }, +}; + +registerConverter(roamConverter); diff --git a/modules/rnotes/converters/sync.ts b/modules/rnotes/converters/sync.ts new file mode 100644 index 0000000..88f46f0 --- /dev/null +++ b/modules/rnotes/converters/sync.ts @@ -0,0 +1,207 @@ +/** + * 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; +} diff --git a/modules/rnotes/landing.ts b/modules/rnotes/landing.ts index 362fdc1..9cc8ac4 100644 --- a/modules/rnotes/landing.ts +++ b/modules/rnotes/landing.ts @@ -314,7 +314,8 @@ export function renderLanding(): string {

Import & Export

Bring your notes from Logseq, Obsidian, - Notion, and Google Docs. + Notion, Google Docs, Evernote, + and Roam Research. Drop any .md, .txt, or .html file directly. Export back to any format anytime — your data, your choice.

@@ -322,6 +323,9 @@ export function renderLanding(): string { Obsidian Notion Google Docs + Evernote + Roam + Files
diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index c0d6ee9..48a467e 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -17,10 +17,14 @@ import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import { notebookSchema, notebookDocId, connectionsDocId, createNoteItem } from "./schemas"; import type { NotebookDoc, NoteItem, ConnectionsDoc } from "./schemas"; -import { getConverter, getAllConverters } from "./converters/index"; +import { getConverter, getAllConverters, hashContent } from "./converters/index"; +import { importFile } from "./converters/file-import"; +import { syncNotionNote, syncGoogleDocsNote, syncFileBasedNotes } from "./converters/sync"; import type { ConvertedNote } from "./converters/index"; import type { SyncServer } from "../../server/local-first/sync-server"; import { unlockArticle } from "../../lib/article-unlock"; +import { writeFile, mkdir } from "fs/promises"; +import { join } from "path"; const routes = new Hono(); @@ -110,6 +114,11 @@ function noteToRest(item: NoteItem) { mime_type: item.mimeType, file_size: item.fileSize, duration: item.duration, + source_ref: item.sourceRef ? { + source: item.sourceRef.source, + syncStatus: item.sourceRef.syncStatus, + lastSyncedAt: item.sourceRef.lastSyncedAt, + } : undefined, created_at: new Date(item.createdAt).toISOString(), updated_at: new Date(item.updatedAt).toISOString(), }; @@ -534,6 +543,15 @@ routes.delete("/api/notes/:id", async (c) => { // ── Import/Export API ── /** Helper: import ConvertedNotes into a notebook. */ +async function saveAttachments(attachments: { filename: string; data: Uint8Array; mimeType: string }[]) { + if (attachments.length === 0) return; + const uploadDir = '/data/files/uploads'; + await mkdir(uploadDir, { recursive: true }); + for (const att of attachments) { + await writeFile(join(uploadDir, att.filename), att.data); + } +} + function importNotesIntoNotebook( space: string, notebookId: string, @@ -542,6 +560,13 @@ function importNotesIntoNotebook( let imported = 0; let updated = 0; + // Save any attachments to disk + for (const cn of convertedNotes) { + if (cn.attachments && cn.attachments.length > 0) { + saveAttachments(cn.attachments).catch(() => {}); // fire-and-forget + } + } + ensureDoc(space, notebookId); const docId = notebookDocId(space, notebookId); @@ -612,8 +637,8 @@ routes.post("/api/import/upload", async (c) => { const notebookId = formData.get("notebookId") as string | null; if (!file) return c.json({ error: "No file uploaded" }, 400); - if (!source || !['logseq', 'obsidian'].includes(source)) { - return c.json({ error: "source must be 'logseq' or 'obsidian'" }, 400); + if (!source || !['logseq', 'obsidian', 'evernote', 'roam'].includes(source)) { + return c.json({ error: "source must be 'logseq', 'obsidian', 'evernote', or 'roam'" }, 400); } const converter = getConverter(source); @@ -650,6 +675,58 @@ routes.post("/api/import/upload", async (c) => { }); }); +// POST /api/import/files — Generic file import (md, txt, html, images, etc.) +routes.post("/api/import/files", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const formData = await c.req.formData(); + const notebookId = formData.get("notebookId") as string | null; + const files: any[] = []; + for (const [key, value] of formData.entries()) { + if (key === 'files' && typeof value === 'object' && value !== null && 'arrayBuffer' in value) files.push(value); + } + if (files.length === 0) return c.json({ error: "No files uploaded" }, 400); + + const convertedNotes: ConvertedNote[] = []; + const warnings: string[] = []; + + for (const file of files) { + try { + const data = new Uint8Array(await file.arrayBuffer()); + const note = importFile(file.name, data, file.type || undefined); + convertedNotes.push(note); + } catch (err) { + warnings.push(`Failed to import ${file.name}: ${(err as Error).message}`); + } + } + + if (convertedNotes.length === 0) { + return c.json({ error: "No files could be imported", warnings }, 400); + } + + let targetNotebookId = notebookId; + if (!targetNotebookId) { + targetNotebookId = newId(); + const now = Date.now(); + ensureDoc(dataSpace, targetNotebookId); + _syncServer!.changeDoc(notebookDocId(dataSpace, targetNotebookId), "Create file import notebook", (d) => { + d.notebook.id = targetNotebookId!; + d.notebook.title = files.length === 1 ? files[0].name.replace(/\.[^.]+$/, '') : 'File Import'; + d.notebook.slug = slugify(d.notebook.title); + d.notebook.createdAt = now; + d.notebook.updatedAt = now; + }); + } + + const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, convertedNotes); + + return c.json({ ok: true, notebookId: targetNotebookId, imported, updated, warnings }); +}); + // POST /api/import/notion — Import selected Notion pages routes.post("/api/import/notion", async (c) => { const space = c.req.param("space") || "demo"; @@ -981,6 +1058,207 @@ routes.post("/api/export/google-docs", async (c) => { return c.json(resultData); }); +// ── Sync routes ── + +// POST /api/sync/note/:noteId — Sync a single note (API sources) +routes.post("/api/sync/note/:noteId", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const noteId = c.req.param("noteId"); + const found = findNote(dataSpace, noteId); + if (!found) return c.json({ error: "Note not found" }, 404); + + const note = found.item; + if (!note.sourceRef) return c.json({ error: "Note has no source reference" }, 400); + + const conn = getConnectionDoc(dataSpace); + let result; + + if (note.sourceRef.source === 'notion') { + if (!conn?.notion?.accessToken) return c.json({ error: "Notion not connected" }, 400); + result = await syncNotionNote(note, conn.notion.accessToken); + } else if (note.sourceRef.source === 'google-docs') { + if (!conn?.google?.accessToken) return c.json({ error: "Google not connected" }, 400); + result = await syncGoogleDocsNote(note, conn.google.accessToken); + } else { + return c.json({ error: `Sync not supported for source: ${note.sourceRef.source}` }, 400); + } + + // Apply sync result to Automerge doc + if (result.action === 'updated' && result.updatedContent) { + _syncServer!.changeDoc(found.docId, `Sync update ${noteId}`, (d) => { + const item = d.items[noteId]; + if (!item) return; + item.content = result.updatedContent!; + item.contentPlain = result.updatedPlain || ''; + item.contentFormat = 'tiptap-json'; + item.sourceRef!.contentHash = result.remoteHash || ''; + item.sourceRef!.lastSyncedAt = Date.now(); + item.sourceRef!.syncStatus = 'synced'; + item.updatedAt = Date.now(); + }); + } else if (result.action === 'conflict' && result.updatedContent) { + _syncServer!.changeDoc(found.docId, `Sync conflict ${noteId}`, (d) => { + const item = d.items[noteId]; + if (!item) return; + item.sourceRef!.syncStatus = 'conflict'; + item.conflictContent = result.updatedContent!; + }); + } + + return c.json({ ok: true, noteId, ...result }); +}); + +// POST /api/sync/notebook/:id — Sync all notes with sourceRef in a notebook +routes.post("/api/sync/notebook/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const notebookId = c.req.param("id"); + const docId = notebookDocId(dataSpace, notebookId); + const doc = _syncServer?.getDoc(docId); + if (!doc) return c.json({ error: "Notebook not found" }, 404); + + const conn = getConnectionDoc(dataSpace); + const results: Record = {}; + let synced = 0, conflicts = 0, errors = 0; + + for (const [noteId, note] of Object.entries(doc.items)) { + if (!note.sourceRef) continue; + + let result; + if (note.sourceRef.source === 'notion' && conn?.notion?.accessToken) { + result = await syncNotionNote(note, conn.notion.accessToken); + } else if (note.sourceRef.source === 'google-docs' && conn?.google?.accessToken) { + result = await syncGoogleDocsNote(note, conn.google.accessToken); + } else { + continue; // Skip file-based sources (need ZIP re-upload) + } + + if (result.action === 'updated' && result.updatedContent) { + _syncServer!.changeDoc(docId, `Sync update ${noteId}`, (d) => { + const item = d.items[noteId]; + if (!item) return; + item.content = result.updatedContent!; + item.contentPlain = result.updatedPlain || ''; + item.contentFormat = 'tiptap-json'; + item.sourceRef!.contentHash = result.remoteHash || ''; + item.sourceRef!.lastSyncedAt = Date.now(); + item.sourceRef!.syncStatus = 'synced'; + item.updatedAt = Date.now(); + }); + synced++; + } else if (result.action === 'conflict') { + _syncServer!.changeDoc(docId, `Sync conflict ${noteId}`, (d) => { + const item = d.items[noteId]; + if (!item) return; + item.sourceRef!.syncStatus = 'conflict'; + item.conflictContent = result.updatedContent || ''; + }); + conflicts++; + } else if (result.action === 'error') { + errors++; + } + + results[noteId] = { action: result.action, error: result.error }; + } + + return c.json({ ok: true, notebookId, synced, conflicts, errors, results }); +}); + +// POST /api/sync/upload — Re-upload vault ZIP for file-based sync (Obsidian/Logseq) +routes.post("/api/sync/upload", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const formData = await c.req.formData(); + const file = formData.get("file") as File | null; + const notebookId = formData.get("notebookId") as string; + const source = formData.get("source") as 'obsidian' | 'logseq'; + + if (!file) return c.json({ error: "No file uploaded" }, 400); + if (!notebookId) return c.json({ error: "notebookId required" }, 400); + if (!source || !['obsidian', 'logseq'].includes(source)) { + return c.json({ error: "source must be 'obsidian' or 'logseq'" }, 400); + } + + const docId = notebookDocId(dataSpace, notebookId); + const doc = _syncServer?.getDoc(docId); + if (!doc) return c.json({ error: "Notebook not found" }, 404); + + const notes = Object.values(doc.items).filter(n => n.sourceRef?.source === source); + if (notes.length === 0) { + return c.json({ error: `No ${source} notes in this notebook` }, 400); + } + + const zipData = new Uint8Array(await file.arrayBuffer()); + const results = await syncFileBasedNotes(notes, zipData, source); + + let synced = 0, conflicts = 0; + for (const [noteId, result] of results) { + if (result.action === 'updated' && result.updatedContent) { + _syncServer!.changeDoc(docId, `Sync update ${noteId}`, (d) => { + const item = d.items[noteId]; + if (!item) return; + item.content = result.updatedContent!; + item.contentPlain = result.updatedPlain || ''; + item.contentFormat = 'tiptap-json'; + item.sourceRef!.contentHash = result.remoteHash || ''; + item.sourceRef!.lastSyncedAt = Date.now(); + item.sourceRef!.syncStatus = 'synced'; + item.updatedAt = Date.now(); + }); + synced++; + } else if (result.action === 'conflict' && result.updatedContent) { + _syncServer!.changeDoc(docId, `Sync conflict ${noteId}`, (d) => { + const item = d.items[noteId]; + if (!item) return; + item.sourceRef!.syncStatus = 'conflict'; + item.conflictContent = result.updatedContent || ''; + }); + conflicts++; + } + } + + return c.json({ ok: true, notebookId, synced, conflicts, total: notes.length }); +}); + +// GET /api/sync/status/:id — Get sync status for a notebook's notes +routes.get("/api/sync/status/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + + const notebookId = c.req.param("id"); + const docId = notebookDocId(dataSpace, notebookId); + const doc = _syncServer?.getDoc(docId); + if (!doc) return c.json({ error: "Notebook not found" }, 404); + + const statuses: Record = {}; + let syncable = 0; + for (const [noteId, note] of Object.entries(doc.items)) { + if (!note.sourceRef) continue; + syncable++; + statuses[noteId] = { + source: note.sourceRef.source, + syncStatus: note.sourceRef.syncStatus, + lastSyncedAt: note.sourceRef.lastSyncedAt, + hasConflict: note.sourceRef.syncStatus === 'conflict', + }; + } + + return c.json({ notebookId, syncable, statuses }); +}); + // GET /api/connections — Status of all integrations routes.get("/api/connections", async (c) => { const space = c.req.param("space") || "demo"; @@ -1000,12 +1278,14 @@ routes.get("/api/connections", async (c) => { } : { connected: false }, logseq: { connected: true, note: "File-based, no account needed" }, obsidian: { connected: true, note: "File-based, no account needed" }, + evernote: { connected: true, note: "File-based, no account needed" }, + roam: { connected: true, note: "File-based, no account needed" }, + files: { connected: true, note: "Direct file import" }, }); }); // ── File uploads ── -import { join } from "path"; import { existsSync, mkdirSync } from "fs"; const UPLOAD_DIR = "/data/files/generated"; diff --git a/modules/rnotes/schemas.ts b/modules/rnotes/schemas.ts index 1d73a54..67acc56 100644 --- a/modules/rnotes/schemas.ts +++ b/modules/rnotes/schemas.ts @@ -14,10 +14,11 @@ import type { DocSchema } from '../../shared/local-first/document'; // ── Document types ── export interface SourceRef { - source: 'logseq' | 'obsidian' | 'notion' | 'google-docs' | 'manual'; + source: 'logseq' | 'obsidian' | 'notion' | 'google-docs' | 'evernote' | 'roam' | 'manual'; externalId: string; // Notion page ID, Google Doc ID, file path, etc. lastSyncedAt: number; contentHash?: string; // For conflict detection on re-import + syncStatus?: 'synced' | 'local-modified' | 'remote-modified' | 'conflict'; } export interface CommentMessage { @@ -58,6 +59,7 @@ export interface NoteItem { summaryModel?: string; openNotebookSourceId?: string; sourceRef?: SourceRef; + conflictContent?: string; // Stores remote version on conflict collabEnabled?: boolean; comments?: Record; createdAt: number; @@ -120,12 +122,12 @@ export function connectionsDocId(space: string) { export const notebookSchema: DocSchema = { module: 'notes', collection: 'notebooks', - version: 4, + version: 5, init: (): NotebookDoc => ({ meta: { module: 'notes', collection: 'notebooks', - version: 4, + version: 5, spaceSlug: '', createdAt: Date.now(), }, @@ -149,6 +151,7 @@ export const notebookSchema: DocSchema = { } // v2→v3: sourceRef field is optional, no migration needed // v3→v4: collabEnabled + comments fields are optional, no migration needed + // v4→v5: syncStatus on SourceRef + conflictContent on NoteItem — both optional, no migration needed return doc; }, }; diff --git a/package.json b/package.json index 5406a22..73b5ede 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@tiptap/starter-kit": "^3.20.0", "@tiptap/y-tiptap": "^3.0.2", "@types/qrcode": "^1.5.6", + "@types/turndown": "^5.0.6", "@x402/core": "^2.3.1", "@x402/evm": "^2.5.0", "@xterm/addon-fit": "^0.11.0", @@ -59,6 +60,7 @@ "postgres": "^3.4.5", "qrcode": "^1.5.4", "sharp": "^0.33.0", + "turndown": "^7.2.2", "web-push": "^3.6.7", "y-indexeddb": "^9.0.12", "y-prosemirror": "^1.3.7",