/** * — Modal dialog for importing/exporting notes. * * Supports 4 sources: Logseq, Obsidian, Notion, Google Docs. * File-based (Logseq/Obsidian) use ZIP upload/download. * API-based (Notion/Google) use OAuth connections. */ interface NotebookOption { id: string; title: string; } interface ConnectionStatus { notion: { connected: boolean; workspaceName?: string }; google: { connected: boolean; email?: string }; logseq: { connected: boolean }; obsidian: { connected: boolean }; } interface RemotePage { id: string; title: string; lastEdited?: string; lastModified?: string; icon?: string; } class ImportExportDialog extends HTMLElement { private shadow!: ShadowRoot; private space = ''; private activeTab: 'import' | 'export' = 'import'; private activeSource: 'obsidian' | 'logseq' | 'notion' | 'google-docs' = 'obsidian'; private notebooks: NotebookOption[] = []; private connections: ConnectionStatus = { notion: { connected: false }, google: { connected: false }, logseq: { connected: true }, obsidian: { connected: true }, }; private remotePages: RemotePage[] = []; private selectedPages = new Set(); private importing = false; private exporting = false; private statusMessage = ''; private statusType: 'info' | 'success' | 'error' = 'info'; private selectedFile: File | null = null; private targetNotebookId = ''; constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); } connectedCallback() { this.space = this.getAttribute('space') || 'demo'; this.render(); this.loadConnections(); } /** Open the dialog. */ open(notebooks: NotebookOption[], tab: 'import' | 'export' = 'import') { this.notebooks = notebooks; this.activeTab = tab; this.statusMessage = ''; this.selectedPages.clear(); this.selectedFile = null; this.render(); (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open'); } /** Close the dialog. */ close() { (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.remove('open'); } private async loadConnections() { try { const res = await fetch(`/${this.space}/rnotes/api/connections`); if (res.ok) { this.connections = await res.json(); } } catch { /* ignore */ } } private async loadRemotePages() { this.remotePages = []; this.selectedPages.clear(); if (this.activeSource === 'notion') { try { const res = await fetch(`/${this.space}/rnotes/api/import/notion/pages`); if (res.ok) { const data = await res.json(); this.remotePages = data.pages || []; } } catch { /* ignore */ } } else if (this.activeSource === 'google-docs') { try { const res = await fetch(`/${this.space}/rnotes/api/import/google-docs/list`); if (res.ok) { const data = await res.json(); this.remotePages = data.docs || []; } } catch { /* ignore */ } } this.render(); (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open'); } private setStatus(msg: string, type: 'info' | 'success' | 'error' = 'info') { this.statusMessage = msg; this.statusType = type; const statusEl = this.shadow.querySelector('.status-message') as HTMLElement; if (statusEl) { statusEl.textContent = msg; statusEl.className = `status-message status-${type}`; statusEl.style.display = msg ? 'block' : 'none'; } } private async handleImport() { this.importing = true; this.setStatus('Importing...', 'info'); try { if (this.activeSource === 'obsidian' || this.activeSource === 'logseq') { if (!this.selectedFile) { this.setStatus('Please select a ZIP file', 'error'); this.importing = false; return; } const formData = new FormData(); formData.append('file', this.selectedFile); formData.append('source', this.activeSource); if (this.targetNotebookId) { formData.append('notebookId', this.targetNotebookId); } const token = localStorage.getItem('encryptid_token') || ''; const res = await fetch(`/${this.space}/rnotes/api/import/upload`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData, }); const data = await res.json(); if (data.ok) { this.setStatus( `Imported ${data.imported} notes${data.updated ? `, updated ${data.updated}` : ''}${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 === 'notion') { if (this.selectedPages.size === 0) { this.setStatus('Please select at least one page', 'error'); this.importing = false; return; } const token = localStorage.getItem('encryptid_token') || ''; const res = await fetch(`/${this.space}/rnotes/api/import/notion`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ pageIds: Array.from(this.selectedPages), notebookId: this.targetNotebookId || undefined, }), }); const data = await res.json(); if (data.ok) { this.setStatus(`Imported ${data.imported} notes from Notion`, 'success'); this.dispatchEvent(new CustomEvent('import-complete', { detail: data })); } else { this.setStatus(data.error || 'Notion import failed', 'error'); } } else if (this.activeSource === 'google-docs') { if (this.selectedPages.size === 0) { this.setStatus('Please select at least one document', 'error'); this.importing = false; return; } const token = localStorage.getItem('encryptid_token') || ''; const res = await fetch(`/${this.space}/rnotes/api/import/google-docs`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ docIds: Array.from(this.selectedPages), notebookId: this.targetNotebookId || undefined, }), }); const data = await res.json(); if (data.ok) { this.setStatus(`Imported ${data.imported} notes from Google Docs`, 'success'); this.dispatchEvent(new CustomEvent('import-complete', { detail: data })); } else { this.setStatus(data.error || 'Google Docs import failed', 'error'); } } } catch (err) { this.setStatus(`Import error: ${(err as Error).message}`, 'error'); } this.importing = false; } private async handleExport() { if (!this.targetNotebookId) { this.setStatus('Please select a notebook to export', 'error'); return; } this.exporting = true; this.setStatus('Exporting...', 'info'); try { if (this.activeSource === 'obsidian' || this.activeSource === 'logseq' || this.activeSource === 'markdown' as any) { const format = this.activeSource === 'markdown' as any ? 'markdown' : this.activeSource; const url = `/${this.space}/rnotes/api/export/${format}?notebookId=${encodeURIComponent(this.targetNotebookId)}`; const res = await fetch(url); if (res.ok) { const blob = await res.blob(); const disposition = res.headers.get('Content-Disposition') || ''; const filename = disposition.match(/filename="(.+)"/)?.[1] || `export-${format}.zip`; const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); this.setStatus('Download started!', 'success'); } else { const data = await res.json(); this.setStatus(data.error || 'Export failed', 'error'); } } else if (this.activeSource === 'notion') { const token = localStorage.getItem('encryptid_token') || ''; const res = await fetch(`/${this.space}/rnotes/api/export/notion`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ notebookId: this.targetNotebookId }), }); const data = await res.json(); if (data.exported) { this.setStatus(`Exported ${data.exported.length} notes to Notion`, 'success'); } else { this.setStatus(data.error || 'Notion export failed', 'error'); } } else if (this.activeSource === 'google-docs') { const token = localStorage.getItem('encryptid_token') || ''; const res = await fetch(`/${this.space}/rnotes/api/export/google-docs`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ notebookId: this.targetNotebookId }), }); const data = await res.json(); if (data.exported) { this.setStatus(`Exported ${data.exported.length} notes to Google Docs`, 'success'); } else { this.setStatus(data.error || 'Google Docs export failed', 'error'); } } } catch (err) { this.setStatus(`Export error: ${(err as Error).message}`, 'error'); } this.exporting = false; } private esc(s: string): string { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; } private render() { const isApiSource = this.activeSource === 'notion' || this.activeSource === 'google-docs'; const sourceConnKey = this.activeSource === 'google-docs' ? 'google' : this.activeSource; const isConnected = (this.connections as any)[sourceConnKey]?.connected || false; this.shadow.innerHTML = `

Import / Export

${(['obsidian', 'logseq', 'notion', 'google-docs'] as const).map(s => ` `).join('')}
${this.activeTab === 'import' ? this.renderImportTab(isApiSource, isConnected) : this.renderExportTab(isApiSource, isConnected)}
${this.esc(this.statusMessage)}
`; this.attachListeners(); } private renderImportTab(isApiSource: boolean, isConnected: boolean): string { if (isApiSource && !isConnected) { return `

Connect your ${this.sourceName(this.activeSource)} account to import notes.

`; } if (isApiSource) { // Show page list for selection return `
Select pages to import:
${this.remotePages.length === 0 ? '
No pages found. Click Refresh to load.
' : this.remotePages.map(p => ` `).join('')}
`; } // File-based import (Obsidian/Logseq) return `

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

or drag & drop a ZIP file here

`; } private renderExportTab(isApiSource: boolean, isConnected: boolean): string { const formats = this.activeSource === 'notion' || this.activeSource === 'google-docs' ? '' : ''; if (isApiSource && !isConnected) { return `

Connect your ${this.sourceName(this.activeSource)} account to export notes.

`; } return `
`; } private sourceName(s: string): string { const names: Record = { obsidian: 'Obsidian', logseq: 'Logseq', notion: 'Notion', 'google-docs': 'Google Docs', }; return names[s] || s; } private sourceIcon(s: string): string { const icons: Record = { obsidian: '', logseq: '', notion: '', 'google-docs': '', }; return icons[s] || ''; } private attachListeners() { // Close button this.shadow.getElementById('btn-close')?.addEventListener('click', () => this.close()); // Overlay click to close this.shadow.querySelector('.dialog-overlay')?.addEventListener('click', (e) => { if ((e.target as HTMLElement).classList.contains('dialog-overlay')) this.close(); }); // Tab switching this.shadow.querySelectorAll('.tab').forEach(btn => { btn.addEventListener('click', () => { this.activeTab = (btn as HTMLElement).dataset.tab as any; this.render(); (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open'); }); }); // Source switching this.shadow.querySelectorAll('.source-btn').forEach(btn => { btn.addEventListener('click', () => { this.activeSource = (btn as HTMLElement).dataset.source as any; this.remotePages = []; this.selectedPages.clear(); this.selectedFile = null; this.statusMessage = ''; this.render(); (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open'); }); }); // File input const fileInput = this.shadow.getElementById('file-input') as HTMLInputElement; const chooseBtn = this.shadow.getElementById('btn-choose-file'); chooseBtn?.addEventListener('click', () => fileInput?.click()); fileInput?.addEventListener('change', () => { this.selectedFile = fileInput.files?.[0] || null; if (chooseBtn) chooseBtn.textContent = this.selectedFile?.name || 'Choose File'; const importBtn = this.shadow.getElementById('btn-import') as HTMLButtonElement; if (importBtn) importBtn.disabled = !this.selectedFile; }); // Drag & drop const uploadArea = this.shadow.getElementById('upload-area'); if (uploadArea) { uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('dragover'); }); uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover')); uploadArea.addEventListener('drop', (e) => { e.preventDefault(); uploadArea.classList.remove('dragover'); const file = (e as DragEvent).dataTransfer?.files[0]; if (file && file.name.endsWith('.zip')) { this.selectedFile = file; if (chooseBtn) chooseBtn.textContent = file.name; const importBtn = this.shadow.getElementById('btn-import') as HTMLButtonElement; if (importBtn) importBtn.disabled = false; } }); } // Target notebook select const notebookSelect = this.shadow.getElementById('target-notebook') as HTMLSelectElement; notebookSelect?.addEventListener('change', () => { this.targetNotebookId = notebookSelect.value; }); // Page checkboxes this.shadow.querySelectorAll('.page-item input[type="checkbox"]').forEach(cb => { cb.addEventListener('change', () => { const input = cb as HTMLInputElement; if (input.checked) { this.selectedPages.add(input.value); } else { this.selectedPages.delete(input.value); } const importBtn = this.shadow.getElementById('btn-import'); if (importBtn) importBtn.textContent = `Import Selected (${this.selectedPages.size})`; }); }); // Refresh pages this.shadow.getElementById('btn-refresh-pages')?.addEventListener('click', () => { this.loadRemotePages(); }); // Connect button this.shadow.getElementById('btn-connect')?.addEventListener('click', () => { const provider = this.activeSource === 'google-docs' ? 'google' : this.activeSource; window.location.href = `/api/oauth/${provider}/authorize?space=${this.space}`; }); // Import button this.shadow.getElementById('btn-import')?.addEventListener('click', () => this.handleImport()); // Export button this.shadow.getElementById('btn-export')?.addEventListener('click', () => this.handleExport()); } private getStyles(): string { return ` :host { display: block; } .dialog-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); z-index: 10000; justify-content: center; align-items: center; } .dialog-overlay.open { display: flex; } .dialog { background: var(--rs-bg-surface, #1a1a2e); border: 1px solid var(--rs-border, #2a2a4a); border-radius: 16px; width: 560px; max-width: 95vw; max-height: 80vh; display: flex; flex-direction: column; box-shadow: 0 24px 80px rgba(0,0,0,0.5); } .dialog-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--rs-border-subtle, #2a2a4a); } .dialog-header h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--rs-text-primary, #e0e0e0); } .dialog-close { background: none; border: none; color: var(--rs-text-muted, #888); font-size: 22px; cursor: pointer; padding: 0 4px; line-height: 1; } .dialog-close:hover { color: var(--rs-text-primary, #e0e0e0); } .tab-bar { display: flex; gap: 0; border-bottom: 1px solid var(--rs-border-subtle, #2a2a4a); } .tab { flex: 1; padding: 10px; border: none; background: none; color: var(--rs-text-secondary, #aaa); font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.15s; border-bottom: 2px solid transparent; } .tab.active { color: var(--rs-primary, #6366f1); border-bottom-color: var(--rs-primary, #6366f1); } .tab:hover { color: var(--rs-text-primary, #e0e0e0); } .source-bar { display: flex; gap: 4px; padding: 12px 16px; border-bottom: 1px solid var(--rs-border-subtle, #2a2a4a); } .source-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--rs-border, #2a2a4a); background: transparent; color: var(--rs-text-secondary, #aaa); font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: all 0.15s; } .source-btn:hover { border-color: var(--rs-border-strong, #444); color: var(--rs-text-primary, #e0e0e0); } .source-btn.active { background: var(--rs-primary, #6366f1); color: #fff; border-color: var(--rs-primary, #6366f1); } .dialog-body { padding: 16px 20px; overflow-y: auto; flex: 1; } .upload-area { border: 2px dashed var(--rs-border, #2a2a4a); border-radius: 10px; padding: 24px; text-align: center; margin-bottom: 16px; transition: border-color 0.15s, background 0.15s; } .upload-area.dragover { border-color: var(--rs-primary, #6366f1); background: rgba(99,102,241,0.05); } .upload-area p { margin: 0 0 8px; color: var(--rs-text-secondary, #aaa); font-size: 13px; } .upload-hint { font-size: 11px !important; color: var(--rs-text-muted, #666) !important; margin-top: 8px !important; } .form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } .form-row label { font-size: 13px; color: var(--rs-text-secondary, #aaa); white-space: nowrap; } .form-row select { flex: 1; padding: 7px 10px; border-radius: 6px; border: 1px solid var(--rs-border, #2a2a4a); background: var(--rs-input-bg, #111); color: var(--rs-text-primary, #e0e0e0); font-size: 13px; } .btn-primary { width: 100%; padding: 10px; border-radius: 8px; border: none; background: var(--rs-primary, #6366f1); color: #fff; font-weight: 600; font-size: 13px; cursor: pointer; transition: background 0.15s; } .btn-primary:hover { background: var(--rs-primary-hover, #5558e6); } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .btn-secondary { padding: 8px 16px; border-radius: 6px; border: 1px solid var(--rs-border, #2a2a4a); background: transparent; color: var(--rs-text-primary, #e0e0e0); font-size: 13px; cursor: pointer; } .btn-secondary:hover { border-color: var(--rs-border-strong, #444); } .btn-sm { padding: 4px 10px; font-size: 11px; } .connect-prompt { text-align: center; padding: 24px; } .connect-prompt p { color: var(--rs-text-secondary, #aaa); margin-bottom: 16px; font-size: 13px; } .page-list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .page-list-header span { font-size: 13px; color: var(--rs-text-secondary, #aaa); } .page-list { max-height: 200px; overflow-y: auto; border: 1px solid var(--rs-border-subtle, #2a2a4a); border-radius: 8px; margin-bottom: 14px; } .page-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; transition: background 0.1s; border-bottom: 1px solid var(--rs-border-subtle, #1a1a2e); } .page-item:last-child { border-bottom: none; } .page-item:hover { background: rgba(255,255,255,0.03); } .page-item input[type="checkbox"] { accent-color: var(--rs-primary, #6366f1); } .page-icon { width: 20px; height: 20px; font-size: 12px; display: flex; align-items: center; justify-content: center; background: var(--rs-bg-surface-raised, #222); border-radius: 4px; color: var(--rs-text-muted, #888); } .page-title { font-size: 13px; color: var(--rs-text-primary, #e0e0e0); flex: 1; } .empty-list { padding: 20px; text-align: center; color: var(--rs-text-muted, #666); font-size: 12px; } .status-message { margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 12px; text-align: center; } .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); } `; } } customElements.define('import-export-dialog', ImportExportDialog); export { ImportExportDialog };