Merge branch 'dev'
This commit is contained in:
commit
616944fb91
8
bun.lock
8
bun.lock
|
|
@ -27,6 +27,7 @@
|
|||
"@tiptap/starter-kit": "^3.20.0",
|
||||
"@tiptap/y-tiptap": "^3.0.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@x402/core": "^2.3.1",
|
||||
"@x402/evm": "^2.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
|
@ -46,6 +47,7 @@
|
|||
"postgres": "^3.4.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.33.0",
|
||||
"turndown": "^7.2.2",
|
||||
"web-push": "^3.6.7",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "^1.3.7",
|
||||
|
|
@ -257,6 +259,8 @@
|
|||
|
||||
"@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="],
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||
|
||||
"@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
|
||||
|
|
@ -577,6 +581,8 @@
|
|||
|
||||
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
|
||||
|
||||
"@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
|
||||
|
|
@ -1063,6 +1069,8 @@
|
|||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">×</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; }
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ``;
|
||||
}
|
||||
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);
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* Generic file import for rNotes.
|
||||
*
|
||||
* Handles direct import of individual files:
|
||||
* - .md / .txt → parse as markdown/text
|
||||
* - .html → convert via Turndown
|
||||
* - .jpg / .png / .webp / .gif → create IMAGE note with stored file
|
||||
*
|
||||
* All produce ConvertedNote with sourceRef.source = 'manual'.
|
||||
*/
|
||||
|
||||
import TurndownService from 'turndown';
|
||||
import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap';
|
||||
import { hashContent } from './index';
|
||||
import type { ConvertedNote } from './index';
|
||||
|
||||
const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
|
||||
|
||||
/** Dispatch file import by extension / MIME type. */
|
||||
export function importFile(
|
||||
filename: string,
|
||||
data: Uint8Array,
|
||||
mimeType?: string,
|
||||
): ConvertedNote {
|
||||
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
||||
const textContent = () => new TextDecoder().decode(data);
|
||||
|
||||
if (ext === '.md' || ext === '.markdown') {
|
||||
return importMarkdownFile(filename, textContent());
|
||||
}
|
||||
if (ext === '.txt') {
|
||||
return importTextFile(filename, textContent());
|
||||
}
|
||||
if (ext === '.html' || ext === '.htm') {
|
||||
return importHtmlFile(filename, textContent());
|
||||
}
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'].includes(ext)) {
|
||||
return importImageFile(filename, data, mimeType || guessMime(ext));
|
||||
}
|
||||
|
||||
// Default: treat as text
|
||||
try {
|
||||
return importTextFile(filename, textContent());
|
||||
} catch {
|
||||
// Binary file — store as FILE note
|
||||
return importBinaryFile(filename, data, mimeType || 'application/octet-stream');
|
||||
}
|
||||
}
|
||||
|
||||
/** Import a markdown file. */
|
||||
export function importMarkdownFile(filename: string, content: string): ConvertedNote {
|
||||
const title = titleFromFilename(filename);
|
||||
const tiptapJson = markdownToTiptap(content);
|
||||
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
|
||||
|
||||
return {
|
||||
title,
|
||||
content: tiptapJson,
|
||||
contentPlain,
|
||||
markdown: content,
|
||||
tags: [],
|
||||
sourceRef: {
|
||||
source: 'manual',
|
||||
externalId: `file:${filename}`,
|
||||
lastSyncedAt: Date.now(),
|
||||
contentHash: hashContent(content),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Import a plain text file — wrap as simple note. */
|
||||
export function importTextFile(filename: string, content: string): ConvertedNote {
|
||||
const title = titleFromFilename(filename);
|
||||
const tiptapJson = markdownToTiptap(content);
|
||||
const contentPlain = content;
|
||||
|
||||
return {
|
||||
title,
|
||||
content: tiptapJson,
|
||||
contentPlain,
|
||||
markdown: content,
|
||||
tags: [],
|
||||
sourceRef: {
|
||||
source: 'manual',
|
||||
externalId: `file:${filename}`,
|
||||
lastSyncedAt: Date.now(),
|
||||
contentHash: hashContent(content),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Import an HTML file — convert via Turndown. */
|
||||
export function importHtmlFile(filename: string, html: string): ConvertedNote {
|
||||
const title = titleFromFilename(filename);
|
||||
const markdown = turndown.turndown(html);
|
||||
const tiptapJson = markdownToTiptap(markdown);
|
||||
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
|
||||
|
||||
return {
|
||||
title,
|
||||
content: tiptapJson,
|
||||
contentPlain,
|
||||
markdown,
|
||||
tags: [],
|
||||
sourceRef: {
|
||||
source: 'manual',
|
||||
externalId: `file:${filename}`,
|
||||
lastSyncedAt: Date.now(),
|
||||
contentHash: hashContent(markdown),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Import an image file — create IMAGE note with stored file reference. */
|
||||
export function importImageFile(filename: string, data: Uint8Array, mimeType: string): ConvertedNote {
|
||||
const title = titleFromFilename(filename);
|
||||
const md = ``;
|
||||
const tiptapJson = markdownToTiptap(md);
|
||||
|
||||
return {
|
||||
title,
|
||||
content: tiptapJson,
|
||||
contentPlain: title,
|
||||
markdown: md,
|
||||
tags: [],
|
||||
type: 'IMAGE',
|
||||
attachments: [{ filename, data, mimeType }],
|
||||
sourceRef: {
|
||||
source: 'manual',
|
||||
externalId: `file:${filename}`,
|
||||
lastSyncedAt: Date.now(),
|
||||
contentHash: hashContent(String(data.length)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Import a binary/unknown file as a FILE note. */
|
||||
function importBinaryFile(filename: string, data: Uint8Array, mimeType: string): ConvertedNote {
|
||||
const title = titleFromFilename(filename);
|
||||
const md = `[${filename}](/data/files/uploads/${filename})`;
|
||||
const tiptapJson = markdownToTiptap(md);
|
||||
|
||||
return {
|
||||
title,
|
||||
content: tiptapJson,
|
||||
contentPlain: title,
|
||||
markdown: md,
|
||||
tags: [],
|
||||
type: 'FILE',
|
||||
attachments: [{ filename, data, mimeType }],
|
||||
sourceRef: {
|
||||
source: 'manual',
|
||||
externalId: `file:${filename}`,
|
||||
lastSyncedAt: Date.now(),
|
||||
contentHash: hashContent(String(data.length)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function titleFromFilename(filename: string): string {
|
||||
return filename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
|
||||
}
|
||||
|
||||
function guessMime(ext: string): string {
|
||||
const mimes: Record<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';
|
||||
}
|
||||
|
|
@ -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 += ``;
|
||||
} else {
|
||||
text += ``;
|
||||
}
|
||||
} else {
|
||||
text += ``;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 
|
||||
// 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 ``;
|
||||
}
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -314,7 +314,8 @@ export function renderLanding(): string {
|
|||
<h3>Import & 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 — 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">← 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('folk-newsletter-manager', FolkNewsletterManager);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue