Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-20 15:54:18 -07:00
commit 616944fb91
19 changed files with 1954 additions and 126 deletions

View File

@ -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=="],

View File

@ -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%)</code></pre><p><em>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<string, string> = {
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 = `<span class="note-sync-dot" style="background:${color}" title="${n.source_ref.source} (${status})"></span>`;
}
return `
<div class="note-item" data-note="${n.id}" data-collab-id="note:${n.id}" style="border-left: 3px solid ${typeBorder}">
<span class="note-item__icon">${this.getNoteIcon(n.type)}</span>
<div class="note-item__body">
<div class="note-item__title">${n.is_pinned ? '<span class="note-item__pin">\u{1F4CC}</span> ' : ""}${this.esc(n.title)}</div>
<div class="note-item__title">${n.is_pinned ? '<span class="note-item__pin">\u{1F4CC}</span> ' : ""}${this.esc(n.title)}${syncBadge}</div>
<div class="note-item__preview">${this.esc(n.content_plain || "")}</div>
<div class="note-item__meta">
<span>${this.formatDate(n.updated_at)}</span>
@ -2696,7 +2711,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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;

View File

@ -1,9 +1,12 @@
/**
* <import-export-dialog> Modal dialog for importing/exporting notes.
* <import-export-dialog> 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<string>();
private importing = false;
private exporting = false;
private syncing = false;
private syncStatuses: Record<string, { source: string; syncStatus?: string; lastSyncedAt?: number; hasConflict: boolean }> = {};
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<string, string> = {
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 = `
<style>${this.getStyles()}</style>
<div class="dialog-overlay">
<div class="dialog">
<div class="dialog-header">
<h2>Import / Export</h2>
<h2>${this.activeTab === 'sync' ? 'Sync' : this.activeTab === 'export' ? 'Export' : 'Import'} Notes</h2>
<button class="dialog-close" id="btn-close">&times;</button>
</div>
<div class="tab-bar">
<button class="tab ${this.activeTab === 'import' ? 'active' : ''}" data-tab="import">Import</button>
<button class="tab ${this.activeTab === 'export' ? 'active' : ''}" data-tab="export">Export</button>
<button class="tab ${this.activeTab === 'sync' ? 'active' : ''}" data-tab="sync">Sync</button>
</div>
<div class="source-bar">
${(['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 `<div class="source-bar">
${sources.map(s => `
<button class="source-btn ${this.activeSource === s ? 'active' : ''}" data-source="${s}">
${this.sourceIcon(s)} ${this.sourceName(s)}
</button>
`).join('')}
</div>
</div>`;
})() : ''}
<div class="dialog-body">
${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)}
<div class="status-message ${this.statusMessage ? `status-${this.statusType}` : ''}" style="display:${this.statusMessage ? 'block' : 'none'}">
${this.esc(this.statusMessage)}
@ -376,15 +422,47 @@ class ImportExportDialog extends HTMLElement {
</button>`;
}
// File-based import (Obsidian/Logseq)
// Generic file import
if (this.activeSource === 'files') {
return `
<div class="upload-area" id="upload-area">
<p>Upload a ZIP of your ${this.sourceName(this.activeSource)} vault</p>
<input type="file" id="file-input" accept=".zip" style="display:none">
<p>Drop files to import as notes</p>
<input type="file" id="file-input" accept=".md,.txt,.html,.htm,.jpg,.jpeg,.png,.gif,.webp,.svg" multiple style="display:none">
<button class="btn-secondary" id="btn-choose-file">
${this.selectedFiles.length > 0 ? `${this.selectedFiles.length} file${this.selectedFiles.length > 1 ? 's' : ''} selected` : 'Choose Files'}
</button>
<p class="upload-hint">.md .txt .html .jpg .png .webp drag & drop supported</p>
</div>
<div class="form-row">
<label>Target notebook:</label>
<select id="target-notebook">
<option value="">Create new notebook</option>
${this.notebooks.map(nb => `<option value="${nb.id}">${this.esc(nb.title)}</option>`).join('')}
</select>
</div>
<button class="btn-primary" id="btn-import" ${this.importing || this.selectedFiles.length === 0 ? 'disabled' : ''}>
${this.importing ? 'Importing...' : `Import ${this.selectedFiles.length} File${this.selectedFiles.length !== 1 ? 's' : ''}`}
</button>`;
}
// File-based source import (Obsidian/Logseq/Evernote/Roam)
const acceptMap: Record<string, string> = {
obsidian: '.zip', logseq: '.zip', evernote: '.enex,.zip', roam: '.json,.zip',
};
const hintMap: Record<string, string> = {
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 `
<div class="upload-area" id="upload-area">
<p>${hintMap[this.activeSource] || 'Upload a file'}</p>
<input type="file" id="file-input" accept="${acceptMap[this.activeSource] || '.zip'}" style="display:none">
<button class="btn-secondary" id="btn-choose-file">
${this.selectedFile ? this.esc(this.selectedFile.name) : 'Choose File'}
</button>
<p class="upload-hint">or drag & drop a ZIP file here</p>
<p class="upload-hint">or drag & drop here</p>
</div>
<div class="form-row">
<label>Target notebook:</label>
@ -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 `
<div class="connect-prompt">
@ -425,22 +500,173 @@ class ImportExportDialog extends HTMLElement {
</button>`;
}
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 `
<div class="form-row">
<label>Notebook:</label>
<select id="sync-notebook">
<option value="">Select a notebook</option>
${this.notebooks.map(nb => `<option value="${nb.id}" ${this.targetNotebookId === nb.id ? 'selected' : ''}>${this.esc(nb.title)}</option>`).join('')}
</select>
</div>
${this.targetNotebookId ? `
<div class="sync-summary">
${statusEntries.length === 0
? '<p class="sync-empty">No imported notes found in this notebook. Import notes first to enable sync.</p>'
: `<p class="sync-count">${statusEntries.length} synced note${statusEntries.length !== 1 ? 's' : ''} found</p>
<div class="sync-list">
${statusEntries.map(([id, s]) => `
<div class="sync-item">
<span class="sync-source-badge ${s.source}">${s.source}</span>
<span class="sync-status ${s.syncStatus || 'synced'}">${s.hasConflict ? 'conflict' : s.syncStatus || 'synced'}</span>
${s.lastSyncedAt ? `<span class="sync-time">${this.relativeTime(s.lastSyncedAt)}</span>` : ''}
</div>
`).join('')}
</div>`
}
</div>
${hasApiNotes ? `
<button class="btn-primary" id="btn-sync-api" ${this.syncing ? 'disabled' : ''}>
${this.syncing ? 'Syncing...' : 'Sync API Notes (Notion / Google Docs)'}
</button>
` : ''}
${hasFileNotes ? `
<div class="sync-upload" style="margin-top: 12px;">
<p style="font-size: 12px; color: var(--rs-text-muted, #888); margin: 0 0 8px;">
File-based sources require re-uploading your vault ZIP:
</p>
<div class="upload-area" id="sync-upload-area" style="padding: 16px;">
<input type="file" id="sync-file-input" accept=".zip" style="display:none">
<button class="btn-secondary btn-sm" id="btn-sync-choose-file">Choose ZIP</button>
</div>
</div>
` : ''}
` : ''}
`;
}
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<string, string> = {
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<string, string> = {
files: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h5l2 2h5v10H2V2z"/></svg>',
obsidian: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1l6 3.5v7L8 15l-6-3.5v-7L8 1z"/></svg>',
logseq: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>',
notion: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><rect x="2" y="2" width="12" height="12" rx="2"/></svg>',
'google-docs': '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1h6l4 4v10H4V1z"/></svg>',
evernote: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1C4.7 1 2 3.7 2 7c0 2.2 1.2 4.1 3 5.2V15l3-2 3 2v-2.8c1.8-1.1 3-3 3-5.2 0-3.3-2.7-6-6-6z"/></svg>',
roam: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="2" r="1.5"/><circle cx="13" cy="11" r="1.5"/><circle cx="3" cy="11" r="1.5"/></svg>',
};
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', () => {
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; }
`;
}
}

View File

@ -0,0 +1,236 @@
/**
* Evernote ENEX rNotes converter.
*
* Import: Parse .enex XML (ENML strict HTML subset inside <en-note>)
* Convert ENML markdown via Turndown.
* Extract <resource> 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 = `</${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 <note> 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 <content> CDATA)
let enml = extractSingleTag(noteXml, 'content');
// Strip CDATA wrapper if present
enml = enml.replace(/^\s*<!\[CDATA\[/, '').replace(/\]\]>\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<ImportResult> {
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<string, { filename: string; data: Uint8Array; mimeType: string }>();
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<ExportResult> {
throw new Error('Evernote export is not supported — use Evernote\'s native import');
},
};
registerConverter(evernoteConverter);

View File

@ -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<string, string> = {
'.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';
}

View File

@ -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, any>): 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, any>): string {
const style = paragraph.paragraphStyle?.namedStyleType || 'NORMAL_TEXT';
const elements = paragraph.elements || [];
let text = '';
@ -55,10 +55,21 @@ 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
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)`;
}
}
}
// Remove trailing newline that Google Docs adds to every paragraph
text = text.replace(/\n$/, '');
@ -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) {

View File

@ -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');
}

View File

@ -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<string, string>; 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

View File

@ -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),
},
});

View File

@ -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<string, any>; 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<string, { file: JSZip.JSZipObject; mimeType: string }>();
const imageExts: Record<string, string> = { '.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<string>();
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,

View File

@ -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<string>();
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<ImportResult> {
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<ExportResult> {
throw new Error('Roam Research export is not supported — use Roam\'s native import');
},
};
registerConverter(roamConverter);

View File

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

View File

@ -314,7 +314,8 @@ export function renderLanding(): string {
<h3>Import &amp; Export</h3>
<p>
Bring your notes from <strong>Logseq</strong>, <strong>Obsidian</strong>,
<strong>Notion</strong>, and <strong>Google Docs</strong>.
<strong>Notion</strong>, <strong>Google Docs</strong>, <strong>Evernote</strong>,
and <strong>Roam Research</strong>. Drop any .md, .txt, or .html file directly.
Export back to any format anytime &mdash; your data, your choice.
</p>
<div style="display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.5rem">
@ -322,6 +323,9 @@ export function renderLanding(): string {
<span class="rl-badge" style="background:rgba(139,92,246,0.2);color:#8b5cf6">Obsidian</span>
<span class="rl-badge" style="background:rgba(59,130,246,0.2);color:#3b82f6">Notion</span>
<span class="rl-badge" style="background:rgba(245,158,11,0.2);color:#f59e0b">Google Docs</span>
<span class="rl-badge" style="background:rgba(16,185,129,0.2);color:#10b981">Evernote</span>
<span class="rl-badge" style="background:rgba(236,72,153,0.2);color:#ec4899">Roam</span>
<span class="rl-badge" style="background:rgba(107,114,128,0.2);color:#9ca3af">Files</span>
</div>
</div>

View File

@ -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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(docId);
if (!doc) return c.json({ error: "Notebook not found" }, 404);
const conn = getConnectionDoc(dataSpace);
const results: Record<string, { action: string; error?: string }> = {};
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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(docId);
if (!doc) return c.json({ error: "Notebook not found" }, 404);
const statuses: Record<string, { source: string; syncStatus?: string; lastSyncedAt?: number; hasConflict: boolean }> = {};
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";

View File

@ -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<string, CommentThread>;
createdAt: number;
@ -120,12 +122,12 @@ export function connectionsDocId(space: string) {
export const notebookSchema: DocSchema<NotebookDoc> = {
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<NotebookDoc> = {
}
// 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;
},
};

View File

@ -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 `
<div class="nl-header">
<h2>Newsletter Manager</h2>
@ -197,18 +345,22 @@ export class FolkNewsletterManager extends HTMLElement {
private renderLists(): string {
if (this._lists.length === 0) return `<div class="nl-empty">No mailing lists found</div>`;
const rows = this._lists.map(l => `
const rows = this._lists.map(l => {
const optinLabel = l.optin === 'double' ? 'double' : 'single';
return `
<tr>
<td>${this.esc(l.name)}</td>
<td><span class="nl-badge nl-badge--${l.type === 'public' ? 'active' : 'draft'}">${this.esc(l.type)}</span></td>
<td>${l.subscriber_count ?? '—'}</td>
<td><span class="nl-badge nl-badge--enabled">${l.subscriber_count ?? 0}</span></td>
<td><span class="nl-badge nl-badge--${optinLabel === 'double' ? 'active' : 'draft'}">${optinLabel} opt-in</span></td>
<td>${l.created_at ? new Date(l.created_at).toLocaleDateString() : '—'}</td>
</tr>
`).join('');
`;
}).join('');
return `
<table class="nl-table">
<thead><tr><th>Name</th><th>Type</th><th>Subscribers</th><th>Created</th></tr></thead>
<thead><tr><th>Name</th><th>Type</th><th>Subscribers</th><th>Opt-in</th><th>Created</th></tr></thead>
<tbody>${rows}</tbody>
</table>
`;
@ -249,32 +401,11 @@ export class FolkNewsletterManager extends HTMLElement {
const createBtn = this.isAdmin ? `
<div class="nl-toolbar">
<span></span>
<button class="nl-btn nl-btn--primary" data-action="toggle-create">+ New Campaign</button>
<button class="nl-btn nl-btn--primary" data-action="new-campaign">+ New Campaign</button>
</div>
` : '';
const form = this._showCreateForm && this.isAdmin ? `
<form class="nl-form" data-form="create-campaign">
<div class="nl-form-row">
<div>
<label>Campaign Name</label>
<input name="name" required placeholder="My Newsletter #1" />
</div>
<div>
<label>Subject Line</label>
<input name="subject" required placeholder="This week's update" />
</div>
</div>
<label>Body (HTML)</label>
<textarea name="body" placeholder="<p>Newsletter content goes here...</p>"></textarea>
<div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.5rem;">
<button type="button" class="nl-btn" data-action="toggle-create">Cancel</button>
<button type="submit" class="nl-btn nl-btn--primary">Create Campaign</button>
</div>
</form>
` : '';
if (this._campaigns.length === 0 && !this._showCreateForm) {
if (this._campaigns.length === 0) {
return `${createBtn}<div class="nl-empty">No campaigns found</div>`;
}
@ -284,36 +415,106 @@ export class FolkNewsletterManager extends HTMLElement {
};
const rows = this._campaigns.map(c => {
const listNames = (c.lists || []).map((l: any) => this.esc(l.name)).join(', ') || '—';
const actions: string[] = [];
if (c.status === 'draft' && this.isAdmin) {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="edit-campaign" data-id="${c.id}">Edit</button>`);
actions.push(`<button class="nl-btn nl-btn--sm" data-action="start-campaign" data-id="${c.id}">Start</button>`);
actions.push(`<button class="nl-btn nl-btn--sm nl-btn--danger" data-action="delete-campaign" data-id="${c.id}">Delete</button>`);
} else if (c.status === 'running') {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="pause-campaign" data-id="${c.id}">Pause</button>`);
} else if (c.status === 'paused') {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="resume-campaign" data-id="${c.id}">Resume</button>`);
} else if (c.status === 'scheduled' && this.isAdmin) {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="edit-campaign" data-id="${c.id}">Edit</button>`);
}
return `
<tr>
<td>${this.esc(c.name)}</td>
<td>${this.esc(c.subject || '—')}</td>
<td><span class="nl-badge nl-badge--${statusLabel(c.status)}">${this.esc(c.status)}</span></td>
<td>${listNames}</td>
<td>${c.sent ?? '—'}</td>
<td>${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'}</td>
<td>${actions.join(' ')}</td>
<td class="nl-actions-cell">${actions.join(' ')}</td>
</tr>
`;
}).join('');
return `
${createBtn}
${form}
<table class="nl-table">
<thead><tr><th>Name</th><th>Subject</th><th>Status</th><th>Sent</th><th>Created</th><th>Actions</th></tr></thead>
<thead><tr><th>Name</th><th>Subject</th><th>Status</th><th>Lists</th><th>Sent</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>${rows}</tbody>
</table>
`;
}
private renderEditor(): string {
const c = this._editingCampaign;
const isNew = !c.id;
const title = isNew ? 'New Campaign' : `Edit: ${this.esc(c.name)}`;
const listCheckboxes = this._lists.map(l => {
const checked = this._selectedListIds.includes(l.id) ? 'checked' : '';
return `
<label class="nl-list-option">
<input type="checkbox" data-list-id="${l.id}" ${checked} />
<span>${this.esc(l.name)}</span>
<span class="nl-list-count">${l.subscriber_count ?? 0}</span>
</label>
`;
}).join('');
return `
<div class="nl-editor-header">
<button class="nl-btn" data-action="close-editor">&larr; Back</button>
<h2>${title}</h2>
</div>
${this._error ? `<div class="nl-error">${this.esc(this._error)}</div>` : ''}
<div class="nl-editor-fields">
<div class="nl-form-row">
<div>
<label>Campaign Name</label>
<input data-field="name" value="${this.escAttr(c.name || '')}" placeholder="My Newsletter #1" />
</div>
<div>
<label>Subject Line</label>
<input data-field="subject" value="${this.escAttr(c.subject || '')}" placeholder="This week's update" />
</div>
</div>
<label>Target Lists</label>
<div class="nl-list-selector">
${listCheckboxes || '<span class="nl-empty">No lists available</span>'}
</div>
</div>
<div class="nl-editor">
<div class="nl-editor__body">
<label>Body (HTML)</label>
<textarea data-field="body" placeholder="<p>Newsletter content goes here...</p>">${this.esc(c.body || '')}</textarea>
</div>
<div class="nl-editor__preview">
<label>Preview</label>
<iframe class="nl-preview-frame" sandbox="allow-same-origin" srcdoc="${this.escAttr(this._previewHtml || c.body || '')}"></iframe>
</div>
</div>
<div class="nl-editor-actions">
<div class="nl-editor-actions__left">
<button class="nl-btn" data-action="close-editor">Cancel</button>
${c.id ? `<button class="nl-btn nl-btn--danger" data-action="delete-campaign" data-id="${c.id}">Delete</button>` : ''}
</div>
<div class="nl-editor-actions__right">
<button class="nl-btn nl-btn--primary" data-action="save-draft">Save Draft</button>
<div class="nl-schedule-group">
<input type="datetime-local" data-field="schedule" />
<button class="nl-btn" data-action="schedule-send">Schedule</button>
</div>
<button class="nl-btn nl-btn--send" data-action="send-now">Send Now</button>
</div>
</div>
`;
}
// ── Event listeners ──
private attachListeners() {
@ -336,7 +537,12 @@ export class FolkNewsletterManager extends HTMLElement {
this.loadTab();
});
// Create campaign toggle
// New campaign (opens editor)
root.querySelector('[data-action="new-campaign"]')?.addEventListener('click', () => {
this.openEditor();
});
// Create campaign toggle (legacy, kept for form)
root.querySelectorAll('[data-action="toggle-create"]').forEach(btn => {
btn.addEventListener('click', () => {
this._showCreateForm = !this._showCreateForm;
@ -344,7 +550,7 @@ export class FolkNewsletterManager extends HTMLElement {
});
});
// Create campaign form
// Create campaign form (legacy)
const form = root.querySelector('[data-form="create-campaign"]') as HTMLFormElement | null;
form?.addEventListener('submit', async (e) => {
e.preventDefault();
@ -367,6 +573,47 @@ export class FolkNewsletterManager extends HTMLElement {
root.querySelectorAll('[data-action="resume-campaign"]').forEach(btn => {
btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running'));
});
// Edit campaign
root.querySelectorAll('[data-action="edit-campaign"]').forEach(btn => {
btn.addEventListener('click', () => this.openEditor(Number((btn as HTMLElement).dataset.id)));
});
// Delete campaign
root.querySelectorAll('[data-action="delete-campaign"]').forEach(btn => {
btn.addEventListener('click', () => this.deleteCampaign(Number((btn as HTMLElement).dataset.id)));
});
// Editor controls
root.querySelectorAll('[data-action="close-editor"]').forEach(btn => {
btn.addEventListener('click', () => this.closeEditor());
});
root.querySelector('[data-action="save-draft"]')?.addEventListener('click', () => this.saveDraft());
root.querySelector('[data-action="send-now"]')?.addEventListener('click', () => this.sendNow());
root.querySelector('[data-action="schedule-send"]')?.addEventListener('click', () => this.scheduleSend());
// List selector checkboxes
root.querySelectorAll('[data-list-id]').forEach(cb => {
cb.addEventListener('change', () => {
const id = Number((cb as HTMLElement).dataset.listId);
if ((cb as HTMLInputElement).checked) {
if (!this._selectedListIds.includes(id)) this._selectedListIds.push(id);
} else {
this._selectedListIds = this._selectedListIds.filter(x => x !== id);
}
});
});
// Live preview update on body textarea input
const bodyTextarea = root.querySelector('[data-field="body"]') as HTMLTextAreaElement | null;
bodyTextarea?.addEventListener('input', () => {
clearTimeout(this._previewTimer);
this._previewTimer = setTimeout(() => {
this._previewHtml = bodyTextarea.value;
const iframe = root.querySelector('.nl-preview-frame') as HTMLIFrameElement | null;
if (iframe) iframe.srcdoc = this._previewHtml;
}, 300);
});
}
private esc(s: string): string {
@ -374,6 +621,10 @@ export class FolkNewsletterManager extends HTMLElement {
d.textContent = s;
return d.innerHTML;
}
private escAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
}
customElements.define('folk-newsletter-manager', FolkNewsletterManager);

View File

@ -60,5 +60,54 @@
.nl-form-row { display: flex; gap: .75rem; }
.nl-form-row > * { flex: 1; }
/* Danger button */
.nl-btn--danger { border-color: #ef4444; color: #f87171; }
.nl-btn--danger:hover { background: #ef444422; border-color: #f87171; }
.nl-btn--send { background: #6366f1; border-color: #6366f1; color: #fff; font-weight: 500; }
.nl-btn--send:hover { background: #4f46e5; }
/* Actions cell */
.nl-actions-cell { white-space: nowrap; }
.nl-actions-cell .nl-btn { margin-right: .25rem; }
/* Error */
.nl-error { padding: .75rem 1rem; background: #ef444422; border: 1px solid #ef4444; border-radius: 6px; color: #f87171; font-size: .85rem; margin-bottom: 1rem; }
/* ── Editor ── */
.nl-editor-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.nl-editor-header h2 { font-size: 1.3rem; margin: 0; }
.nl-editor-fields { margin-bottom: 1rem; }
.nl-editor-fields label { display: block; font-size: .8rem; color: #a3a3a3; margin-bottom: .25rem; }
.nl-editor-fields input { width: 100%; padding: .45rem .7rem; background: #0a0a0a; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: .85rem; margin-bottom: .75rem; box-sizing: border-box; }
/* List selector */
.nl-list-selector { display: flex; flex-wrap: wrap; gap: .5rem; padding: .5rem; background: #1e1e2e; border: 1px solid #333; border-radius: 6px; margin-bottom: 1rem; min-height: 2.5rem; align-items: center; }
.nl-list-option { display: flex; align-items: center; gap: .4rem; padding: .3rem .6rem; background: #0a0a0a; border: 1px solid #333; border-radius: 4px; cursor: pointer; font-size: .82rem; transition: border-color .15s; }
.nl-list-option:hover { border-color: #14b8a6; }
.nl-list-option input[type="checkbox"] { accent-color: #14b8a6; }
.nl-list-count { color: #a3a3a3; font-size: .75rem; margin-left: .25rem; }
/* Editor body + preview */
.nl-editor { display: flex; gap: 1rem; margin-bottom: 1rem; }
.nl-editor__body { flex: 1; display: flex; flex-direction: column; }
.nl-editor__body label { font-size: .8rem; color: #a3a3a3; margin-bottom: .25rem; }
.nl-editor__body textarea { flex: 1; min-height: 300px; padding: .6rem .8rem; background: #0a0a0a; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: .85rem; font-family: 'Fira Code', 'Cascadia Code', monospace; resize: vertical; box-sizing: border-box; line-height: 1.5; }
.nl-editor__preview { flex: 1; display: flex; flex-direction: column; }
.nl-editor__preview label { font-size: .8rem; color: #a3a3a3; margin-bottom: .25rem; }
.nl-preview-frame { flex: 1; min-height: 300px; border: 1px solid #404040; border-radius: 4px; background: #fff; }
/* Editor action bar */
.nl-editor-actions { display: flex; justify-content: space-between; align-items: center; gap: .75rem; flex-wrap: wrap; padding-top: .75rem; border-top: 1px solid #333; }
.nl-editor-actions__left, .nl-editor-actions__right { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.nl-schedule-group { display: flex; align-items: center; gap: .35rem; }
.nl-schedule-group input[type="datetime-local"] { padding: .4rem .5rem; background: #0a0a0a; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: .82rem; }
/* Responsive: stack editor panes vertically on narrow screens */
@media (max-width: 700px) {
.nl-editor { flex-direction: column; }
.nl-editor-actions { flex-direction: column; align-items: stretch; }
.nl-editor-actions__left, .nl-editor-actions__right { justify-content: center; }
.nl-form-row { flex-direction: column; }
.nl-schedule-group { flex-wrap: wrap; }
}

View File

@ -517,6 +517,51 @@ routes.put("/api/newsletter/campaigns/:id/status", async (c) => {
return c.json(data, res.status as any);
});
// Single campaign CRUD
routes.get("/api/newsletter/campaigns/:id", async (c) => {
const auth = await requireNewsletterRole(c, "moderator");
if (auth instanceof Response) return auth;
const space = c.req.param("space") || "demo";
const config = await getListmonkConfig(space);
if (!config) return c.json({ error: "Listmonk not configured" }, 404);
const campaignId = c.req.param("id");
const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`);
const data = await res.json();
return c.json(data, res.status as any);
});
routes.put("/api/newsletter/campaigns/:id", async (c) => {
const auth = await requireNewsletterRole(c, "admin");
if (auth instanceof Response) return auth;
const space = c.req.param("space") || "demo";
const config = await getListmonkConfig(space);
if (!config) return c.json({ error: "Listmonk not configured" }, 404);
const campaignId = c.req.param("id");
const body = await c.req.text();
const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`, { method: "PUT", body });
const data = await res.json();
return c.json(data, res.status as any);
});
routes.delete("/api/newsletter/campaigns/:id", async (c) => {
const auth = await requireNewsletterRole(c, "admin");
if (auth instanceof Response) return auth;
const space = c.req.param("space") || "demo";
const config = await getListmonkConfig(space);
if (!config) return c.json({ error: "Listmonk not configured" }, 404);
const campaignId = c.req.param("id");
const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`, { method: "DELETE" });
if (res.status === 200 || res.status === 204) return c.json({ ok: true });
const data = await res.json().catch(() => ({}));
return c.json(data, res.status as any);
});
// ── Postiz API proxy routes ──
routes.get("/api/postiz/status", async (c) => {

View File

@ -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",