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 = `
+
-
- ${(['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 ``;
+ }
+ 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 = `${tagName}>`;
+ 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 = ``;
+ 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 += ``;
+ const objectId = el.inlineObjectElement.inlineObjectId;
+ const obj = inlineObjects?.[objectId];
+ if (obj) {
+ const imageProps = obj.inlineObjectProperties?.embeddedObject?.imageProperties;
+ const contentUri = imageProps?.contentUri;
+ if (contentUri) {
+ text += ``;
+ } else {
+ text += ``;
+ }
+ } else {
+ text += ``;
+ }
}
}
@@ -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 
+ // 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 ``;
+ }
+ 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
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/modules/rsocials/components/folk-newsletter-manager.ts b/modules/rsocials/components/folk-newsletter-manager.ts
index 3057322..fe5dce7 100644
--- a/modules/rsocials/components/folk-newsletter-manager.ts
+++ b/modules/rsocials/components/folk-newsletter-manager.ts
@@ -4,6 +4,7 @@
* Attributes: space, role
* Uses EncryptID access token for auth headers.
* Three tabs: Lists, Subscribers, Campaigns.
+ * Full campaign editor with list selector, HTML body, live preview, schedule/send.
*/
import { getAccessToken } from '../../../shared/components/rstack-identity';
@@ -26,6 +27,12 @@ export class FolkNewsletterManager extends HTMLElement {
private _campaigns: any[] = [];
private _showCreateForm = false;
+ // Editor state
+ private _editingCampaign: any | null = null;
+ private _selectedListIds: number[] = [];
+ private _previewHtml = '';
+ private _previewTimer: any = null;
+
static get observedAttributes() { return ['space', 'role']; }
connectedCallback() {
@@ -35,7 +42,6 @@ export class FolkNewsletterManager extends HTMLElement {
this.render();
this.checkStatus();
- // Re-check status when module is configured inline
this.addEventListener('module-configured', () => {
this._configured = false;
this._loading = true;
@@ -113,6 +119,8 @@ export class FolkNewsletterManager extends HTMLElement {
this._campaigns = data.data?.results || data.results || [];
}
+ // ── Campaign CRUD ──
+
private async createCampaign(form: HTMLFormElement) {
const fd = new FormData(form);
const body = JSON.stringify({
@@ -133,10 +141,12 @@ export class FolkNewsletterManager extends HTMLElement {
await this.loadCampaigns();
}
- private async setCampaignStatus(id: number, status: string) {
+ private async setCampaignStatus(id: number, status: string, sendAt?: string) {
+ const payload: any = { status };
+ if (sendAt) payload.send_at = sendAt;
const res = await this.apiFetch(`/campaigns/${id}/status`, {
method: 'PUT',
- body: JSON.stringify({ status }),
+ body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
@@ -148,6 +158,139 @@ export class FolkNewsletterManager extends HTMLElement {
this.render();
}
+ private async openEditor(campaignId?: number) {
+ if (campaignId) {
+ this._loading = true;
+ this.render();
+ try {
+ const res = await this.apiFetch(`/campaigns/${campaignId}`);
+ if (!res.ok) throw new Error('Failed to load campaign');
+ const data = await res.json();
+ this._editingCampaign = data.data || data;
+ this._selectedListIds = (this._editingCampaign.lists || []).map((l: any) => l.id);
+ this._previewHtml = this._editingCampaign.body || '';
+ } catch (e: any) {
+ this._error = e.message;
+ }
+ this._loading = false;
+ } else {
+ this._editingCampaign = { name: '', subject: '', body: '', lists: [] };
+ this._selectedListIds = [];
+ this._previewHtml = '';
+ }
+ // Ensure lists are loaded for the selector
+ if (this._lists.length === 0) {
+ try { await this.loadLists(); } catch {}
+ }
+ this.render();
+ }
+
+ private closeEditor() {
+ this._editingCampaign = null;
+ this._selectedListIds = [];
+ this._previewHtml = '';
+ this.loadCampaigns();
+ }
+
+ private async saveDraft() {
+ const root = this.shadowRoot!;
+ const name = (root.querySelector('[data-field="name"]') as HTMLInputElement)?.value?.trim();
+ const subject = (root.querySelector('[data-field="subject"]') as HTMLInputElement)?.value?.trim();
+ const body = (root.querySelector('[data-field="body"]') as HTMLTextAreaElement)?.value || '';
+
+ if (!name || !subject) {
+ this._error = 'Name and subject are required';
+ this.render();
+ return;
+ }
+
+ const payload: any = {
+ name,
+ subject,
+ body,
+ content_type: 'richtext',
+ type: 'regular',
+ lists: this._selectedListIds,
+ };
+
+ try {
+ let res: Response;
+ if (this._editingCampaign?.id) {
+ res = await this.apiFetch(`/campaigns/${this._editingCampaign.id}`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ });
+ } else {
+ res = await this.apiFetch('/campaigns', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+ }
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.message || err.error || 'Failed to save');
+ }
+ const data = await res.json();
+ // Update editing campaign with server response (gets ID for new campaigns)
+ this._editingCampaign = data.data || data;
+ this._error = '';
+ this.render();
+ } catch (e: any) {
+ this._error = e.message;
+ this.render();
+ }
+ }
+
+ private async deleteCampaign(id: number) {
+ if (!confirm('Delete this campaign? This cannot be undone.')) return;
+ try {
+ const res = await this.apiFetch(`/campaigns/${id}`, { method: 'DELETE' });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.message || err.error || 'Failed to delete');
+ }
+ // If we were editing this campaign, close editor
+ if (this._editingCampaign?.id === id) {
+ this._editingCampaign = null;
+ }
+ await this.loadCampaigns();
+ this.render();
+ } catch (e: any) {
+ this._error = e.message;
+ this.render();
+ }
+ }
+
+ private async sendNow() {
+ if (!this._editingCampaign?.id) {
+ this._error = 'Save the campaign first';
+ this.render();
+ return;
+ }
+ if (!confirm('Send this campaign now to the selected lists? This action cannot be undone.')) return;
+ await this.saveDraft();
+ await this.setCampaignStatus(this._editingCampaign.id, 'running');
+ this.closeEditor();
+ }
+
+ private async scheduleSend() {
+ if (!this._editingCampaign?.id) {
+ this._error = 'Save the campaign first';
+ this.render();
+ return;
+ }
+ const input = this.shadowRoot!.querySelector('[data-field="schedule"]') as HTMLInputElement;
+ const sendAt = input?.value;
+ if (!sendAt) {
+ this._error = 'Pick a date and time to schedule';
+ this.render();
+ return;
+ }
+ await this.saveDraft();
+ await this.setCampaignStatus(this._editingCampaign.id, 'scheduled', new Date(sendAt).toISOString());
+ this.closeEditor();
+ }
+
private get isAdmin(): boolean {
return this._role === 'admin';
}
@@ -169,6 +312,11 @@ export class FolkNewsletterManager extends HTMLElement {
return this.renderSetup();
}
+ // Editor mode replaces normal view
+ if (this._editingCampaign) {
+ return this.renderEditor();
+ }
+
return `