From 18b61fa5e690604e2f60fca51e5ae3eebe24da92 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 10 Apr 2026 18:05:35 -0400 Subject: [PATCH] feat: rebuild rNotes as vault browser, editor code now in rDocs Phase 2-3 of the rNotes/rDocs split. Rewrites rNotes from a full TipTap editor (~1800 lines) into a lightweight Obsidian/Logseq vault sync and browse module (~560 lines). Rich editing features remain in rDocs. rNotes vault browser: - VaultDoc schema: metadata-only in Automerge (title, tags, hash, wikilinks) - ZIP vault uploads stored on disk at /data/files/uploads/vaults/ - File tree browser, search, read-only markdown preview - Wikilink graph data endpoint for visualization - 5 MCP tools: list_vaults, browse_vault, search_vault, get_vault_note, sync_status - Browser extension compat shim redirects old API calls to rDocs Cleanup: - Removed dead editor files from rnotes (converters, components, local-first-client) - Updated MI integration to use getRecentVaultNotesForMI - Updated ONTOLOGY.md with new module descriptions - Bumped JS cache versions Co-Authored-By: Claude Opus 4.6 --- ONTOLOGY.md | 6 +- ...llaborative-drawing-gesture-recognition.md | 19 +- lib/mi-voice-bridge.ts | 224 + .../rnotes/browser-extension/background.js | 315 -- .../browser-extension/icons/icon-128.png | Bin 837 -> 0 bytes .../browser-extension/icons/icon-16.png | Bin 185 -> 0 bytes .../browser-extension/icons/icon-48.png | Bin 349 -> 0 bytes .../rnotes/browser-extension/manifest.json | 50 - modules/rnotes/browser-extension/options.html | 231 - modules/rnotes/browser-extension/options.js | 179 - .../browser-extension/parakeet-offline.js | 147 - modules/rnotes/browser-extension/popup.html | 262 - modules/rnotes/browser-extension/popup.js | 328 -- modules/rnotes/browser-extension/voice.html | 414 -- modules/rnotes/browser-extension/voice.js | 610 --- modules/rnotes/components/comment-mark.ts | 49 - modules/rnotes/components/comment-panel.ts | 918 ---- modules/rnotes/components/folk-notes-app.ts | 4828 ++--------------- .../rnotes/components/folk-voice-recorder.ts | 578 -- .../rnotes/components/import-export-dialog.ts | 1003 ---- modules/rnotes/components/notes-demo.ts | 353 -- modules/rnotes/components/notes.css | 7 - modules/rnotes/components/slash-command.ts | 308 -- modules/rnotes/components/suggestion-marks.ts | 101 - .../rnotes/components/suggestion-plugin.ts | 366 -- modules/rnotes/converters/evernote.ts | 236 - modules/rnotes/converters/file-import.ts | 171 - modules/rnotes/converters/google-docs.ts | 329 -- modules/rnotes/converters/index.ts | 115 - modules/rnotes/converters/logseq.ts | 9 - modules/rnotes/converters/markdown-tiptap.ts | 9 - modules/rnotes/converters/notion.ts | 461 -- modules/rnotes/converters/obsidian.ts | 9 - modules/rnotes/converters/roam.ts | 171 - modules/rnotes/converters/sync.ts | 207 - modules/rnotes/landing.ts | 411 +- modules/rnotes/local-first-client.ts | 210 - modules/rnotes/mod.ts | 2214 ++------ modules/rnotes/schemas.ts | 208 +- modules/rnotes/yjs-ws-provider.ts | 5 - server/mcp-tools/rnotes.ts | 319 +- server/mi-data-queries.ts | 10 +- server/mi-routes.ts | 30 +- shared/components/rstack-mi.ts | 298 + 44 files changed, 1850 insertions(+), 14868 deletions(-) create mode 100644 lib/mi-voice-bridge.ts delete mode 100644 modules/rnotes/browser-extension/background.js delete mode 100644 modules/rnotes/browser-extension/icons/icon-128.png delete mode 100644 modules/rnotes/browser-extension/icons/icon-16.png delete mode 100644 modules/rnotes/browser-extension/icons/icon-48.png delete mode 100644 modules/rnotes/browser-extension/manifest.json delete mode 100644 modules/rnotes/browser-extension/options.html delete mode 100644 modules/rnotes/browser-extension/options.js delete mode 100644 modules/rnotes/browser-extension/parakeet-offline.js delete mode 100644 modules/rnotes/browser-extension/popup.html delete mode 100644 modules/rnotes/browser-extension/popup.js delete mode 100644 modules/rnotes/browser-extension/voice.html delete mode 100644 modules/rnotes/browser-extension/voice.js delete mode 100644 modules/rnotes/components/comment-mark.ts delete mode 100644 modules/rnotes/components/comment-panel.ts delete mode 100644 modules/rnotes/components/folk-voice-recorder.ts delete mode 100644 modules/rnotes/components/import-export-dialog.ts delete mode 100644 modules/rnotes/components/notes-demo.ts delete mode 100644 modules/rnotes/components/notes.css delete mode 100644 modules/rnotes/components/slash-command.ts delete mode 100644 modules/rnotes/components/suggestion-marks.ts delete mode 100644 modules/rnotes/components/suggestion-plugin.ts delete mode 100644 modules/rnotes/converters/evernote.ts delete mode 100644 modules/rnotes/converters/file-import.ts delete mode 100644 modules/rnotes/converters/google-docs.ts delete mode 100644 modules/rnotes/converters/index.ts delete mode 100644 modules/rnotes/converters/logseq.ts delete mode 100644 modules/rnotes/converters/markdown-tiptap.ts delete mode 100644 modules/rnotes/converters/notion.ts delete mode 100644 modules/rnotes/converters/obsidian.ts delete mode 100644 modules/rnotes/converters/roam.ts delete mode 100644 modules/rnotes/converters/sync.ts delete mode 100644 modules/rnotes/local-first-client.ts delete mode 100644 modules/rnotes/yjs-ws-provider.ts diff --git a/ONTOLOGY.md b/ONTOLOGY.md index 3b0c827f..a8dcdbab 100644 --- a/ONTOLOGY.md +++ b/ONTOLOGY.md @@ -221,7 +221,7 @@ Flows are typed connections between modules: | Kind | Description | Example | |------|-------------|---------| -| `data` | Information flow | rNotes → rPubs (publish) | +| `data` | Information flow | rDocs → rPubs (publish) | | `economic` | Value/payment flow | rFunds → rWallet (treasury) | | `trust` | Reputation/attestation | rVote → rNetwork (delegation) | | `attention` | Signal/notification | rInbox → rForum (mentions) | @@ -251,10 +251,10 @@ redirects to the unified server with subdomain-based space routing. | Module | Domain | Purpose | |--------|--------|---------| -| **rNotes** | rnotes.online | Collaborative notebooks (Automerge) | +| **rDocs** | rdocs.online | Rich editor — notebooks, voice transcription, AI, import/export (TipTap + Automerge) | +| **rNotes** | rnotes.online | Vault sync & browse for Obsidian and Logseq | | **rPubs** | rpubs.online | Long-form publishing (Typst PDF) | | **rBooks** | rbooks.online | PDF library with flipbook reader | -| **rDocs** | rdocs.online | Document management | | **rData** | rdata.online | Data visualization & analysis | ### Planning & Spatial diff --git a/backlog/tasks/task-29 - Port-folk-drawfast-shape-collaborative-drawing-gesture-recognition.md b/backlog/tasks/task-29 - Port-folk-drawfast-shape-collaborative-drawing-gesture-recognition.md index cbcaadc0..55d78960 100644 --- a/backlog/tasks/task-29 - Port-folk-drawfast-shape-collaborative-drawing-gesture-recognition.md +++ b/backlog/tasks/task-29 - Port-folk-drawfast-shape-collaborative-drawing-gesture-recognition.md @@ -1,9 +1,10 @@ --- id: TASK-29 title: Port folk-drawfast shape (collaborative drawing/gesture recognition) -status: To Do +status: Done assignee: [] created_date: '2026-02-18 19:50' +updated_date: '2026-04-10 21:28' labels: - shape-port - phase-2 @@ -34,8 +35,16 @@ Features to implement: ## Acceptance Criteria -- [ ] #1 Freehand drawing works with pointer/touch input -- [ ] #2 Gesture recognition detects basic shapes -- [ ] #3 Drawing state syncs across clients -- [ ] #4 Toolbar button added to canvas.html +- [x] #1 Freehand drawing works with pointer/touch input +- [x] #2 Gesture recognition detects basic shapes +- [x] #3 Drawing state syncs across clients +- [x] #4 Toolbar button added to canvas.html + +## Implementation Notes + + +2026-04-10: Added AI sketch-to-image generation (fal.ai + Gemini via /api/image-gen/img2img). Split-view layout with drawing canvas + AI result. Auto-generate toggle, strength slider, provider selector. Image preloading for smooth transitions. Port descriptors for folk-arrow connections. AC#1 (freehand drawing) and AC#4 (toolbar button) were already implemented. AC#2 (gesture recognition) and AC#3 (collaborative sync) still outstanding. + +AC#2: Implemented Unistroke Recognizer with templates for circle, rectangle, triangle, line, arrow, checkmark. Freehand strokes matching >70% confidence are auto-converted to clean geometric shapes with a floating badge. AC#3: Fixed applyData() to restore strokes array, prompt text, and last result URL from Automerge sync data. toJSON() now exports prompt text for sync. + diff --git a/lib/mi-voice-bridge.ts b/lib/mi-voice-bridge.ts new file mode 100644 index 00000000..7d296219 --- /dev/null +++ b/lib/mi-voice-bridge.ts @@ -0,0 +1,224 @@ +/** + * MiVoiceBridge — TTS output via Edge TTS bridge + Web Speech Synthesis fallback. + * + * Connects to claude-voice.jeffemmett.com for high-quality neural voice synthesis. + * Falls back to browser speechSynthesis if the bridge is unavailable. + */ + +export type VoiceState = "idle" | "listening" | "thinking" | "speaking"; + +export interface MiVoiceBridgeOptions { + bridgeUrl?: string; + voice?: string; + onStateChange?: (state: VoiceState) => void; +} + +const DEFAULT_BRIDGE = "https://claude-voice.jeffemmett.com"; +const WS_PATH = "/ws/audio"; +const TTS_PATH = "/api/tts/speak"; + +export class MiVoiceBridge { + #bridgeUrl: string; + #voice: string; + #onStateChange: ((s: VoiceState) => void) | null; + #ws: WebSocket | null = null; + #audioCtx: AudioContext | null = null; + #currentSource: AudioBufferSourceNode | null = null; + #speaking = false; + #destroyed = false; + #speakResolve: (() => void) | null = null; + + constructor(opts: MiVoiceBridgeOptions = {}) { + this.#bridgeUrl = opts.bridgeUrl ?? DEFAULT_BRIDGE; + this.#voice = opts.voice ?? "en-US-AriaNeural"; + this.#onStateChange = opts.onStateChange ?? null; + } + + get isSpeaking(): boolean { + return this.#speaking; + } + + setVoice(voice: string): void { + this.#voice = voice; + } + + async speak(text: string): Promise { + if (this.#destroyed || !text.trim()) return; + this.#speaking = true; + + try { + await this.#speakViaBridge(text); + } catch { + // Bridge unavailable — fall back to browser TTS + await this.#speakViaBrowser(text); + } finally { + this.#speaking = false; + } + } + + stop(): void { + // Stop AudioContext playback + if (this.#currentSource) { + try { this.#currentSource.stop(); } catch { /* already stopped */ } + this.#currentSource = null; + } + // Stop browser TTS + if (window.speechSynthesis?.speaking) { + window.speechSynthesis.cancel(); + } + this.#speaking = false; + if (this.#speakResolve) { + this.#speakResolve(); + this.#speakResolve = null; + } + } + + destroy(): void { + this.#destroyed = true; + this.stop(); + if (this.#ws) { + this.#ws.close(); + this.#ws = null; + } + if (this.#audioCtx) { + this.#audioCtx.close(); + this.#audioCtx = null; + } + } + + // ── Bridge TTS ── + + #ensureAudioCtx(): AudioContext { + if (!this.#audioCtx || this.#audioCtx.state === "closed") { + this.#audioCtx = new AudioContext(); + } + if (this.#audioCtx.state === "suspended") { + this.#audioCtx.resume(); + } + return this.#audioCtx; + } + + #connectWs(): Promise { + if (this.#ws && this.#ws.readyState === WebSocket.OPEN) { + return Promise.resolve(this.#ws); + } + + return new Promise((resolve, reject) => { + const wsUrl = this.#bridgeUrl.replace(/^http/, "ws") + WS_PATH; + const ws = new WebSocket(wsUrl); + ws.binaryType = "arraybuffer"; + + const timeout = setTimeout(() => { + ws.close(); + reject(new Error("WS connect timeout")); + }, 5000); + + ws.onopen = () => { + clearTimeout(timeout); + this.#ws = ws; + resolve(ws); + }; + + ws.onerror = () => { + clearTimeout(timeout); + reject(new Error("WS connection failed")); + }; + + ws.onclose = () => { + if (this.#ws === ws) this.#ws = null; + }; + }); + } + + async #speakViaBridge(text: string): Promise { + // Connect WS first so we're ready to receive audio + const ws = await this.#connectWs(); + + return new Promise(async (resolve, reject) => { + this.#speakResolve = resolve; + + // Listen for the audio frame + const handler = async (ev: MessageEvent) => { + if (!(ev.data instanceof ArrayBuffer)) return; + ws.removeEventListener("message", handler); + + try { + const buf = ev.data as ArrayBuffer; + const view = new DataView(buf); + + // Frame format: [4B header_len][JSON header][MP3 bytes] + const headerLen = view.getUint32(0, true); + const mp3Bytes = buf.slice(4 + headerLen); + + const ctx = this.#ensureAudioCtx(); + const audioBuffer = await ctx.decodeAudioData(mp3Bytes.slice(0)); // slice to copy + const source = ctx.createBufferSource(); + source.buffer = audioBuffer; + source.connect(ctx.destination); + this.#currentSource = source; + + source.onended = () => { + this.#currentSource = null; + this.#speakResolve = null; + resolve(); + }; + + source.start(); + } catch (err) { + this.#speakResolve = null; + reject(err); + } + }; + + ws.addEventListener("message", handler); + + // POST the TTS request + try { + const res = await fetch(`${this.#bridgeUrl}${TTS_PATH}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, voice: this.#voice, volume: 100 }), + }); + if (!res.ok) { + ws.removeEventListener("message", handler); + this.#speakResolve = null; + reject(new Error(`TTS POST failed: ${res.status}`)); + } + } catch (err) { + ws.removeEventListener("message", handler); + this.#speakResolve = null; + reject(err); + } + + // Timeout: if no audio frame in 15s, reject + setTimeout(() => { + ws.removeEventListener("message", handler); + if (this.#speakResolve === resolve) { + this.#speakResolve = null; + reject(new Error("TTS audio timeout")); + } + }, 15000); + }); + } + + // ── Browser fallback ── + + async #speakViaBrowser(text: string): Promise { + if (!window.speechSynthesis) return; + + return new Promise((resolve) => { + this.#speakResolve = resolve; + const utterance = new SpeechSynthesisUtterance(text); + utterance.rate = 1.05; + utterance.onend = () => { + this.#speakResolve = null; + resolve(); + }; + utterance.onerror = () => { + this.#speakResolve = null; + resolve(); + }; + window.speechSynthesis.speak(utterance); + }); + } +} diff --git a/modules/rnotes/browser-extension/background.js b/modules/rnotes/browser-extension/background.js deleted file mode 100644 index a07f8b3d..00000000 --- a/modules/rnotes/browser-extension/background.js +++ /dev/null @@ -1,315 +0,0 @@ -const DEFAULT_HOST = 'https://rnotes.online'; - -// --- Context Menu Setup --- - -chrome.runtime.onInstalled.addListener(() => { - chrome.contextMenus.create({ - id: 'clip-page', - title: 'Clip page to rNotes', - contexts: ['page'], - }); - - chrome.contextMenus.create({ - id: 'save-link', - title: 'Save link to rNotes', - contexts: ['link'], - }); - - chrome.contextMenus.create({ - id: 'save-image', - title: 'Save image to rNotes', - contexts: ['image'], - }); - - chrome.contextMenus.create({ - id: 'clip-selection', - title: 'Clip selection to rNotes', - contexts: ['selection'], - }); - - chrome.contextMenus.create({ - id: 'unlock-article', - title: 'Unlock & Clip article to rNotes', - contexts: ['page', 'link'], - }); -}); - -// --- Helpers --- - -async function getSettings() { - const result = await chrome.storage.sync.get(['rnotesHost']); - return { - host: result.rnotesHost || DEFAULT_HOST, - }; -} - -async function getToken() { - const result = await chrome.storage.local.get(['encryptid_token']); - return result.encryptid_token || null; -} - -async function getDefaultNotebook() { - const result = await chrome.storage.local.get(['lastNotebookId']); - return result.lastNotebookId || null; -} - -function showNotification(title, message) { - chrome.notifications.create({ - type: 'basic', - iconUrl: 'icons/icon-128.png', - title: title, - message: message, - }); -} - -async function createNote(data) { - const token = await getToken(); - if (!token) { - showNotification('rNotes Error', 'Not signed in. Open extension settings to sign in.'); - return; - } - - const settings = await getSettings(); - const notebookId = await getDefaultNotebook(); - - const body = { - title: data.title, - content: data.content, - type: data.type || 'CLIP', - url: data.url, - }; - - if (notebookId) body.notebookId = notebookId; - if (data.fileUrl) body.fileUrl = data.fileUrl; - if (data.mimeType) body.mimeType = data.mimeType; - if (data.fileSize) body.fileSize = data.fileSize; - - const response = await fetch(`${settings.host}/api/notes`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`${response.status}: ${text}`); - } - - return response.json(); -} - -async function uploadImage(imageUrl) { - const token = await getToken(); - const settings = await getSettings(); - - // Fetch the image - const imgResponse = await fetch(imageUrl); - const blob = await imgResponse.blob(); - - // Extract filename - let filename; - try { - const urlPath = new URL(imageUrl).pathname; - filename = urlPath.split('/').pop() || `image-${Date.now()}.jpg`; - } catch { - filename = `image-${Date.now()}.jpg`; - } - - // Upload to rNotes - const formData = new FormData(); - formData.append('file', blob, filename); - - const response = await fetch(`${settings.host}/api/uploads`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - }, - body: formData, - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Upload failed: ${response.status} ${text}`); - } - - return response.json(); -} - -async function unlockArticle(url) { - const token = await getToken(); - if (!token) { - showNotification('rNotes Error', 'Not signed in. Open extension settings to sign in.'); - return null; - } - - const settings = await getSettings(); - const response = await fetch(`${settings.host}/api/articles/unlock`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ url }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Unlock failed: ${response.status} ${text}`); - } - - return response.json(); -} - -// --- Context Menu Handler --- - -chrome.contextMenus.onClicked.addListener(async (info, tab) => { - try { - switch (info.menuItemId) { - case 'clip-page': { - // Get page HTML - let content = ''; - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: () => document.body.innerHTML, - }); - content = result?.result || ''; - } catch { - content = `

Clipped from ${tab.url}

`; - } - - await createNote({ - title: tab.title || 'Untitled Clip', - content: content, - type: 'CLIP', - url: tab.url, - }); - - showNotification('Page Clipped', `"${tab.title}" saved to rNotes`); - break; - } - - case 'save-link': { - const linkUrl = info.linkUrl; - const linkText = info.selectionText || linkUrl; - - await createNote({ - title: linkText, - content: `

${linkText}

Found on: ${tab.title}

`, - type: 'BOOKMARK', - url: linkUrl, - }); - - showNotification('Link Saved', `Bookmark saved to rNotes`); - break; - } - - case 'save-image': { - const imageUrl = info.srcUrl; - - // Upload the image first - const upload = await uploadImage(imageUrl); - - // Create IMAGE note with file reference - await createNote({ - title: `Image from ${tab.title || 'page'}`, - content: `

Clipped image

Source: ${tab.title}

`, - type: 'IMAGE', - url: tab.url, - fileUrl: upload.url, - mimeType: upload.mimeType, - fileSize: upload.size, - }); - - showNotification('Image Saved', `Image saved to rNotes`); - break; - } - - case 'unlock-article': { - const targetUrl = info.linkUrl || tab.url; - showNotification('Unlocking Article', `Finding readable version of ${new URL(targetUrl).hostname}...`); - - const result = await unlockArticle(targetUrl); - if (result && result.success && result.archiveUrl) { - // Create a CLIP note with the archive URL - await createNote({ - title: tab.title || 'Unlocked Article', - content: `

Unlocked via ${result.strategy}

Original: ${targetUrl}

Archive: ${result.archiveUrl}

`, - type: 'CLIP', - url: targetUrl, - }); - showNotification('Article Unlocked', `Readable version found via ${result.strategy}`); - // Open the unlocked article in a new tab - chrome.tabs.create({ url: result.archiveUrl }); - } else { - showNotification('Unlock Failed', result?.error || 'No archived version found'); - } - break; - } - - case 'clip-selection': { - // Get selection HTML - let content = ''; - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: () => { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return ''; - const range = selection.getRangeAt(0); - const div = document.createElement('div'); - div.appendChild(range.cloneContents()); - return div.innerHTML; - }, - }); - content = result?.result || ''; - } catch { - content = `

${info.selectionText || ''}

`; - } - - if (!content && info.selectionText) { - content = `

${info.selectionText}

`; - } - - await createNote({ - title: `Selection from ${tab.title || 'page'}`, - content: content, - type: 'CLIP', - url: tab.url, - }); - - showNotification('Selection Clipped', `Saved to rNotes`); - break; - } - } - } catch (err) { - console.error('Context menu action failed:', err); - showNotification('rNotes Error', err.message || 'Failed to save'); - } -}); - -// --- Keyboard shortcut handler --- - -chrome.commands.onCommand.addListener(async (command) => { - if (command === 'open-voice-recorder') { - const settings = await getSettings(); - chrome.windows.create({ - url: `${settings.host}/voice`, - type: 'popup', - width: 400, - height: 600, - focused: true, - }); - } -}); - -// --- Message Handler (from popup) --- - -chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (message.type === 'notify') { - showNotification(message.title, message.message); - } -}); diff --git a/modules/rnotes/browser-extension/icons/icon-128.png b/modules/rnotes/browser-extension/icons/icon-128.png deleted file mode 100644 index 1e296f93e2ad8879b405f99027910b72426addd4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 837 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrVCM03aSW-L^Y*rVp0J~Y!$;Gu zV#cWH+CTYX8ig+e?DTYP4GIi&UAZD7=3)WUx&yL0JAGzzt9UHjG-Zb54^+hW%s)u#7vV|;O+n*0Gx zZqJT{OUZiM8*aZf`}wo>zWROpr?(HL)Ni=*=($*3yjIIfH-n$i2hL@S*S~l^Z?`9# zK;ip&uj&#eF}|^W*0s!s(S_lZF@ptjgE7O7?pypjcJAZ3?|c8={+iwIe;=H^>q;@B zY2V)0yYebO2fVdx&*Bex{p9VPd-XLRzpZ4fsr&u-NwU-`H;#wG3%(tC&NGu~=kk{9 z+jDoH5OCto-uvTsa6)?Js~K0#M}|La$CH$M_q zDE+!U=FgNnE*X1YXs+}ODB@~iH`sR6-u<`W6rKeybQgSGH-FKSO1`_y6~^SzvJQ?#$!ePOA48YaZc~EuMGRtHt?!LTz)6x8$z2$@8Wz&)v`7VdUE_b7x%^Tmbp8}e9RqRBj uK};|AieI8x9RHs`nWEx335=HhVKwSJI%Dzgqxrz>!QkoY=d#Wzp$P!dEpYGv diff --git a/modules/rnotes/browser-extension/icons/icon-16.png b/modules/rnotes/browser-extension/icons/icon-16.png deleted file mode 100644 index 62b0620d2dea8414e163c060721932d494cb44f3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 185 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`4W2HJAr*6y6Amy31qJYP0j_Y{$Ky#g+Z^1-K(Fu+Cb(Ip^r|g=U}o&EuFahzc+rKETcVo~es@ViNO* hXRI|0)5JB{7|i|~H7?({GaTp^22WQ%mvv4FO#r4T3MVaQm^9=oxzv@!qjUI&l65pSn0 z`8GeV>=A=QRZiObGjH1L66d&G*>hr@bj_|KKUb+7XK2`!#pu&L!73rhhT+L-AxX1l z)8I!YRbD-YHyg9IzwzlVcZ}V-IQGLop>76qr31Yj1f7z#w^H_R zY{+D~UQyrT(jYDP;8-=|Pric3e+7jY^MC@Cy+v@g;Dcw|SuZ6EpYipGF*d!!&>{O^ r(t;bP0l+XkKs-TbP diff --git a/modules/rnotes/browser-extension/manifest.json b/modules/rnotes/browser-extension/manifest.json deleted file mode 100644 index 95f6da23..00000000 --- a/modules/rnotes/browser-extension/manifest.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "manifest_version": 3, - "name": "rNotes Web Clipper & Voice", - "version": "1.1.0", - "description": "Clip pages, text, links, and images to rNotes.online. Record voice notes with transcription.", - "permissions": [ - "activeTab", - "contextMenus", - "storage", - "notifications", - "offscreen" - ], - "host_permissions": [ - "https://rnotes.online/*", - "https://auth.ridentity.online/*", - "*://*/*" - ], - "action": { - "default_popup": "popup.html", - "default_icon": { - "16": "icons/icon-16.png", - "48": "icons/icon-48.png", - "128": "icons/icon-128.png" - } - }, - "icons": { - "16": "icons/icon-16.png", - "48": "icons/icon-48.png", - "128": "icons/icon-128.png" - }, - "background": { - "service_worker": "background.js" - }, - "options_ui": { - "page": "options.html", - "open_in_tab": false - }, - "content_security_policy": { - "extension_pages": "script-src 'self' https://esm.sh; object-src 'self'" - }, - "commands": { - "open-voice-recorder": { - "suggested_key": { - "default": "Ctrl+Shift+V", - "mac": "Command+Shift+V" - }, - "description": "Open rVoice recorder" - } - } -} diff --git a/modules/rnotes/browser-extension/options.html b/modules/rnotes/browser-extension/options.html deleted file mode 100644 index 99468401..00000000 --- a/modules/rnotes/browser-extension/options.html +++ /dev/null @@ -1,231 +0,0 @@ - - - - - - - -

rNotes Web Clipper Settings

- - -
-

Connection

-
- - -
The URL of your rNotes instance
-
-
- - -
-

Authentication

-
- Not signed in -
- -
-
- - -
Opens rNotes in a new tab. Sign in with your passkey.
-
-
- - -
After signing in, copy the extension token and paste it here.
-
-
- -
-
- - -
- - -
-

Default Notebook

-
- - -
Pre-selected notebook when clipping
-
-
- - -
- - -
- -
- - - - diff --git a/modules/rnotes/browser-extension/options.js b/modules/rnotes/browser-extension/options.js deleted file mode 100644 index 55858c52..00000000 --- a/modules/rnotes/browser-extension/options.js +++ /dev/null @@ -1,179 +0,0 @@ -const DEFAULT_HOST = 'https://rnotes.online'; - -// --- Helpers --- - -function decodeToken(token) { - try { - const payload = JSON.parse(atob(token.split('.')[1])); - if (payload.exp && payload.exp * 1000 < Date.now()) { - return null; - } - return payload; - } catch { - return null; - } -} - -function showStatus(message, type) { - const el = document.getElementById('status'); - el.textContent = message; - el.className = `status ${type}`; - if (type === 'success') { - setTimeout(() => { el.className = 'status'; }, 3000); - } -} - -// --- Auth UI --- - -async function updateAuthUI() { - const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']); - const claims = encryptid_token ? decodeToken(encryptid_token) : null; - - const authStatus = document.getElementById('authStatus'); - const loginSection = document.getElementById('loginSection'); - const loggedInSection = document.getElementById('loggedInSection'); - - if (claims) { - const username = claims.username || claims.sub?.slice(0, 20) || 'Authenticated'; - authStatus.textContent = `Signed in as ${username}`; - authStatus.className = 'auth-status authed'; - loginSection.style.display = 'none'; - loggedInSection.style.display = 'block'; - } else { - authStatus.textContent = 'Not signed in'; - authStatus.className = 'auth-status not-authed'; - loginSection.style.display = 'block'; - loggedInSection.style.display = 'none'; - } -} - -async function populateNotebooks() { - const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']); - if (!encryptid_token) return; - - const host = document.getElementById('host').value.replace(/\/+$/, '') || DEFAULT_HOST; - - try { - const response = await fetch(`${host}/api/notebooks`, { - headers: { 'Authorization': `Bearer ${encryptid_token}` }, - }); - - if (!response.ok) return; - - const notebooks = await response.json(); - const select = document.getElementById('defaultNotebook'); - - // Clear existing options (keep first) - while (select.options.length > 1) { - select.remove(1); - } - - for (const nb of notebooks) { - const option = document.createElement('option'); - option.value = nb.id; - option.textContent = nb.title; - select.appendChild(option); - } - - // Restore saved default - const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']); - if (lastNotebookId) { - select.value = lastNotebookId; - } - } catch (err) { - console.error('Failed to load notebooks:', err); - } -} - -// --- Load settings --- - -async function loadSettings() { - const result = await chrome.storage.sync.get(['rnotesHost']); - document.getElementById('host').value = result.rnotesHost || DEFAULT_HOST; - - await updateAuthUI(); - await populateNotebooks(); -} - -// --- Event handlers --- - -// Open rNotes sign-in -document.getElementById('openSigninBtn').addEventListener('click', () => { - const host = document.getElementById('host').value.replace(/\/+$/, '') || DEFAULT_HOST; - chrome.tabs.create({ url: `${host}/auth/signin?extension=true` }); -}); - -// Save token -document.getElementById('saveTokenBtn').addEventListener('click', async () => { - const tokenInput = document.getElementById('tokenInput').value.trim(); - - if (!tokenInput) { - showStatus('Please paste a token', 'error'); - return; - } - - const claims = decodeToken(tokenInput); - if (!claims) { - showStatus('Invalid or expired token', 'error'); - return; - } - - await chrome.storage.local.set({ encryptid_token: tokenInput }); - document.getElementById('tokenInput').value = ''; - - showStatus(`Signed in as ${claims.username || claims.sub}`, 'success'); - await updateAuthUI(); - await populateNotebooks(); -}); - -// Logout -document.getElementById('logoutBtn').addEventListener('click', async () => { - await chrome.storage.local.remove(['encryptid_token']); - showStatus('Signed out', 'success'); - await updateAuthUI(); -}); - -// Save settings -document.getElementById('saveBtn').addEventListener('click', async () => { - const host = document.getElementById('host').value.trim().replace(/\/+$/, ''); - const notebookId = document.getElementById('defaultNotebook').value; - - await chrome.storage.sync.set({ rnotesHost: host || DEFAULT_HOST }); - await chrome.storage.local.set({ lastNotebookId: notebookId }); - - showStatus('Settings saved', 'success'); -}); - -// Test connection -document.getElementById('testBtn').addEventListener('click', async () => { - const host = document.getElementById('host').value.trim().replace(/\/+$/, '') || DEFAULT_HOST; - const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']); - - try { - const headers = {}; - if (encryptid_token) { - headers['Authorization'] = `Bearer ${encryptid_token}`; - } - - const response = await fetch(`${host}/api/notebooks`, { headers }); - - if (response.ok) { - const data = await response.json(); - showStatus(`Connected! Found ${data.length || 0} notebooks.`, 'success'); - } else if (response.status === 401) { - showStatus('Connected but not authenticated. Sign in first.', 'error'); - } else { - showStatus(`Connection failed: ${response.status}`, 'error'); - } - } catch (err) { - showStatus(`Cannot connect: ${err.message}`, 'error'); - } -}); - -// Default notebook change -document.getElementById('defaultNotebook').addEventListener('change', async (e) => { - await chrome.storage.local.set({ lastNotebookId: e.target.value }); -}); - -// Init -document.addEventListener('DOMContentLoaded', loadSettings); diff --git a/modules/rnotes/browser-extension/parakeet-offline.js b/modules/rnotes/browser-extension/parakeet-offline.js deleted file mode 100644 index 2aa4443f..00000000 --- a/modules/rnotes/browser-extension/parakeet-offline.js +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Offline transcription using parakeet.js (NVIDIA Parakeet TDT 0.6B v2). - * Loaded at runtime from CDN. Model ~634 MB (int8) on first download, - * cached in IndexedDB after. Works fully offline after first download. - * - * Port of src/lib/parakeetOffline.ts for the browser extension. - */ - -const CACHE_KEY = 'parakeet-offline-cached'; - -// Singleton model — don't reload on subsequent calls -let cachedModel = null; -let loadingPromise = null; - -/** - * Check if the Parakeet model has been downloaded before. - */ -function isModelCached() { - try { - return localStorage.getItem(CACHE_KEY) === 'true'; - } catch { - return false; - } -} - -/** - * Detect WebGPU availability. - */ -async function detectWebGPU() { - if (!navigator.gpu) return false; - try { - const adapter = await navigator.gpu.requestAdapter(); - return !!adapter; - } catch { - return false; - } -} - -/** - * Get or create the Parakeet model singleton. - * @param {function} onProgress - callback({ status, progress, file, message }) - */ -async function getModel(onProgress) { - if (cachedModel) return cachedModel; - if (loadingPromise) return loadingPromise; - - loadingPromise = (async () => { - onProgress?.({ status: 'loading', message: 'Loading Parakeet model...' }); - - // Dynamic import from CDN at runtime - const { fromHub } = await import('https://esm.sh/parakeet.js@1.1.2'); - - const backend = (await detectWebGPU()) ? 'webgpu' : 'wasm'; - const fileProgress = {}; - - const model = await fromHub('parakeet-tdt-0.6b-v2', { - backend, - progress: ({ file, loaded, total }) => { - fileProgress[file] = { loaded, total }; - - let totalBytes = 0; - let loadedBytes = 0; - for (const fp of Object.values(fileProgress)) { - totalBytes += fp.total || 0; - loadedBytes += fp.loaded || 0; - } - - if (totalBytes > 0) { - const pct = Math.round((loadedBytes / totalBytes) * 100); - onProgress?.({ - status: 'downloading', - progress: pct, - file, - message: `Downloading model... ${pct}%`, - }); - } - }, - }); - - localStorage.setItem(CACHE_KEY, 'true'); - onProgress?.({ status: 'loading', message: 'Model loaded' }); - - cachedModel = model; - loadingPromise = null; - return model; - })(); - - return loadingPromise; -} - -/** - * Decode an audio Blob to Float32Array at 16 kHz mono. - */ -async function decodeAudioBlob(blob) { - const arrayBuffer = await blob.arrayBuffer(); - const audioCtx = new AudioContext({ sampleRate: 16000 }); - try { - const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); - - if (audioBuffer.sampleRate === 16000 && audioBuffer.numberOfChannels === 1) { - return audioBuffer.getChannelData(0); - } - - // Resample via OfflineAudioContext - const numSamples = Math.ceil(audioBuffer.duration * 16000); - const offlineCtx = new OfflineAudioContext(1, numSamples, 16000); - const source = offlineCtx.createBufferSource(); - source.buffer = audioBuffer; - source.connect(offlineCtx.destination); - source.start(); - const resampled = await offlineCtx.startRendering(); - return resampled.getChannelData(0); - } finally { - await audioCtx.close(); - } -} - -/** - * Transcribe an audio Blob offline using Parakeet in the browser. - * First call downloads the model (~634 MB). Subsequent calls use cached. - * - * @param {Blob} audioBlob - * @param {function} onProgress - callback({ status, progress, file, message }) - * @returns {Promise} transcribed text - */ -async function transcribeOffline(audioBlob, onProgress) { - const model = await getModel(onProgress); - - onProgress?.({ status: 'transcribing', message: 'Transcribing audio...' }); - - const audioData = await decodeAudioBlob(audioBlob); - - const result = await model.transcribe(audioData, 16000, { - returnTimestamps: false, - enableProfiling: false, - }); - - const text = result.utterance_text?.trim() || ''; - onProgress?.({ status: 'done', message: 'Transcription complete' }); - return text; -} - -// Export for use in voice.js (loaded as ES module) -window.ParakeetOffline = { - isModelCached, - transcribeOffline, -}; diff --git a/modules/rnotes/browser-extension/popup.html b/modules/rnotes/browser-extension/popup.html deleted file mode 100644 index dcb72a9c..00000000 --- a/modules/rnotes/browser-extension/popup.html +++ /dev/null @@ -1,262 +0,0 @@ - - - - - - - -
- rNotes Clipper - ... -
- - - -
-
Loading...
-
-
- -
-
- - -
-
- - -
-
- -
- - -
- -
- -
- -
- -
- -
- - - - - - diff --git a/modules/rnotes/browser-extension/popup.js b/modules/rnotes/browser-extension/popup.js deleted file mode 100644 index 4a9f1f7d..00000000 --- a/modules/rnotes/browser-extension/popup.js +++ /dev/null @@ -1,328 +0,0 @@ -const DEFAULT_HOST = 'https://rnotes.online'; - -let currentTab = null; -let selectedText = ''; -let selectedHtml = ''; - -// --- Helpers --- - -async function getSettings() { - const result = await chrome.storage.sync.get(['rnotesHost']); - return { - host: result.rnotesHost || DEFAULT_HOST, - }; -} - -async function getToken() { - const result = await chrome.storage.local.get(['encryptid_token']); - return result.encryptid_token || null; -} - -function decodeToken(token) { - try { - const payload = JSON.parse(atob(token.split('.')[1])); - // Check expiry - if (payload.exp && payload.exp * 1000 < Date.now()) { - return null; // expired - } - return payload; - } catch { - return null; - } -} - -function parseTags(tagString) { - if (!tagString || !tagString.trim()) return []; - return tagString.split(',').map(t => t.trim().toLowerCase()).filter(Boolean); -} - -function showStatus(message, type) { - const el = document.getElementById('status'); - el.textContent = message; - el.className = `status ${type}`; - if (type === 'success') { - setTimeout(() => { el.className = 'status'; }, 3000); - } -} - -// --- API calls --- - -async function createNote(data) { - const token = await getToken(); - const settings = await getSettings(); - - const body = { - title: data.title, - content: data.content, - type: data.type || 'CLIP', - url: data.url, - }; - - const notebookId = document.getElementById('notebook').value; - if (notebookId) body.notebookId = notebookId; - - const tags = parseTags(document.getElementById('tags').value); - if (tags.length > 0) body.tags = tags; - - const response = await fetch(`${settings.host}/api/notes`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`${response.status}: ${text}`); - } - - return response.json(); -} - -async function fetchNotebooks() { - const token = await getToken(); - const settings = await getSettings(); - - const response = await fetch(`${settings.host}/api/notebooks`, { - headers: { - 'Authorization': `Bearer ${token}`, - }, - }); - - if (!response.ok) return []; - const data = await response.json(); - return Array.isArray(data) ? data : []; -} - -// --- UI --- - -async function populateNotebooks() { - const select = document.getElementById('notebook'); - try { - const notebooks = await fetchNotebooks(); - // Keep the "No notebook" option - for (const nb of notebooks) { - const option = document.createElement('option'); - option.value = nb.id; - option.textContent = nb.title; - select.appendChild(option); - } - - // Restore last used notebook - const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']); - if (lastNotebookId) { - select.value = lastNotebookId; - } - } catch (err) { - console.error('Failed to load notebooks:', err); - } -} - -// Save last used notebook when changed -function setupNotebookMemory() { - document.getElementById('notebook').addEventListener('change', (e) => { - chrome.storage.local.set({ lastNotebookId: e.target.value }); - }); -} - -async function init() { - // Get current tab - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - currentTab = tab; - - // Display page info - document.getElementById('pageTitle').textContent = tab.title || 'Untitled'; - document.getElementById('pageUrl').textContent = tab.url || ''; - - // Check auth - const token = await getToken(); - const claims = token ? decodeToken(token) : null; - - if (!claims) { - document.getElementById('userStatus').textContent = 'Not signed in'; - document.getElementById('userStatus').classList.add('not-authed'); - document.getElementById('authWarning').style.display = 'block'; - return; - } - - document.getElementById('userStatus').textContent = claims.username || claims.sub?.slice(0, 16) || 'Authenticated'; - document.getElementById('authWarning').style.display = 'none'; - - // Enable buttons - document.getElementById('clipPageBtn').disabled = false; - document.getElementById('unlockBtn').disabled = false; - document.getElementById('voiceBtn').disabled = false; - - // Load notebooks - await populateNotebooks(); - setupNotebookMemory(); - - // Detect text selection - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: () => { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { - return { text: '', html: '' }; - } - const range = selection.getRangeAt(0); - const div = document.createElement('div'); - div.appendChild(range.cloneContents()); - return { text: selection.toString(), html: div.innerHTML }; - }, - }); - - if (result?.result?.text) { - selectedText = result.result.text; - selectedHtml = result.result.html; - document.getElementById('clipSelectionBtn').disabled = false; - } - } catch (err) { - // Can't access some pages (chrome://, etc.) - console.warn('Cannot access page content:', err); - } -} - -// --- Event handlers --- - -document.getElementById('clipPageBtn').addEventListener('click', async () => { - const btn = document.getElementById('clipPageBtn'); - btn.disabled = true; - showStatus('Clipping page...', 'loading'); - - try { - // Get page HTML content - let pageContent = ''; - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId: currentTab.id }, - func: () => document.body.innerHTML, - }); - pageContent = result?.result || ''; - } catch { - // Fallback: just use URL as content - pageContent = `

Clipped from ${currentTab.url}

`; - } - - const note = await createNote({ - title: currentTab.title || 'Untitled Clip', - content: pageContent, - type: 'CLIP', - url: currentTab.url, - }); - - showStatus(`Clipped! Note saved.`, 'success'); - - // Notify background worker - chrome.runtime.sendMessage({ - type: 'notify', - title: 'Page Clipped', - message: `"${currentTab.title}" saved to rNotes`, - }); - } catch (err) { - showStatus(`Error: ${err.message}`, 'error'); - } finally { - btn.disabled = false; - } -}); - -document.getElementById('clipSelectionBtn').addEventListener('click', async () => { - const btn = document.getElementById('clipSelectionBtn'); - btn.disabled = true; - showStatus('Clipping selection...', 'loading'); - - try { - const content = selectedHtml || `

${selectedText}

`; - const note = await createNote({ - title: `Selection from ${currentTab.title || 'page'}`, - content: content, - type: 'CLIP', - url: currentTab.url, - }); - - showStatus(`Selection clipped!`, 'success'); - - chrome.runtime.sendMessage({ - type: 'notify', - title: 'Selection Clipped', - message: `Saved to rNotes`, - }); - } catch (err) { - showStatus(`Error: ${err.message}`, 'error'); - } finally { - btn.disabled = false; - } -}); - -document.getElementById('unlockBtn').addEventListener('click', async () => { - const btn = document.getElementById('unlockBtn'); - btn.disabled = true; - showStatus('Unlocking article...', 'loading'); - - try { - const token = await getToken(); - const settings = await getSettings(); - - const response = await fetch(`${settings.host}/api/articles/unlock`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ url: currentTab.url }), - }); - - const result = await response.json(); - - if (result.success && result.archiveUrl) { - // Also save as a note - await createNote({ - title: currentTab.title || 'Unlocked Article', - content: `

Unlocked via ${result.strategy}

Original: ${currentTab.url}

Archive: ${result.archiveUrl}

`, - type: 'CLIP', - url: currentTab.url, - }); - - showStatus(`Unlocked via ${result.strategy}! Opening...`, 'success'); - - // Open archive in new tab - chrome.tabs.create({ url: result.archiveUrl }); - } else { - showStatus(result.error || 'No archived version found', 'error'); - } - } catch (err) { - showStatus(`Error: ${err.message}`, 'error'); - } finally { - btn.disabled = false; - } -}); - -document.getElementById('voiceBtn').addEventListener('click', async () => { - // Open rVoice PWA page in a popup window (supports PiP pop-out) - const settings = await getSettings(); - chrome.windows.create({ - url: `${settings.host}/voice`, - type: 'popup', - width: 400, - height: 600, - focused: true, - }); - // Close the current popup - window.close(); -}); - -document.getElementById('optionsLink').addEventListener('click', (e) => { - e.preventDefault(); - chrome.runtime.openOptionsPage(); -}); - -document.getElementById('openSettings')?.addEventListener('click', (e) => { - e.preventDefault(); - chrome.runtime.openOptionsPage(); -}); - -// Init on load -document.addEventListener('DOMContentLoaded', init); diff --git a/modules/rnotes/browser-extension/voice.html b/modules/rnotes/browser-extension/voice.html deleted file mode 100644 index 0da0f251..00000000 --- a/modules/rnotes/browser-extension/voice.html +++ /dev/null @@ -1,414 +0,0 @@ - - - - - - - -
- - rVoice - voice notes - - -
- - - -
-
Ready
- -
00:00
-
- - Live transcribe -
-
- -
-
Loading model...
-
-
- -
- -
- -
-
Transcript
-
- Transcribing... -
-
- -
- - -
- - - -
- -
- Space to record · Esc to close · Offline ready -
- - - - - diff --git a/modules/rnotes/browser-extension/voice.js b/modules/rnotes/browser-extension/voice.js deleted file mode 100644 index 9c94767f..00000000 --- a/modules/rnotes/browser-extension/voice.js +++ /dev/null @@ -1,610 +0,0 @@ -const DEFAULT_HOST = 'https://rnotes.online'; - -// --- State --- -let state = 'idle'; // idle | recording | processing | done -let mediaRecorder = null; -let audioChunks = []; -let timerInterval = null; -let startTime = 0; -let audioBlob = null; -let audioUrl = null; -let transcript = ''; -let liveTranscript = ''; // accumulated from Web Speech API -let uploadedFileUrl = ''; -let uploadedMimeType = ''; -let uploadedFileSize = 0; -let duration = 0; - -// Web Speech API -let recognition = null; -let speechSupported = !!(window.SpeechRecognition || window.webkitSpeechRecognition); - -// --- DOM refs --- -const recBtn = document.getElementById('recBtn'); -const timerEl = document.getElementById('timer'); -const statusLabel = document.getElementById('statusLabel'); -const transcriptArea = document.getElementById('transcriptArea'); -const transcriptText = document.getElementById('transcriptText'); -const liveIndicator = document.getElementById('liveIndicator'); -const audioPreview = document.getElementById('audioPreview'); -const audioPlayer = document.getElementById('audioPlayer'); -const notebookSelect = document.getElementById('notebook'); -const postActions = document.getElementById('postActions'); -const saveBtn = document.getElementById('saveBtn'); -const discardBtn = document.getElementById('discardBtn'); -const copyBtn = document.getElementById('copyBtn'); -const statusBar = document.getElementById('statusBar'); -const authWarning = document.getElementById('authWarning'); -const closeBtn = document.getElementById('closeBtn'); - -// --- Helpers --- - -async function getSettings() { - const result = await chrome.storage.sync.get(['rnotesHost']); - return { host: result.rnotesHost || DEFAULT_HOST }; -} - -async function getToken() { - const result = await chrome.storage.local.get(['encryptid_token']); - return result.encryptid_token || null; -} - -function decodeToken(token) { - try { - const payload = JSON.parse(atob(token.split('.')[1])); - if (payload.exp && payload.exp * 1000 < Date.now()) return null; - return payload; - } catch { return null; } -} - -function formatTime(seconds) { - const m = Math.floor(seconds / 60).toString().padStart(2, '0'); - const s = (seconds % 60).toString().padStart(2, '0'); - return `${m}:${s}`; -} - -function setStatusLabel(text, cls) { - statusLabel.textContent = text; - statusLabel.className = `status-label ${cls}`; -} - -function showStatusBar(message, type) { - statusBar.textContent = message; - statusBar.className = `status-bar visible ${type}`; - if (type === 'success') { - setTimeout(() => { statusBar.className = 'status-bar'; }, 3000); - } -} - -// --- Parakeet progress UI --- - -const progressArea = document.getElementById('progressArea'); -const progressLabel = document.getElementById('progressLabel'); -const progressFill = document.getElementById('progressFill'); - -function showParakeetProgress(p) { - if (!progressArea) return; - progressArea.classList.add('visible'); - - if (p.message) { - progressLabel.textContent = p.message; - } - - if (p.status === 'downloading' && p.progress !== undefined) { - progressFill.style.width = `${p.progress}%`; - } else if (p.status === 'transcribing') { - progressFill.style.width = '100%'; - } else if (p.status === 'loading') { - progressFill.style.width = '0%'; - } -} - -function hideParakeetProgress() { - if (progressArea) { - progressArea.classList.remove('visible'); - progressFill.style.width = '0%'; - } -} - -// --- Notebook loader --- - -async function loadNotebooks() { - const token = await getToken(); - if (!token) return; - const settings = await getSettings(); - - try { - const res = await fetch(`${settings.host}/api/notebooks`, { - headers: { 'Authorization': `Bearer ${token}` }, - }); - if (!res.ok) return; - const notebooks = await res.json(); - - for (const nb of notebooks) { - const opt = document.createElement('option'); - opt.value = nb.id; - opt.textContent = nb.title; - notebookSelect.appendChild(opt); - } - - // Restore last used - const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']); - if (lastNotebookId) notebookSelect.value = lastNotebookId; - } catch (err) { - console.error('Failed to load notebooks:', err); - } -} - -notebookSelect.addEventListener('change', (e) => { - chrome.storage.local.set({ lastNotebookId: e.target.value }); -}); - -// --- Live transcription (Web Speech API) --- - -function startLiveTranscription() { - if (!speechSupported) return; - - const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; - recognition = new SpeechRecognition(); - recognition.continuous = true; - recognition.interimResults = true; - recognition.lang = 'en-US'; - - let finalizedText = ''; - - recognition.onresult = (event) => { - let interimText = ''; - // Rebuild finalized text from all final results - finalizedText = ''; - for (let i = 0; i < event.results.length; i++) { - const result = event.results[i]; - if (result.isFinal) { - finalizedText += result[0].transcript.trim() + ' '; - } else { - interimText += result[0].transcript; - } - } - - liveTranscript = finalizedText.trim(); - - // Update the live transcript display - updateLiveDisplay(finalizedText.trim(), interimText.trim()); - }; - - recognition.onerror = (event) => { - if (event.error !== 'aborted' && event.error !== 'no-speech') { - console.warn('Speech recognition error:', event.error); - } - }; - - // Auto-restart on end (Chrome stops after ~60s of silence) - recognition.onend = () => { - if (state === 'recording' && recognition) { - try { recognition.start(); } catch {} - } - }; - - try { - recognition.start(); - if (liveIndicator) liveIndicator.classList.add('visible'); - } catch (err) { - console.warn('Could not start speech recognition:', err); - speechSupported = false; - } -} - -function stopLiveTranscription() { - if (recognition) { - const ref = recognition; - recognition = null; - try { ref.stop(); } catch {} - } - if (liveIndicator) liveIndicator.classList.remove('visible'); -} - -function updateLiveDisplay(finalText, interimText) { - if (state !== 'recording') return; - - // Show transcript area while recording - transcriptArea.classList.add('visible'); - - let html = ''; - if (finalText) { - html += `${escapeHtml(finalText)}`; - } - if (interimText) { - html += `${escapeHtml(interimText)}`; - } - if (!finalText && !interimText) { - html = 'Listening...'; - } - transcriptText.innerHTML = html; - - // Auto-scroll - transcriptText.scrollTop = transcriptText.scrollHeight; -} - -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -// --- Recording --- - -async function startRecording() { - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - - const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') - ? 'audio/webm;codecs=opus' - : 'audio/webm'; - - mediaRecorder = new MediaRecorder(stream, { mimeType }); - audioChunks = []; - liveTranscript = ''; - - mediaRecorder.ondataavailable = (e) => { - if (e.data.size > 0) audioChunks.push(e.data); - }; - - mediaRecorder.start(1000); - startTime = Date.now(); - state = 'recording'; - - // UI updates - recBtn.classList.add('recording'); - timerEl.classList.add('recording'); - setStatusLabel('Recording', 'recording'); - postActions.style.display = 'none'; - audioPreview.classList.remove('visible'); - statusBar.className = 'status-bar'; - - // Show transcript area with listening placeholder - if (speechSupported) { - transcriptArea.classList.add('visible'); - transcriptText.innerHTML = 'Listening...'; - } else { - transcriptArea.classList.remove('visible'); - } - - timerInterval = setInterval(() => { - const elapsed = Math.floor((Date.now() - startTime) / 1000); - timerEl.textContent = formatTime(elapsed); - }, 1000); - - // Start live transcription alongside recording - startLiveTranscription(); - - } catch (err) { - showStatusBar(err.message || 'Microphone access denied', 'error'); - } -} - -async function stopRecording() { - if (!mediaRecorder || mediaRecorder.state === 'inactive') return; - - clearInterval(timerInterval); - timerInterval = null; - duration = Math.floor((Date.now() - startTime) / 1000); - - // Capture live transcript before stopping recognition - const capturedLiveTranscript = liveTranscript; - - // Stop live transcription - stopLiveTranscription(); - - state = 'processing'; - recBtn.classList.remove('recording'); - timerEl.classList.remove('recording'); - setStatusLabel('Processing...', 'processing'); - - // Stop recorder and collect blob - audioBlob = await new Promise((resolve) => { - mediaRecorder.onstop = () => { - mediaRecorder.stream.getTracks().forEach(t => t.stop()); - resolve(new Blob(audioChunks, { type: mediaRecorder.mimeType })); - }; - mediaRecorder.stop(); - }); - - // Show audio preview - if (audioUrl) URL.revokeObjectURL(audioUrl); - audioUrl = URL.createObjectURL(audioBlob); - audioPlayer.src = audioUrl; - audioPreview.classList.add('visible'); - - // Show live transcript while we process (if we have one) - transcriptArea.classList.add('visible'); - if (capturedLiveTranscript) { - transcriptText.textContent = capturedLiveTranscript; - showStatusBar('Improving transcript...', 'loading'); - } else { - transcriptText.innerHTML = 'Transcribing...'; - showStatusBar('Uploading & transcribing...', 'loading'); - } - - // Upload audio file - const token = await getToken(); - const settings = await getSettings(); - - try { - const uploadForm = new FormData(); - uploadForm.append('file', audioBlob, 'voice-note.webm'); - - const uploadRes = await fetch(`${settings.host}/api/uploads`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, - body: uploadForm, - }); - - if (!uploadRes.ok) throw new Error('Upload failed'); - - const uploadResult = await uploadRes.json(); - uploadedFileUrl = uploadResult.url; - uploadedMimeType = uploadResult.mimeType; - uploadedFileSize = uploadResult.size; - - // --- Three-tier transcription cascade --- - - // Tier 1: Batch API (Whisper on server — highest quality) - let bestTranscript = ''; - try { - showStatusBar('Transcribing via server...', 'loading'); - const transcribeForm = new FormData(); - transcribeForm.append('audio', audioBlob, 'voice-note.webm'); - - const transcribeRes = await fetch(`${settings.host}/api/voice/transcribe`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, - body: transcribeForm, - }); - - if (transcribeRes.ok) { - const transcribeResult = await transcribeRes.json(); - bestTranscript = transcribeResult.text || ''; - } - } catch { - console.warn('Tier 1 (batch API) unavailable'); - } - - // Tier 2: Live transcript from Web Speech API (already captured) - if (!bestTranscript && capturedLiveTranscript) { - bestTranscript = capturedLiveTranscript; - } - - // Tier 3: Offline Parakeet.js (NVIDIA, runs in browser) - if (!bestTranscript && window.ParakeetOffline) { - try { - showStatusBar('Transcribing offline (Parakeet)...', 'loading'); - bestTranscript = await window.ParakeetOffline.transcribeOffline(audioBlob, (p) => { - showParakeetProgress(p); - }); - hideParakeetProgress(); - } catch (offlineErr) { - console.warn('Tier 3 (Parakeet offline) failed:', offlineErr); - hideParakeetProgress(); - } - } - - transcript = bestTranscript; - - // Show transcript (editable) - if (transcript) { - transcriptText.textContent = transcript; - } else { - transcriptText.innerHTML = 'No transcript available - you can type one here'; - } - - state = 'done'; - setStatusLabel('Done', 'done'); - postActions.style.display = 'flex'; - statusBar.className = 'status-bar'; - - } catch (err) { - // On upload error, try offline transcription directly - let fallbackTranscript = capturedLiveTranscript || ''; - - if (!fallbackTranscript && window.ParakeetOffline) { - try { - showStatusBar('Upload failed, transcribing offline...', 'loading'); - fallbackTranscript = await window.ParakeetOffline.transcribeOffline(audioBlob, (p) => { - showParakeetProgress(p); - }); - hideParakeetProgress(); - } catch { - hideParakeetProgress(); - } - } - - transcript = fallbackTranscript; - if (transcript) { - transcriptText.textContent = transcript; - } - - showStatusBar(`Error: ${err.message}`, 'error'); - state = 'done'; - setStatusLabel('Error', 'idle'); - postActions.style.display = 'flex'; - } -} - -function toggleRecording() { - if (state === 'idle' || state === 'done') { - startRecording(); - } else if (state === 'recording') { - stopRecording(); - } - // Ignore clicks while processing -} - -// --- Save to rNotes --- - -async function saveToRNotes() { - saveBtn.disabled = true; - showStatusBar('Saving to rNotes...', 'loading'); - - const token = await getToken(); - const settings = await getSettings(); - - // Get current transcript text (user may have edited it) - const editedTranscript = transcriptText.textContent.trim(); - const isPlaceholder = transcriptText.querySelector('.placeholder') !== null; - const finalTranscript = isPlaceholder ? '' : editedTranscript; - - const now = new Date(); - const timeStr = now.toLocaleString('en-US', { - month: 'short', day: 'numeric', - hour: 'numeric', minute: '2-digit', - hour12: true - }); - - const body = { - title: `Voice note - ${timeStr}`, - content: finalTranscript - ? `

${finalTranscript.replace(/\n/g, '

')}

` - : '

Voice recording (no transcript)

', - type: 'AUDIO', - mimeType: uploadedMimeType || 'audio/webm', - fileUrl: uploadedFileUrl, - fileSize: uploadedFileSize, - duration: duration, - tags: ['voice'], - }; - - const notebookId = notebookSelect.value; - if (notebookId) body.notebookId = notebookId; - - try { - const res = await fetch(`${settings.host}/api/notes`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`${res.status}: ${text}`); - } - - showStatusBar('Saved to rNotes!', 'success'); - - // Notify - chrome.runtime.sendMessage({ - type: 'notify', - title: 'Voice Note Saved', - message: `${formatTime(duration)} recording saved to rNotes`, - }); - - // Reset after short delay - setTimeout(resetState, 1500); - - } catch (err) { - showStatusBar(`Save failed: ${err.message}`, 'error'); - } finally { - saveBtn.disabled = false; - } -} - -// --- Copy to clipboard --- - -async function copyTranscript() { - const text = transcriptText.textContent.trim(); - if (!text || transcriptText.querySelector('.placeholder')) { - showStatusBar('No transcript to copy', 'error'); - return; - } - try { - await navigator.clipboard.writeText(text); - showStatusBar('Copied to clipboard', 'success'); - } catch { - showStatusBar('Copy failed', 'error'); - } -} - -// --- Discard --- - -function resetState() { - state = 'idle'; - mediaRecorder = null; - audioChunks = []; - audioBlob = null; - transcript = ''; - liveTranscript = ''; - uploadedFileUrl = ''; - uploadedMimeType = ''; - uploadedFileSize = 0; - duration = 0; - - stopLiveTranscription(); - - if (audioUrl) { - URL.revokeObjectURL(audioUrl); - audioUrl = null; - } - - timerEl.textContent = '00:00'; - timerEl.classList.remove('recording'); - recBtn.classList.remove('recording'); - setStatusLabel('Ready', 'idle'); - postActions.style.display = 'none'; - audioPreview.classList.remove('visible'); - transcriptArea.classList.remove('visible'); - hideParakeetProgress(); - statusBar.className = 'status-bar'; -} - -// --- Keyboard shortcuts --- - -document.addEventListener('keydown', (e) => { - // Space bar: toggle recording (unless editing transcript) - if (e.code === 'Space' && document.activeElement !== transcriptText) { - e.preventDefault(); - toggleRecording(); - } - // Escape: close window - if (e.code === 'Escape') { - window.close(); - } - // Ctrl+Enter: save (when in done state) - if ((e.ctrlKey || e.metaKey) && e.code === 'Enter' && state === 'done') { - e.preventDefault(); - saveToRNotes(); - } -}); - -// Clear placeholder on focus -transcriptText.addEventListener('focus', () => { - const ph = transcriptText.querySelector('.placeholder'); - if (ph) transcriptText.textContent = ''; -}); - -// --- Event listeners --- - -recBtn.addEventListener('click', toggleRecording); -saveBtn.addEventListener('click', saveToRNotes); -discardBtn.addEventListener('click', resetState); -copyBtn.addEventListener('click', copyTranscript); -closeBtn.addEventListener('click', () => window.close()); - -// --- Init --- - -async function init() { - const token = await getToken(); - const claims = token ? decodeToken(token) : null; - - if (!claims) { - authWarning.style.display = 'block'; - recBtn.style.opacity = '0.3'; - recBtn.style.pointerEvents = 'none'; - return; - } - - authWarning.style.display = 'none'; - await loadNotebooks(); -} - -document.addEventListener('DOMContentLoaded', init); diff --git a/modules/rnotes/components/comment-mark.ts b/modules/rnotes/components/comment-mark.ts deleted file mode 100644 index c27eec89..00000000 --- a/modules/rnotes/components/comment-mark.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * TipTap mark extension for inline comments. - * - * Applies a highlight to selected text and associates it with a comment thread - * stored in Automerge. The mark position is synced via Yjs (as part of the doc content), - * while the thread data (messages, resolved state) lives in Automerge. - */ - -import { Mark, mergeAttributes } from '@tiptap/core'; - -export const CommentMark = Mark.create({ - name: 'comment', - - addAttributes() { - return { - threadId: { default: null }, - resolved: { default: false }, - }; - }, - - parseHTML() { - return [ - { - tag: 'span[data-thread-id]', - getAttrs: (el) => { - const element = el as HTMLElement; - return { - threadId: element.getAttribute('data-thread-id'), - resolved: element.getAttribute('data-resolved') === 'true', - }; - }, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return [ - 'span', - mergeAttributes( - { - class: `comment-highlight${HTMLAttributes.resolved ? ' resolved' : ''}`, - 'data-thread-id': HTMLAttributes.threadId, - 'data-resolved': HTMLAttributes.resolved ? 'true' : 'false', - }, - ), - 0, - ]; - }, -}); diff --git a/modules/rnotes/components/comment-panel.ts b/modules/rnotes/components/comment-panel.ts deleted file mode 100644 index d7f711f6..00000000 --- a/modules/rnotes/components/comment-panel.ts +++ /dev/null @@ -1,918 +0,0 @@ -/** - * — Right sidebar panel for viewing/managing inline comments. - * - * Shows threaded comments anchored to highlighted text in the editor. - * Comment thread data is stored in Automerge, while the highlight mark - * position is stored in Yjs (part of the document content). - * - * Supports: demo mode (in-memory), emoji reactions, date reminders. - */ - -import type { Editor } from '@tiptap/core'; -import type { DocumentId } from '../../../shared/local-first/document'; -import { getModuleApiBase } from "../../../shared/url-helpers"; - -interface CommentMessage { - id: string; - authorId: string; - authorName: string; - text: string; - createdAt: number; -} - -interface CommentThread { - id: string; - anchor: string; - resolved: boolean; - messages: CommentMessage[]; - createdAt: number; - reactions?: Record; - reminderAt?: number; - reminderId?: string; -} - -interface NotebookDoc { - items: Record; - [key: string]: any; - }>; - [key: string]: any; -} - -const REACTION_EMOJIS = ['👍', '👎', '❤️', '🎉', '😂', '😮', '🔥']; - -interface SuggestionEntry { - id: string; - type: 'insert' | 'delete'; - text: string; - authorId: string; - authorName: string; - createdAt: number; -} - -class NotesCommentPanel extends HTMLElement { - private shadow: ShadowRoot; - private _noteId: string | null = null; - private _doc: any = null; - private _subscribedDocId: string | null = null; - private _activeThreadId: string | null = null; - private _editor: Editor | null = null; - private _demoThreads: Record | null = null; - private _space = ''; - private _suggestions: SuggestionEntry[] = []; - - constructor() { - super(); - this.shadow = this.attachShadow({ mode: 'open' }); - } - - set noteId(v: string | null) { this._noteId = v; this.render(); } - set doc(v: any) { this._doc = v; this.render(); } - set subscribedDocId(v: string | null) { this._subscribedDocId = v; } - set activeThreadId(v: string | null) { this._activeThreadId = v; this.render(); } - set editor(v: Editor | null) { this._editor = v; } - set space(v: string) { this._space = v; } - set demoThreads(v: Record | null) { - this._demoThreads = v; - this.render(); - } - set suggestions(v: SuggestionEntry[]) { - this._suggestions = v; - this.render(); - } - - private get isDemo(): boolean { - return this._space === 'demo'; - } - - private getSessionInfo(): { authorName: string; authorId: string } { - try { - const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); - const c = sess?.claims; - return { - authorName: c?.username || c?.displayName || sess?.username || 'Anonymous', - authorId: c?.sub || sess?.userId || 'anon', - }; - } catch { - return { authorName: 'Anonymous', authorId: 'anon' }; - } - } - - private getThreads(): CommentThread[] { - // Demo threads take priority - if (this._demoThreads) { - return Object.values(this._demoThreads).sort((a, b) => a.createdAt - b.createdAt); - } - if (!this._doc || !this._noteId) return []; - const item = this._doc.items?.[this._noteId]; - if (!item?.comments) return []; - return Object.values(item.comments as Record) - .sort((a, b) => a.createdAt - b.createdAt); - } - - private dispatchDemoMutation() { - if (!this._demoThreads || !this._noteId) return; - this.dispatchEvent(new CustomEvent('comment-demo-mutation', { - detail: { noteId: this._noteId, threads: { ...this._demoThreads } }, - bubbles: true, - composed: true, - })); - } - - private render() { - const threads = this.getThreads(); - const suggestions = this._suggestions || []; - if (threads.length === 0 && suggestions.length === 0 && !this._activeThreadId) { - this.shadow.innerHTML = ''; - return; - } - - const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }; - const timeAgo = (ts: number) => { - 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`; - }; - const formatDate = (ts: number) => new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); - const { authorId: currentUserId, authorName: currentUserName } = this.getSessionInfo(); - const initials = (name: string) => name.split(/\s+/).map(w => w[0] || '').join('').slice(0, 2).toUpperCase() || '?'; - const avatarColor = (id: string) => { - let h = 0; - for (let i = 0; i < id.length; i++) h = id.charCodeAt(i) + ((h << 5) - h); - return `hsl(${Math.abs(h) % 360}, 55%, 55%)`; - }; - - this.shadow.innerHTML = ` - -
-
- ${suggestions.length > 0 ? `Suggestions (${suggestions.length})` : ''} ${threads.length > 0 ? `Comments (${threads.filter(t => !t.resolved).length})` : ''} - -
- ${suggestions.length > 0 ? ` - ${suggestions.map(s => ` -
-
-
${initials(s.authorName)}
- ${esc(s.authorName)} - ${s.type === 'insert' ? 'Added' : 'Deleted'} - ${timeAgo(s.createdAt)} -
-
${esc(s.text)}
-
- - -
-
- `).join('')} - ` : ''} - ${threads.map(thread => { - const reactions = thread.reactions || {}; - const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0); - const isActive = thread.id === this._activeThreadId; - const hasMessages = thread.messages.length > 0; - const firstMsg = thread.messages[0]; - const authorName = firstMsg?.authorName || currentUserName; - const authorId = firstMsg?.authorId || currentUserId; - - return ` -
-
-
${initials(authorName)}
-
- ${esc(authorName)} - ${timeAgo(thread.createdAt)} -
-
- ${hasMessages ? ` -
${esc(firstMsg.text)}
- ${thread.messages.slice(1).map(msg => ` -
-
-
${initials(msg.authorName)}
- ${esc(msg.authorName)} - ${timeAgo(msg.createdAt)} -
-
${esc(msg.text)}
-
- `).join('')} - ` : ` -
- -
- - -
-
- `} - ${hasMessages && reactionEntries.length > 0 ? ` -
- ${reactionEntries.map(([emoji, users]) => ` - - `).join('')} - -
-
- ${REACTION_EMOJIS.map(e => ``).join('')} -
- ` : ''} - ${hasMessages && thread.reminderAt ? ` -
- ⏰ ${formatDate(thread.reminderAt)} - -
- ` : ''} - ${hasMessages ? ` -
- -
- -
-
- ` : ''} -
- ${hasMessages ? ` - - - - ` : ''} - - -
-
`; - }).join('')} -
- `; - - this.wireEvents(); - - // Auto-focus new comment textarea - requestAnimationFrame(() => { - const newInput = this.shadow.querySelector('.new-comment-input') as HTMLTextAreaElement; - if (newInput) newInput.focus(); - }); - } - - private wireEvents() { - // Suggestion accept/reject - this.shadow.querySelectorAll('[data-accept-suggestion]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const id = (btn as HTMLElement).dataset.acceptSuggestion; - if (id) this.dispatchEvent(new CustomEvent('suggestion-accept', { detail: { suggestionId: id }, bubbles: true, composed: true })); - }); - }); - this.shadow.querySelectorAll('[data-reject-suggestion]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const id = (btn as HTMLElement).dataset.rejectSuggestion; - if (id) this.dispatchEvent(new CustomEvent('suggestion-reject', { detail: { suggestionId: id }, bubbles: true, composed: true })); - }); - }); - - // Click suggestion card to scroll editor to it - this.shadow.querySelectorAll('.suggestion-card[data-suggestion-id]').forEach(el => { - el.addEventListener('click', (e) => { - if ((e.target as HTMLElement).closest('button')) return; - const id = (el as HTMLElement).dataset.suggestionId; - if (!id || !this._editor) return; - this._editor.state.doc.descendants((node, pos) => { - if (!node.isText) return; - const mark = node.marks.find(m => - (m.type.name === 'suggestionInsert' || m.type.name === 'suggestionDelete') && - m.attrs.suggestionId === id - ); - if (mark) { - this._editor!.commands.setTextSelection(pos); - this._editor!.commands.scrollIntoView(); - return false; - } - }); - }); - }); - - // Collapse/expand panel - const collapseBtn = this.shadow.querySelector('[data-action="toggle-collapse"]'); - if (collapseBtn) { - collapseBtn.addEventListener('click', (e) => { - if ((e.target as HTMLElement).closest('.thread, input, textarea, button:not(.collapse-btn)')) return; - const panel = this.shadow.getElementById('comment-panel'); - if (panel) panel.classList.toggle('collapsed'); - }); - } - - // Click thread to scroll editor to it - this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => { - el.addEventListener('click', (e) => { - // Don't handle clicks on inputs/buttons/textareas - const target = e.target as HTMLElement; - if (target.closest('input, textarea, button')) return; - const threadId = (el as HTMLElement).dataset.thread; - if (!threadId || !this._editor) return; - this._activeThreadId = threadId; - this._editor.state.doc.descendants((node, pos) => { - if (!node.isText) return; - const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId); - if (mark) { - this._editor!.commands.setTextSelection(pos); - this._editor!.commands.scrollIntoView(); - return false; - } - }); - this.render(); - }); - }); - - // New comment submit (thread with no messages yet) - this.shadow.querySelectorAll('[data-submit-new]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const threadId = (btn as HTMLElement).dataset.submitNew; - if (!threadId) return; - const textarea = this.shadow.querySelector(`textarea[data-new-thread="${threadId}"]`) as HTMLTextAreaElement; - const text = textarea?.value?.trim(); - if (!text) return; - this.addReply(threadId, text); - }); - }); - - // New comment cancel — delete the empty thread - this.shadow.querySelectorAll('[data-cancel-new]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const threadId = (btn as HTMLElement).dataset.cancelNew; - if (threadId) this.deleteThread(threadId); - }); - }); - - // New comment textarea — Ctrl+Enter to submit, Escape to cancel - this.shadow.querySelectorAll('.new-comment-input').forEach(textarea => { - textarea.addEventListener('keydown', (e) => { - const ke = e as KeyboardEvent; - if (ke.key === 'Enter' && (ke.ctrlKey || ke.metaKey)) { - e.stopPropagation(); - const threadId = (textarea as HTMLTextAreaElement).dataset.newThread; - const text = (textarea as HTMLTextAreaElement).value.trim(); - if (threadId && text) this.addReply(threadId, text); - } else if (ke.key === 'Escape') { - e.stopPropagation(); - const threadId = (textarea as HTMLTextAreaElement).dataset.newThread; - if (threadId) this.deleteThread(threadId); - } - }); - textarea.addEventListener('click', (e) => e.stopPropagation()); - }); - - // Reply - this.shadow.querySelectorAll('[data-reply]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const threadId = (btn as HTMLElement).dataset.reply; - if (!threadId) return; - const input = this.shadow.querySelector(`input[data-thread="${threadId}"]`) as HTMLInputElement; - const text = input?.value?.trim(); - if (!text) return; - this.addReply(threadId, text); - input.value = ''; - }); - }); - - // Reply on Enter - this.shadow.querySelectorAll('.reply-input').forEach(input => { - input.addEventListener('keydown', (e) => { - if ((e as KeyboardEvent).key === 'Enter') { - e.stopPropagation(); - const threadId = (input as HTMLInputElement).dataset.thread; - const text = (input as HTMLInputElement).value.trim(); - if (threadId && text) { - this.addReply(threadId, text); - (input as HTMLInputElement).value = ''; - } - } - }); - input.addEventListener('click', (e) => e.stopPropagation()); - }); - - // Resolve / re-open - this.shadow.querySelectorAll('[data-resolve]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const threadId = (btn as HTMLElement).dataset.resolve; - if (threadId) this.toggleResolve(threadId); - }); - }); - - // Delete - this.shadow.querySelectorAll('[data-delete]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const threadId = (btn as HTMLElement).dataset.delete; - if (threadId) this.deleteThread(threadId); - }); - }); - - // Reaction pill toggle (existing reaction) - this.shadow.querySelectorAll('[data-react-thread]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const el = btn as HTMLElement; - this.toggleReaction(el.dataset.reactThread!, el.dataset.reactEmoji!); - }); - }); - - // Reaction add "+" button — toggle emoji picker - this.shadow.querySelectorAll('[data-react-add]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const threadId = (btn as HTMLElement).dataset.reactAdd!; - const picker = this.shadow.querySelector(`[data-picker="${threadId}"]`); - if (picker) picker.classList.toggle('open'); - }); - }); - - // Emoji picker buttons - this.shadow.querySelectorAll('[data-pick-thread]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const el = btn as HTMLElement; - this.toggleReaction(el.dataset.pickThread!, el.dataset.pickEmoji!); - // Close picker - const picker = this.shadow.querySelector(`[data-picker="${el.dataset.pickThread}"]`); - if (picker) picker.classList.remove('open'); - }); - }); - - // Reminder "set" button - this.shadow.querySelectorAll('[data-remind-set]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const threadId = (btn as HTMLElement).dataset.remindSet!; - const input = this.shadow.querySelector(`[data-remind-input="${threadId}"]`) as HTMLInputElement; - if (input) { - input.style.display = input.style.display === 'none' ? 'inline-block' : 'none'; - if (input.style.display !== 'none') input.focus(); - } - }); - }); - - // Reminder date change - this.shadow.querySelectorAll('[data-remind-input]').forEach(input => { - input.addEventListener('click', (e) => e.stopPropagation()); - input.addEventListener('change', (e) => { - e.stopPropagation(); - const threadId = (input as HTMLInputElement).dataset.remindInput!; - const val = (input as HTMLInputElement).value; - if (val) this.setReminder(threadId, new Date(val + 'T09:00:00').getTime()); - }); - }); - - // Reminder clear - this.shadow.querySelectorAll('[data-remind-clear]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const threadId = (btn as HTMLElement).dataset.remindClear!; - this.clearReminder(threadId); - }); - }); - } - - private addReply(threadId: string, text: string) { - const { authorName, authorId } = this.getSessionInfo(); - const msg: CommentMessage = { - id: `m_${Date.now()}`, - authorId, - authorName, - text, - createdAt: Date.now(), - }; - - if (this._demoThreads) { - const thread = this._demoThreads[threadId]; - if (!thread) return; - if (!thread.messages) thread.messages = []; - thread.messages.push(msg); - this.dispatchDemoMutation(); - this.render(); - return; - } - - if (!this._noteId || !this._subscribedDocId) return; - const runtime = (window as any).__rspaceOfflineRuntime; - if (!runtime?.isInitialized) return; - - const noteId = this._noteId; - runtime.change(this._subscribedDocId as DocumentId, 'Add comment reply', (d: NotebookDoc) => { - const item = d.items[noteId]; - if (!item?.comments?.[threadId]) return; - const thread = item.comments[threadId] as any; - if (!thread.messages) thread.messages = []; - thread.messages.push(msg); - }); - this._doc = runtime.get(this._subscribedDocId as DocumentId); - this.render(); - } - - private toggleReaction(threadId: string, emoji: string) { - const { authorId } = this.getSessionInfo(); - - if (this._demoThreads) { - const thread = this._demoThreads[threadId]; - if (!thread) return; - if (!thread.reactions) thread.reactions = {}; - if (!thread.reactions[emoji]) thread.reactions[emoji] = []; - const idx = thread.reactions[emoji].indexOf(authorId); - if (idx >= 0) thread.reactions[emoji].splice(idx, 1); - else thread.reactions[emoji].push(authorId); - this.dispatchDemoMutation(); - this.render(); - return; - } - - if (!this._noteId || !this._subscribedDocId) return; - const runtime = (window as any).__rspaceOfflineRuntime; - if (!runtime?.isInitialized) return; - - const noteId = this._noteId; - runtime.change(this._subscribedDocId as DocumentId, 'Toggle reaction', (d: NotebookDoc) => { - const item = d.items[noteId]; - if (!item?.comments?.[threadId]) return; - const thread = item.comments[threadId] as any; - if (!thread.reactions) thread.reactions = {}; - if (!thread.reactions[emoji]) thread.reactions[emoji] = []; - const users: string[] = thread.reactions[emoji]; - const idx = users.indexOf(authorId); - if (idx >= 0) users.splice(idx, 1); - else users.push(authorId); - }); - this._doc = runtime.get(this._subscribedDocId as DocumentId); - this.render(); - } - - private async setReminder(threadId: string, reminderAt: number) { - // Set reminder on thread - let reminderId: string | undefined; - - // Try creating a reminder via rSchedule API (non-demo only) - if (!this.isDemo && this._space) { - try { - const res = await fetch(`${getModuleApiBase("rschedule")}/api/reminders`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, - body: JSON.stringify({ - title: `Comment reminder`, - remindAt: new Date(reminderAt).toISOString(), - allDay: true, - sourceModule: 'rnotes', - sourceEntityId: threadId, - }), - }); - if (res.ok) { - const data = await res.json(); - reminderId = data.id; - } - } catch {} - } - - if (this._demoThreads) { - const thread = this._demoThreads[threadId]; - if (thread) { - thread.reminderAt = reminderAt; - if (reminderId) thread.reminderId = reminderId; - } - this.dispatchDemoMutation(); - this.render(); - return; - } - - if (!this._noteId || !this._subscribedDocId) return; - const runtime = (window as any).__rspaceOfflineRuntime; - if (!runtime?.isInitialized) return; - - const noteId = this._noteId; - runtime.change(this._subscribedDocId as DocumentId, 'Set comment reminder', (d: NotebookDoc) => { - const item = d.items[noteId]; - if (!item?.comments?.[threadId]) return; - const thread = item.comments[threadId] as any; - thread.reminderAt = reminderAt; - if (reminderId) thread.reminderId = reminderId; - }); - this._doc = runtime.get(this._subscribedDocId as DocumentId); - this.render(); - } - - private async clearReminder(threadId: string) { - // Get existing reminderId before clearing - const threads = this.getThreads(); - const thread = threads.find(t => t.id === threadId); - const reminderId = thread?.reminderId; - - // Delete from rSchedule if exists - if (reminderId && !this.isDemo && this._space) { - try { - await fetch(`${getModuleApiBase("rschedule")}/api/reminders/${reminderId}`, { - method: 'DELETE', - headers: this.authHeaders(), - }); - } catch {} - } - - if (this._demoThreads) { - const t = this._demoThreads[threadId]; - if (t) { delete t.reminderAt; delete t.reminderId; } - this.dispatchDemoMutation(); - this.render(); - return; - } - - if (!this._noteId || !this._subscribedDocId) return; - const runtime = (window as any).__rspaceOfflineRuntime; - if (!runtime?.isInitialized) return; - - const noteId = this._noteId; - runtime.change(this._subscribedDocId as DocumentId, 'Clear comment reminder', (d: NotebookDoc) => { - const item = d.items[noteId]; - if (!item?.comments?.[threadId]) return; - const t = item.comments[threadId] as any; - delete t.reminderAt; - delete t.reminderId; - }); - this._doc = runtime.get(this._subscribedDocId as DocumentId); - this.render(); - } - - private authHeaders(): Record { - try { - const s = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); - if (s?.accessToken) return { 'Authorization': 'Bearer ' + s.accessToken }; - } catch {} - return {}; - } - - private toggleResolve(threadId: string) { - if (this._demoThreads) { - const thread = this._demoThreads[threadId]; - if (thread) thread.resolved = !thread.resolved; - this.dispatchDemoMutation(); - // Update editor mark - this.updateEditorResolveMark(threadId, this._demoThreads[threadId]?.resolved ?? false); - this.render(); - return; - } - - if (!this._noteId || !this._subscribedDocId) return; - const runtime = (window as any).__rspaceOfflineRuntime; - if (!runtime?.isInitialized) return; - - const noteId = this._noteId; - runtime.change(this._subscribedDocId as DocumentId, 'Toggle comment resolve', (d: NotebookDoc) => { - const item = d.items[noteId]; - if (!item?.comments?.[threadId]) return; - (item.comments[threadId] as any).resolved = !(item.comments[threadId] as any).resolved; - }); - this._doc = runtime.get(this._subscribedDocId as DocumentId); - - const thread = this._doc?.items?.[this._noteId]?.comments?.[threadId]; - if (thread) this.updateEditorResolveMark(threadId, thread.resolved); - this.render(); - } - - private updateEditorResolveMark(threadId: string, resolved: boolean) { - if (!this._editor) return; - this._editor.state.doc.descendants((node, pos) => { - if (!node.isText) return; - const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId); - if (mark) { - const { tr } = this._editor!.state; - tr.removeMark(pos, pos + node.nodeSize, mark); - tr.addMark(pos, pos + node.nodeSize, - this._editor!.schema.marks.comment.create({ threadId, resolved }) - ); - this._editor!.view.dispatch(tr); - return false; - } - }); - } - - private deleteThread(threadId: string) { - if (this._demoThreads) { - delete this._demoThreads[threadId]; - this.dispatchDemoMutation(); - this.removeEditorCommentMark(threadId); - if (this._activeThreadId === threadId) this._activeThreadId = null; - this.render(); - return; - } - - if (!this._noteId || !this._subscribedDocId) return; - const runtime = (window as any).__rspaceOfflineRuntime; - if (!runtime?.isInitialized) return; - - const noteId = this._noteId; - runtime.change(this._subscribedDocId as DocumentId, 'Delete comment thread', (d: NotebookDoc) => { - const item = d.items[noteId]; - if (item?.comments?.[threadId]) { - delete (item.comments as any)[threadId]; - } - }); - this._doc = runtime.get(this._subscribedDocId as DocumentId); - - this.removeEditorCommentMark(threadId); - if (this._activeThreadId === threadId) this._activeThreadId = null; - this.render(); - } - - private removeEditorCommentMark(threadId: string) { - if (!this._editor) return; - const { state } = this._editor; - const { tr } = state; - state.doc.descendants((node, pos) => { - if (!node.isText) return; - const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId); - if (mark) { - tr.removeMark(pos, pos + node.nodeSize, mark); - } - }); - if (tr.docChanged) { - this._editor.view.dispatch(tr); - } - } -} - -customElements.define('notes-comment-panel', NotesCommentPanel); diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 3f3772ff..0e0f4ce7 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -1,4373 +1,627 @@ /** - * — notebook and note management. + * — vault browser for rNotes. * - * Browse notebooks, create/edit notes with rich text (Tiptap), - * search, tag management. + * Replaces the old TipTap editor with a read-only vault sync browser. + * Obsidian / Logseq vaults are uploaded as ZIP files, parsed server-side, + * and stored in Automerge + on-disk. This component is the browse UI. * - * Notebook list: REST (GET /api/notebooks) - * Notebook detail + notes: Automerge sync via WebSocket - * Search: REST (GET /api/notes?q=...) + * Left sidebar : vault list (fetches /api/vault/list?space=) + * Center panel : file tree (fetches /api/vault/:id/notes?space=) + * Right panel : MD preview (fetches /api/vault/:id/note/:path?space=) */ -import * as Automerge from '@automerge/automerge'; -import { makeDraggableAll } from '../../../shared/draggable'; -import { notebookSchema } from '../schemas'; -import type { DocumentId } from '../../../shared/local-first/document'; -import { getAccessToken } from '../../../shared/components/rstack-identity'; -import { broadcastPresence as sharedBroadcastPresence, startPresenceHeartbeat } from '../../../shared/collab-presence'; -import { Editor } from '@tiptap/core'; -import StarterKit from '@tiptap/starter-kit'; -import Link from '@tiptap/extension-link'; -import Image from '@tiptap/extension-image'; -import TaskList from '@tiptap/extension-task-list'; -import TaskItem from '@tiptap/extension-task-item'; -import Placeholder from '@tiptap/extension-placeholder'; -import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; -import Typography from '@tiptap/extension-typography'; -import Underline from '@tiptap/extension-underline'; -import { common, createLowlight } from 'lowlight'; -import { createSlashCommandPlugin } from './slash-command'; -import type { ImportExportDialog } from './import-export-dialog'; -import { SpeechDictation } from '../../../lib/speech-dictation'; -import { Markdown } from 'tiptap-markdown'; -import { TourEngine } from '../../../shared/tour-engine'; -import * as Y from 'yjs'; -import { IndexeddbPersistence } from 'y-indexeddb'; -import { ySyncPlugin, yUndoPlugin, yCursorPlugin } from '@tiptap/y-tiptap'; -import { RSpaceYjsProvider } from '../yjs-ws-provider'; -import { CommentMark } from './comment-mark'; -import { SuggestionInsertMark, SuggestionDeleteMark } from './suggestion-marks'; -import { createSuggestionPlugin, acceptSuggestion, rejectSuggestion } from './suggestion-plugin'; -import './comment-panel'; +import type { VaultMeta, VaultNoteMeta } from '../schemas'; -const lowlight = createLowlight(common); +// ── Tiny markdown → HTML converter (no dependencies) ───────────────────────── -/** Inline SVG icons for toolbar buttons (16×16, stroke-based, currentColor) */ -const ICONS: Record = { - bold: '', - italic: '', - underline: '', - strike: '', - code: '', - bulletList: '', - orderedList: '123', - taskList: '', - blockquote: '', - codeBlock: '', - horizontalRule: '', - link: '', - image: '', - undo: '', - redo: '', - mic: '', - summarize: '', -}; +function renderMarkdown(md: string): string { + // Escape HTML in code blocks first, then restore placeholders + const codeBlocks: string[] = []; + let html = md.replace(/```[\s\S]*?```/g, (m) => { + const lang = m.match(/^```(\w*)/)?.[1] ?? ''; + const code = m.replace(/^```\w*\n?/, '').replace(/\n?```$/, ''); + const escaped = code.replace(/&/g, '&').replace(//g, '>'); + codeBlocks.push(`
${escaped}
`); + return `\x00CB${codeBlocks.length - 1}\x00`; + }); -interface Notebook { - id: string; - title: string; - description: string; - cover_color: string; - note_count: string; - updated_at: string; + // Inline code + const inlineCodes: string[] = []; + html = html.replace(/`([^`\n]+)`/g, (_, c) => { + const escaped = c.replace(/&/g, '&').replace(//g, '>'); + inlineCodes.push(`${escaped}`); + return `\x00IC${inlineCodes.length - 1}\x00`; + }); + + // Wikilinks [[target]] or [[target|alias]] + html = html.replace(/\[\[([^\]]+)\]\]/g, (_, inner) => { + const [target, alias] = inner.split('|'); + const label = alias ?? target; + return `${label.trim()}`; + }); + + // Images ![alt](url) + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); + // Links [text](url) + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Process line-by-line + const lines = html.split('\n'); + const out: string[] = []; + let inUl = false; + let inOl = false; + let inBlockquote = false; + + function closeList() { + if (inUl) { out.push(''); inUl = false; } + if (inOl) { out.push(''); inOl = false; } + } + function closeBlockquote() { + if (inBlockquote) { out.push(''); inBlockquote = false; } + } + + for (const raw of lines) { + const line = raw; + + // Headings + const hm = line.match(/^(#{1,6})\s+(.+)/); + if (hm) { + closeList(); closeBlockquote(); + const level = hm[1].length; + out.push(`${inlineFormat(hm[2])}`); + continue; + } + + // Blockquote + if (line.startsWith('> ')) { + closeList(); + if (!inBlockquote) { out.push('
'); inBlockquote = true; } + out.push(`

${inlineFormat(line.slice(2))}

`); + continue; + } else if (inBlockquote && line.trim() === '') { + closeBlockquote(); + } + + // HR + if (/^[-*_]{3,}$/.test(line.trim())) { + closeList(); closeBlockquote(); + out.push('
'); + continue; + } + + // Unordered list + const ulm = line.match(/^(\s*)[-*+]\s+(.+)/); + if (ulm) { + closeBlockquote(); + if (!inUl) { out.push('
    '); inUl = true; } + out.push(`
  • ${inlineFormat(ulm[2])}
  • `); + continue; + } + + // Ordered list + const olm = line.match(/^\d+\.\s+(.+)/); + if (olm) { + closeBlockquote(); + if (!inOl) { out.push('
      '); inOl = true; } + out.push(`
    1. ${inlineFormat(olm[1])}
    2. `); + continue; + } + + // Blank line + if (line.trim() === '') { + closeList(); closeBlockquote(); + out.push('
      '); + continue; + } + + // Paragraph + closeList(); closeBlockquote(); + out.push(`

      ${inlineFormat(line)}

      `); + } + + closeList(); closeBlockquote(); + let result = out.join('\n'); + + // Restore placeholders + result = result.replace(/\x00CB(\d+)\x00/g, (_, i) => codeBlocks[Number(i)]); + result = result.replace(/\x00IC(\d+)\x00/g, (_, i) => inlineCodes[Number(i)]); + return result; } -interface Note { - id: string; - title: string; - content: string; - content_plain: string; - content_format?: 'html' | 'tiptap-json'; - type: string; - tags: string[] | null; - is_pinned: boolean; - url?: string | null; - language?: string | null; - fileUrl?: string | null; - mimeType?: string | null; - duration?: number | null; - source_ref?: { source: string; syncStatus?: string; lastSyncedAt?: number }; - sort_order?: number; - created_at: string; - updated_at: string; +function inlineFormat(s: string): string { + return s + .replace(/\*\*\*(.+?)\*\*\*/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/~~(.+?)~~/g, '$1'); } -type NoteType = 'NOTE' | 'CODE' | 'BOOKMARK' | 'CLIP' | 'IMAGE' | 'AUDIO' | 'FILE'; +// ── Helpers ────────────────────────────────────────────────────────────────── -interface CreateNoteOpts { - type?: NoteType; - title?: string; - url?: string; - fileUrl?: string; - mimeType?: string; - duration?: number; - language?: string; - content?: string; - tags?: string[]; +function timeAgo(ts: number): string { + const sec = Math.floor((Date.now() - ts) / 1000); + if (sec < 60) return 'just now'; + if (sec < 3600) return `${Math.floor(sec / 60)}m ago`; + if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`; + return `${Math.floor(sec / 86400)}d ago`; } -/** Shape of Automerge notebook doc (matches PG→Automerge migration) */ -interface NotebookDoc { - meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number }; - notebook: { - id: string; title: string; slug: string; description: string; - coverColor: string; isPublic: boolean; createdAt: number; updatedAt: number; - }; - items: Record; +function escHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } -class FolkNotesApp extends HTMLElement { - private shadow!: ShadowRoot; - private space = ""; - private notebooks: Notebook[] = []; - private selectedNotebook: (Notebook & { notes: Note[] }) | null = null; - private selectedNote: Note | null = null; - private searchQuery = ""; - private searchResults: Note[] = []; - private typeFilter: NoteType | '' = ''; - private loading = false; - private error = ""; +// ── Icons (inline SVG, 16×16, stroke-based) ────────────────────────────────── - // Sidebar state - private expandedNotebooks = new Set(); - private notebookNotes = new Map(); - private sidebarOpen = true; - private mobileEditing = false; - private _resizeHandler: (() => void) | null = null; - private _suggestionSyncTimer: any = null; +const ICON_OBSIDIAN = ``; +const ICON_LOGSEQ = ``; +const ICON_FOLDER = ``; +const ICON_FILE = ``; +const ICON_UPLOAD = ``; +const ICON_SEARCH = ``; +const ICON_CHEVRON = ``; +const ICON_SYNC = ``; - // Zone-based rendering - private navZone!: HTMLDivElement; - private contentZone!: HTMLDivElement; - private metaZone!: HTMLDivElement; +// ── Web Component ───────────────────────────────────────────────────────────── - // Guided tour - private _tour!: TourEngine; - private static readonly TOUR_STEPS = [ - { target: '#create-notebook', title: "Create a Notebook", message: "Notebooks organise your notes by topic. Click '+ New Notebook' to create one.", advanceOnClick: true }, - { target: '.sbt-nb-add', title: "Create a Note", message: "Each notebook has a '+' button to add new notes. Click it to create one.", advanceOnClick: true }, - { target: '#editor-toolbar', title: "Editor Toolbar", message: "Format text with the toolbar — bold, lists, code blocks, headings, and more. Click Next to continue.", advanceOnClick: false }, - { target: '[data-cmd="mic"]', title: "Voice Notes", message: "Record voice notes with live transcription. Your words appear as you speak — no uploads needed.", advanceOnClick: false }, - ]; +export class FolkNotesApp extends HTMLElement { + // Observed attributes + static get observedAttributes() { return ['space', 'module-id']; } - // Tiptap editor - private editor: Editor | null = null; - private editorNoteId: string | null = null; - private isRemoteUpdate = false; - private editorUpdateTimer: ReturnType | null = null; - private dictation: SpeechDictation | null = null; - - // Yjs collaboration state - private ydoc: Y.Doc | null = null; - private yjsProvider: RSpaceYjsProvider | null = null; - private yIndexedDb: IndexeddbPersistence | null = null; - private yjsPlainTextTimer: ReturnType | null = null; - - // Comments/suggestions state - private suggestingMode = false; - - // Audio recording (AUDIO note view) - private audioRecorder: MediaRecorder | null = null; - private audioSegments: { id: string; text: string; timestamp: number; isFinal: boolean }[] = []; - private audioRecordingStart = 0; - private audioRecordingTimer: ReturnType | null = null; - private audioRecordingDictation: SpeechDictation | null = null; - - // Automerge sync state (via shared runtime) - private doc: Automerge.Doc | null = null; - private subscribedDocId: string | null = null; - private syncConnected = false; - private _offlineUnsub: (() => void) | null = null; - private _offlineNotebookUnsubs: (() => void)[] = []; - - // ── Presence indicators ── - private _presencePeers: Map = new Map(); - private _stopPresence: (() => void) | null = null; - private _presenceUnsub: (() => void) | null = null; - private _presenceGC: ReturnType | null = null; - - // ── Demo data ── - private demoNotebooks: (Notebook & { notes: Note[] })[] = []; - private _demoThreads = new Map>(); + // State + private _space = ''; + private _moduleId = 'rnotes'; + private _vaults: VaultMeta[] = []; + private _notes: VaultNoteMeta[] = []; + private _selectedVaultId: string | null = null; + private _selectedNotePath: string | null = null; + private _noteContent = ''; + private _searchQuery = ''; + private _folderOpen: Record = {}; + private _loading = false; + private _uploadOpen = false; + private _uploadStatus = ''; + private _shadow: ShadowRoot; constructor() { super(); - this.shadow = this.attachShadow({ mode: "open", delegatesFocus: true }); - this._tour = new TourEngine( - this.shadow, - FolkNotesApp.TOUR_STEPS, - "rnotes_tour_done", - () => this.shadow.host as HTMLElement, - ); + this._shadow = this.attachShadow({ mode: 'open' }); + } + + get space() { return this._space; } + set space(v: string) { this._space = v; this._fetchVaults(); } + + attributeChangedCallback(name: string, _old: string | null, val: string | null) { + if (name === 'space' && val) { this._space = val; this._fetchVaults(); } + if (name === 'module-id' && val) { this._moduleId = val; } } connectedCallback() { - this.space = this.getAttribute("space") || "demo"; - this.setupShadow(); - if (this.space === "demo") { this.loadDemoData(); } - else { this.subscribeOfflineRuntime(); this.loadNotebooks(); this.setupPresence(); } - // Auto-start tour on first visit - if (!localStorage.getItem("rnotes_tour_done")) { - setTimeout(() => this._tour.start(), 1200); - } - - // Mobile resize handler — sync mobile-editing state on viewport change - this._resizeHandler = () => { - if (window.innerWidth > 768) { - // Switched to desktop — remove mobile-editing so both panels show - this.setMobileEditing(false); - } else if (this.selectedNote && this.editor) { - // Went back to mobile with a note open — restore editor screen - this.setMobileEditing(true); - } - }; - window.addEventListener('resize', this._resizeHandler); + this._space = this.getAttribute('space') ?? ''; + this._moduleId = this.getAttribute('module-id') ?? 'rnotes'; + this._render(); + if (this._space) this._fetchVaults(); } - private async subscribeOfflineRuntime() { - const runtime = (window as any).__rspaceOfflineRuntime; - if (!runtime?.isInitialized) return; + // ── API base ───────────────────────────────────────────────────────────── - try { - // Discover all cached notebooks for this space - const docs = await runtime.subscribeModule('notes', 'notebooks', notebookSchema); - this.syncConnected = runtime.isOnline; - - // Listen for connection state changes - this._offlineUnsub = runtime.onStatusChange((status: string) => { - this.syncConnected = status === 'online'; - }); - - // Populate notebook list from cached docs if REST hasn't loaded - if (docs.size > 0 && this.notebooks.length === 0) { - const fromDocs: Notebook[] = []; - for (const [, doc] of docs) { - const d = doc as NotebookDoc; - if (!d?.notebook?.id) continue; - fromDocs.push({ - id: d.notebook.id, title: d.notebook.title, - description: d.notebook.description || '', - cover_color: d.notebook.coverColor || '#3b82f6', - note_count: String(Object.keys(d.items || {}).length), - updated_at: d.notebook.updatedAt ? new Date(d.notebook.updatedAt).toISOString() : new Date().toISOString(), - }); - } - if (fromDocs.length > 0) { - this.notebooks = fromDocs; - this.renderNav(); - } - } - } catch { - // Runtime unavailable — REST fallback handles data - } - } - - private setupShadow() { - const style = document.createElement('style'); - style.textContent = this.getStyles(); - - const layout = document.createElement('div'); - layout.id = 'notes-layout'; - - this.navZone = document.createElement('div'); - this.navZone.id = 'nav-zone'; - - const rightCol = document.createElement('div'); - rightCol.className = 'notes-right-col'; - - this.contentZone = document.createElement('div'); - this.contentZone.id = 'content-zone'; - this.metaZone = document.createElement('div'); - this.metaZone.id = 'meta-zone'; - - rightCol.appendChild(this.contentZone); - rightCol.appendChild(this.metaZone); - - // Sidebar reopen tab (lives on layout, outside navZone so it's visible when collapsed) - const reopenBtn = document.createElement('button'); - reopenBtn.id = 'sidebar-reopen'; - reopenBtn.className = 'sidebar-reopen'; - reopenBtn.title = 'Show sidebar'; - reopenBtn.textContent = '\u203A'; - reopenBtn.addEventListener('click', () => this.toggleSidebar(true)); - - layout.appendChild(this.navZone); - layout.appendChild(reopenBtn); - layout.appendChild(rightCol); - - this.shadow.appendChild(style); - this.shadow.appendChild(layout); - } - - // ── Demo data ── - - private loadDemoData() { - const now = Date.now(); - const hour = 3600000; - const day = 86400000; - - const tripPlanningNotes: Note[] = [ - { - id: "demo-note-1", title: "Pre-trip Preparation", - content: `

      Pre-trip Preparation

      Flights & Transfers

      • Jul 6: Fly Geneva, shuttle to Chamonix (~1.5h)
      • Jul 14: Train Zermatt to Dolomites (Bernina Express, ~6h scenic route)
      • Jul 20: Fly home from Innsbruck

      Book the Aiguille du Midi cable car tickets at least 2 weeks in advance -- they sell out fast in July.

      Travel Documents

      1. Passports (valid 6+ months)
      2. EU health insurance cards (EHIC)
      3. Travel insurance policy (ref: WA-2026-7891)
      4. Hut reservation confirmations (printed copies)
      5. Drone registration for Italy

      Budget Overview

      Total budget: EUR 4,000 across 4 travelers.

      Transport:     EUR  800 (20%)
      -Accommodation: EUR 1200 (30%)
      -Activities:    EUR 1000 (25%)
      -Food:          EUR  600 (15%)
      -Gear:          EUR  400 (10%)

      Maya is tracking expenses in rFlows. Current spend: EUR 1,203.

      `, - content_plain: "Pre-trip preparation checklist covering flights, transfers, travel documents, and budget overview for the Alpine Explorer 2026 trip.", - content_format: 'html', - type: "NOTE", tags: ["planning", "budget", "transport"], is_pinned: true, - created_at: new Date(now - 14 * day).toISOString(), updated_at: new Date(now - hour).toISOString(), - }, - { - id: "demo-note-2", title: "Accommodation Research", - content: `

      Accommodation Research

      Chamonix (Jul 6-10)

      • Refuge du Lac Blanc -- Jul 7, 4 beds, conf #LB2026-234
      • Airbnb in town for other nights (~EUR 120/night for 4 pax)
      • Consider Hotel Le Morgane if Airbnb falls through

      Zermatt (Jul 10-14)

      • Hornlihutte (Matterhorn base) -- Waitlisted for Jul 12
      • Main accommodation: Apartment near Bahnhofstrasse
      • Car-free village, arrive by Glacier Express

      Zermatt is expensive. Budget EUR 80-100pp/night minimum. The apartment saves us about 40% vs hotels.

      Dolomites (Jul 14-20)

      • Rifugio Locatelli -- Jul 15, 4 beds, conf #TRE2026-089
      • Val Gardena base: Ortisei area
      • Look for agriturismo options for authentic experience
      `, - content_plain: "Accommodation research for all three destinations: Chamonix, Zermatt, and Dolomites. Includes confirmed bookings, waitlists, and budget estimates.", - content_format: 'html', - type: "NOTE", tags: ["accommodation", "budget"], is_pinned: false, - created_at: new Date(now - 12 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(), - }, - { - id: "demo-note-3", title: "Activity Planning", - content: `

      Activity Planning

      Hiking Routes

      • Lac Blanc (Jul 7) -- Acclimatization hike, ~6h round trip, 1000m elevation gain.
      • Gornergrat Sunrise (Jul 11) -- Take the first train up at 7am, hike down.
      • Matterhorn Base Camp (Jul 12) -- Full day trek to Hornlihutte. 1500m gain.
      • Tre Cime di Lavaredo (Jul 15) -- Classic loop, ~4h.
      • Seceda Ridgeline (Jul 17) -- Gondola up, ridge walk, hike down to Ortisei.

      Adventure Activities

      1. Via Ferrata at Aiguille du Midi (Jul 8) -- Rent harness + lanyard + helmet, ~EUR 25/day
      2. Paragliding over Zermatt (Jul 13) -- Tandem flights ~EUR 180pp
      3. Kayaking at Lago di Braies (Jul 16) -- Turquoise glacial lake, ~EUR 15/hour

      Rest Days

      • Jul 9: Explore Chamonix town, gear shopping
      • Jul 19: Free day before flying home, packing
      `, - content_plain: "Detailed activity planning including hiking routes with difficulty ratings, adventure activities with costs, and rest day plans.", - content_format: 'html', - type: "NOTE", tags: ["hiking", "activities", "adventure"], is_pinned: false, - created_at: new Date(now - 10 * day).toISOString(), updated_at: new Date(now - 5 * hour).toISOString(), - }, - { - id: "demo-note-4", title: "Gear Research", - content: `

      Gear Research

      Via Ferrata Kit

      Need harness + lanyard + helmet. Can rent in Chamonix for ~EUR 25/day per person.

      Camera & Drone

      • Bring the DJI Mini 4 Pro for Tre Cime and Seceda
      • Check Italian drone regulations! Need ENAC registration for flights over 250g
      • ND filters for long exposure water shots at Lago di Braies
      • Extra batteries (3x) -- cold altitude drains them fast

      Personal Gear Checklist

      • Hiking boots (broken in!)
      • Rain jacket (waterproof, not just resistant)
      • Headlamp + spare batteries
      • Trekking poles (collapsible for flights)
      • Sunscreen SPF 50 + lip balm
      • Wool base layers for hut nights
      `, - content_plain: "Gear research including Via Ferrata rental, camera and drone regulations, shared group gear status, and personal gear checklist.", - content_format: 'html', - type: "NOTE", tags: ["gear", "equipment", "budget"], is_pinned: false, - created_at: new Date(now - 8 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(), - }, - { - id: "demo-note-5", title: "Emergency Contacts & Safety", - content: `

      Emergency Contacts & Safety

      Emergency Numbers

      • France: 112 (EU general), PGHM Mountain Rescue: +33 4 50 53 16 89
      • Switzerland: 1414 (REGA air rescue), 144 (ambulance)
      • Italy: 118 (medical), 112 (general emergency)

      Insurance

      • Policy #: WA-2026-7891
      • Emergency line: +1-800-555-0199
      • Covers: mountain rescue, helicopter evacuation, medical repatriation

      Altitude Sickness Protocol

      1. Acclimatize in Chamonix (1,035m) for 2 days before going high
      2. Stay hydrated -- minimum 3L water per day above 2,500m
      3. Watch for symptoms: headache, nausea, dizziness
      4. Descend immediately if symptoms worsen
      `, - content_plain: "Emergency contacts for France, Switzerland, and Italy. Insurance details, altitude sickness protocol, weather contingency plans.", - content_format: 'html', - type: "NOTE", tags: ["safety", "emergency", "contacts"], is_pinned: false, - created_at: new Date(now - 7 * day).toISOString(), updated_at: new Date(now - 6 * hour).toISOString(), - }, - { - id: "demo-note-6", title: "Photo Spots & Creative Plan", - content: `

      Photo Spots & Creative Plan

      Must-Capture Locations

      1. Lac Blanc -- Reflection of Mont Blanc at sunrise. Arrive by 5:30am. Tripod essential.
      2. Gornergrat Panorama -- 360-degree view with Matterhorn. Golden hour is best.
      3. Tre Cime from Rifugio Locatelli -- The iconic three peaks at golden hour. Drone shots here.
      4. Seceda Ridgeline -- Dramatic Dolomite spires. Best drone footage location.
      5. Lago di Braies -- Turquoise water, use ND filters for long exposure reflections.

      Zine Plan (Maya)

      We are making an Alpine Explorer Zine after the trip:

      • Format: A5 risograph, 50 copies
      • Print at Chamonix Print Collective
      • Content: best photos, trail notes, hand-drawn maps
      • Price: EUR 12 per copy on rCart
      `, - content_plain: "Photography and creative plan including must-capture locations, drone shot list, zine production details, and video plan.", - content_format: 'html', - type: "NOTE", tags: ["photography", "creative", "planning"], is_pinned: false, - created_at: new Date(now - 5 * day).toISOString(), updated_at: new Date(now - 4 * hour).toISOString(), - }, - ]; - - // Typed demo notes - tripPlanningNotes.push( - { - id: "demo-note-code-1", title: "Expense Tracker Script", - content: `const expenses = [\n { item: "Flights", amount: 800, category: "transport" },\n { item: "Lac Blanc Hut", amount: 120, category: "accommodation" },\n { item: "Via Ferrata Rental", amount: 100, category: "activities" },\n];\n\nconst total = expenses.reduce((sum, e) => sum + e.amount, 0);\nconsole.log(\`Total: EUR \${total}\`);`, - content_plain: "Expense tracker script for the trip budget", - content_format: 'html', - type: "CODE", tags: ["budget", "code"], is_pinned: false, - language: "javascript", - created_at: new Date(now - 2 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(), - } as Note, - { - id: "demo-note-bookmark-1", title: "Chamonix Weather Forecast", - content: "

      Live weather forecast for the Chamonix valley. Check daily before hikes.

      ", - content_plain: "Live weather forecast for the Chamonix valley", - content_format: 'html', - type: "BOOKMARK", tags: ["weather", "chamonix"], is_pinned: false, - url: "https://www.chamonix.com/weather", - created_at: new Date(now - 3 * day).toISOString(), updated_at: new Date(now - 4 * hour).toISOString(), - } as Note, - ); - - const packingNotes: Note[] = [ - { - id: "demo-note-7", title: "Packing Checklist", - content: `

      Packing Checklist

      Footwear

      • Hiking boots (broken in!)
      • Camp sandals / flip-flops
      • Extra laces

      Clothing

      • Rain jacket (Gore-Tex)
      • Down jacket for hut nights
      • 3x wool base layers
      • 2x hiking pants
      • Sun hat + warm beanie

      Gear

      • Headlamp + spare batteries
      • Trekking poles (collapsible)
      • First aid kit
      • Sunscreen SPF 50
      • Water filter (Sawyer Squeeze)
      `, - content_plain: "Complete packing checklist organized by category: footwear, clothing, gear, electronics, documents, and food.", - content_format: 'html', - type: "NOTE", tags: ["packing", "gear", "checklist"], is_pinned: true, - created_at: new Date(now - 6 * day).toISOString(), updated_at: new Date(now - hour).toISOString(), - }, - { - id: "demo-note-8", title: "Food & Cooking Plan", - content: `

      Food & Cooking Plan

      Hut Meals (Half-Board)

      Lac Blanc and Locatelli include dinner + breakfast. Budget EUR 0 for those nights.

      Self-Catering Days

      We have a kitchen in the Chamonix Airbnb and Zermatt apartment.

      Trail Lunches

      Pack these the night before each hike:

      • Sandwiches (baguette + cheese + ham)
      • Energy bars (2 per person)
      • Nuts and dried fruit
      • Chocolate (the altitude calls for it)
      • 1.5L water minimum
      `, - content_plain: "Food and cooking plan covering hut meals, self-catering, trail lunches, special restaurant meals, and dietary notes.", - content_format: 'html', - type: "NOTE", tags: ["food", "planning", "budget"], is_pinned: false, - created_at: new Date(now - 4 * day).toISOString(), updated_at: new Date(now - 8 * hour).toISOString(), - }, - { - id: "demo-note-9", title: "Transport & Logistics", - content: `

      Transport & Logistics

      Getting There

      • Jul 6: Fly to Geneva (everyone arrives by 14:00)
      • Geneva to Chamonix shuttle: EUR 186 for 4 pax

      Between Destinations

      • Jul 10: Chamonix to Zermatt -- Train via Martigny (~3.5h, scenic)
      • Jul 14: Zermatt to Dolomites -- Bernina Express (6 hours but spectacular)

      Local Transport

      • Chamonix: Free local bus with guest card
      • Zermatt: Car-free! Electric taxis + Gornergrat railway
      • Dolomites: Need rental car or local bus (limited schedule)
      `, - content_plain: "Transport and logistics plan covering flights, inter-city transfers, local transport options, return journey, and timetables.", - content_format: 'html', - type: "NOTE", tags: ["transport", "logistics"], is_pinned: false, - created_at: new Date(now - 9 * day).toISOString(), updated_at: new Date(now - 5 * hour).toISOString(), - }, - ]; - - const itineraryNotes: Note[] = [ - { - id: "demo-note-10", title: "Full Itinerary -- Alpine Explorer 2026", - content: `

      Full Itinerary -- Alpine Explorer 2026

      Jul 6-20 | France, Switzerland, Italy

      Week 1: Chamonix, France (Jul 6-10)

      • Jul 6: Fly Geneva, shuttle to Chamonix
      • Jul 7: Acclimatization hike -- Lac Blanc
      • Jul 8: Via Ferrata -- Aiguille du Midi
      • Jul 9: Rest day / Chamonix town
      • Jul 10: Train to Zermatt

      Week 2: Zermatt, Switzerland (Jul 10-14)

      • Jul 10: Arrive Zermatt, settle in
      • Jul 11: Gornergrat sunrise hike
      • Jul 12: Matterhorn base camp trek
      • Jul 13: Paragliding over Zermatt
      • Jul 14: Transfer to Dolomites

      Week 3: Dolomites, Italy (Jul 14-20)

      • Jul 14: Arrive Val Gardena
      • Jul 15: Tre Cime di Lavaredo loop
      • Jul 16: Lago di Braies kayaking
      • Jul 17: Seceda ridgeline hike
      • Jul 18: Cooking class in Bolzano
      • Jul 19: Free day -- shopping & packing
      • Jul 20: Fly home from Innsbruck
      `, - content_plain: "Complete day-by-day itinerary for the Alpine Explorer 2026 trip covering three weeks across Chamonix, Zermatt, and the Dolomites.", - content_format: 'html', - type: "NOTE", tags: ["itinerary", "planning"], is_pinned: true, - created_at: new Date(now - 15 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(), - }, - { - id: "demo-note-11", title: "Mountain Hut Reservations", - content: `

      Mountain Hut Reservations

      Confirmed

      • Refuge du Lac Blanc (Jul 7) -- 4 beds, half-board, conf #LB2026-234
      • Rifugio Locatelli (Jul 15) -- 4 beds, half-board, conf #TRE2026-089

      Waitlisted

      • Hornlihutte (Matterhorn base, Jul 12) -- will know by Jul 1

      Hut Etiquette Reminders

      1. Arrive before 17:00 if possible
      2. Remove boots at entrance (bring hut shoes or thick socks)
      3. Lights out by 22:00
      4. Pack out all trash
      5. Tip is appreciated but not required
      `, - content_plain: "Mountain hut reservations with confirmation numbers, check-in details, and hut etiquette reminders.", - content_format: 'html', - type: "NOTE", tags: ["accommodation", "hiking"], is_pinned: false, - created_at: new Date(now - 11 * day).toISOString(), updated_at: new Date(now - day).toISOString(), - }, - { - id: "demo-note-12", title: "Group Decisions & Votes", - content: `

      Group Decisions & Votes

      Decided

      • Camera Gear: DJI Mini 4 Pro (Liam's decision matrix: 8.5/10)
      • First Night Dinner in Zermatt: Fondue at Chez Vrony (won 5-4 over pizza)
      • Day 5 Activity: Via Ferrata at Aiguille du Midi (won 7-3 over kayaking)

      Active Votes (in rVote)

      • Zermatt to Dolomites transfer: Train vs rental car -- Train leading 3-2

      Pending Decisions

      • Val Gardena accommodation (agriturismo vs apartment)
      • Whether to rent the Starlink Mini (EUR 200, needs funding)
      • Trip zine print run size (50 vs 100 copies)
      `, - content_plain: "Summary of group decisions made and active votes. Covers camera gear, dining, activities, and pending decisions.", - content_format: 'html', - type: "NOTE", tags: ["decisions", "planning"], is_pinned: false, - created_at: new Date(now - 3 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(), - }, - ]; - - this.demoNotebooks = [ - { - id: "demo-nb-1", title: "Alpine Explorer Planning", description: "Shared knowledge base for our July 2026 trip across France, Switzerland, and Italy", - cover_color: "#f59e0b", note_count: "8", updated_at: new Date(now - hour).toISOString(), - notes: tripPlanningNotes, - } as any, - { - id: "demo-nb-2", title: "Packing & Logistics", description: "Checklists, food plans, and transport details", - cover_color: "#22c55e", note_count: "3", updated_at: new Date(now - hour).toISOString(), - notes: packingNotes, - } as any, - { - id: "demo-nb-3", title: "Itinerary & Decisions", description: "Day-by-day schedule, hut reservations, and group votes", - cover_color: "#6366f1", note_count: "3", updated_at: new Date(now - 2 * hour).toISOString(), - notes: itineraryNotes, - } as any, - ]; - - this.notebooks = this.demoNotebooks.map(({ notes, ...nb }) => nb as Notebook); - // Populate sidebar note cache and expand first notebook - for (const nb of this.demoNotebooks) { - this.notebookNotes.set(nb.id, nb.notes); - } - if (this.demoNotebooks.length > 0) { - this.expandedNotebooks.add(this.demoNotebooks[0].id); - } - this.loading = false; - this.render(); - } - - private demoSearchNotes(query: string) { - if (!query.trim()) { - this.searchResults = []; - this.renderNav(); - return; - } - const q = query.toLowerCase(); - const results: Note[] = []; - for (const nb of this.demoNotebooks) { - for (const n of nb.notes) { - if (n.title.toLowerCase().includes(q) || - n.content_plain.toLowerCase().includes(q) || - (n.tags && n.tags.some(t => t.toLowerCase().includes(q)))) { - results.push(Object.assign({}, n, { notebook_id: nb.id }) as any); - } - } - } - this.searchResults = results; - this.renderNav(); - } - - private demoLoadNote(id: string) { - // Find the note and its parent notebook - for (const nb of this.demoNotebooks) { - const note = nb.notes.find(n => n.id === id); - if (note) { - this.selectedNote = note; - this.selectedNotebook = { ...nb }; - if (!this.expandedNotebooks.has(nb.id)) { - this.expandedNotebooks.add(nb.id); - } - this.renderNav(); - this.renderMeta(); - this.mountEditor(this.selectedNote); - return; - } - } - } - - private demoCreateNotebook() { - const now = Date.now(); - const nbId = `demo-nb-${now}`; - const noteId = `demo-note-${now}`; - const newNote: Note = { - id: noteId, title: "Untitled Note", content: "", content_plain: "", - content_format: 'tiptap-json', - type: "NOTE", tags: null, is_pinned: false, sort_order: 0, - created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), - }; - const nb = { - id: nbId, title: "Untitled Notebook", description: "", - cover_color: "#8b5cf6", note_count: "1", - updated_at: new Date(now).toISOString(), notes: [newNote], - } as any; - this.demoNotebooks.push(nb); - this.notebooks = this.demoNotebooks.map(({ notes, ...rest }) => rest as Notebook); - this.notebookNotes.set(nbId, [newNote]); - this.expandedNotebooks.add(nbId); - this.selectedNotebook = { ...nb }; - // Auto-open the note for editing - this.selectedNote = newNote; - this.renderNav(); - this.renderMeta(); - this.mountEditor(newNote); - } - - private demoCreateNote(opts: CreateNoteOpts = {}) { - if (!this.selectedNotebook) return; - const now = Date.now(); - const noteId = `demo-note-${now}`; - const type = opts.type || 'NOTE'; - const title = opts.title || FolkNotesApp.typeDefaultTitle(type); - const newNote: Note = { - id: noteId, title, content: opts.content || "", content_plain: "", - content_format: type === 'CODE' ? 'html' : 'tiptap-json', - type, tags: opts.tags || null, is_pinned: false, sort_order: 0, - url: opts.url || null, language: opts.language || null, - fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null, - duration: opts.duration ?? null, - created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), - }; - const nbId = this.selectedNotebook.id; - const demoNb = this.demoNotebooks.find(n => n.id === nbId); - if (demoNb) { - demoNb.notes.push(newNote); - demoNb.note_count = String(demoNb.notes.length); - } - this.selectedNotebook.notes.push(newNote); - this.selectedNotebook.note_count = String(this.selectedNotebook.notes.length); - // Update sidebar cache - const cached = this.notebookNotes.get(nbId) || []; - cached.unshift(newNote); - this.notebookNotes.set(nbId, cached); - const nbIdx = this.notebooks.findIndex(n => n.id === nbId); - if (nbIdx >= 0) this.notebooks[nbIdx].note_count = String(cached.length); - this.selectedNote = newNote; - this.renderNav(); - this.renderMeta(); - this.mountEditor(newNote); - } - - // ── Mobile stack navigation ── - - private setMobileEditing(editing: boolean) { - this.mobileEditing = editing; - this.shadow.getElementById('notes-layout')?.classList.toggle('mobile-editing', editing); - } - - private mobileGoBack() { - this.setMobileEditing(false); - } - - private mobileBackBarHtml(): string { - const title = this.selectedNotebook?.title || 'Notes'; - return `
      `; - } - - private isMobile(): boolean { - return window.innerWidth <= 768; - } - - disconnectedCallback() { - this.destroyEditor(); - this.cleanupPresence(); - if (this._resizeHandler) { - window.removeEventListener('resize', this._resizeHandler); - this._resizeHandler = null; - } - this._offlineUnsub?.(); - this._offlineUnsub = null; - for (const unsub of this._offlineNotebookUnsubs) unsub(); - this._offlineNotebookUnsubs = []; - } - - // ── Sync (via shared runtime) ── - - private async subscribeNotebook(notebookId: string) { - const runtime = (window as any).__rspaceOfflineRuntime; - // Resolve scope: rnotes is globally-scoped, so use 'global' prefix - const dataSpace = runtime?.isInitialized - ? (runtime.resolveDocSpace?.('rnotes') || this.space) - : this.space; - this.subscribedDocId = `${dataSpace}:notes:notebooks:${notebookId}`; - - if (runtime?.isInitialized) { - try { - const docId = this.subscribedDocId as DocumentId; - const doc = await runtime.subscribe(docId, notebookSchema); - this.doc = doc; - this.renderFromDoc(); - - const unsub = runtime.onChange(docId, (updated: any) => { - this.doc = updated; - this.renderFromDoc(); - }); - this._offlineNotebookUnsubs.push(unsub); - } catch { - // Fallback: initialize empty doc for fresh notebook - this.doc = Automerge.init(); - } - } else { - // No runtime — initialize empty doc - this.doc = Automerge.init(); - } - } - - private unsubscribeNotebook() { - if (this.subscribedDocId) { - const runtime = (window as any).__rspaceOfflineRuntime; - if (runtime?.isInitialized) { - runtime.unsubscribe(this.subscribedDocId as DocumentId); - } - } - this.subscribedDocId = null; - this.doc = null; - for (const unsub of this._offlineNotebookUnsubs) unsub(); - this._offlineNotebookUnsubs = []; - } - - /** Extract notebook + notes from Automerge doc into component state */ - private renderFromDoc() { - if (!this.doc) return; - - const nb = this.doc.notebook; - const items = this.doc.items; - - if (!nb) return; - - // Build notebook data from doc - const notes: Note[] = []; - if (items) { - for (const [, item] of Object.entries(items)) { - notes.push({ - id: item.id, - title: item.title || "Untitled", - content: item.content || "", - content_plain: item.contentPlain || "", - content_format: (item.contentFormat as Note['content_format']) || undefined, - type: item.type || "NOTE", - tags: item.tags?.length ? Array.from(item.tags) : null, - is_pinned: item.isPinned || false, - url: item.url || null, - language: item.language || null, - fileUrl: item.fileUrl || null, - mimeType: item.mimeType || null, - duration: item.duration ?? null, - sort_order: item.sortOrder || 0, - created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), - updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), - }); - } - } - - this.sortNotes(notes); - - this.selectedNotebook = { - id: nb.id, - title: nb.title, - description: nb.description || "", - cover_color: nb.coverColor || "#3b82f6", - note_count: String(notes.length), - updated_at: nb.updatedAt ? new Date(nb.updatedAt).toISOString() : new Date().toISOString(), - notes, - }; - - // Update sidebar note cache - this.notebookNotes.set(nb.id, notes); - const nbIdx = this.notebooks.findIndex(n => n.id === nb.id); - if (nbIdx >= 0) this.notebooks[nbIdx].note_count = String(notes.length); - - // If editor is mounted for a note, update editor content from remote - if (this.selectedNote && this.editor && this.editorNoteId === this.selectedNote.id) { - const noteItem = items?.[this.selectedNote.id]; - if (noteItem) { - this.selectedNote = { - id: noteItem.id, - title: noteItem.title || "Untitled", - content: noteItem.content || "", - content_plain: noteItem.contentPlain || "", - content_format: (noteItem.contentFormat as Note['content_format']) || undefined, - type: noteItem.type || "NOTE", - tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null, - is_pinned: noteItem.isPinned || false, - url: noteItem.url || null, - language: noteItem.language || null, - fileUrl: noteItem.fileUrl || null, - mimeType: noteItem.mimeType || null, - duration: noteItem.duration ?? null, - sort_order: noteItem.sortOrder || 0, - created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(), - updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(), - }; - - // Skip content replacement when Yjs is active — Yjs handles content sync - if (!this.ydoc) { - // Legacy mode: update editor content if different (remote change) - const remoteContent = noteItem.content || ""; - const currentContent = noteItem.contentFormat === 'tiptap-json' - ? JSON.stringify(this.editor.getJSON()) - : this.editor.getHTML(); - - if (remoteContent !== currentContent) { - this.isRemoteUpdate = true; - try { - if (noteItem.contentFormat === 'tiptap-json') { - try { - this.editor.commands.setContent(JSON.parse(remoteContent), { emitUpdate: false }); - } catch { - this.editor.commands.setContent(remoteContent, { emitUpdate: false }); - } - } else { - this.editor.commands.setContent(remoteContent, { emitUpdate: false }); - } - } finally { - this.isRemoteUpdate = false; - } - } - } - - // Update title input if it exists - const titleInput = this.shadow.querySelector('#note-title-input') as HTMLInputElement; - if (titleInput && document.activeElement !== titleInput && titleInput !== this.shadow.activeElement) { - titleInput.value = noteItem.title || "Untitled"; - } - - // Only update nav/meta, skip contentZone - this.renderNav(); - this.renderMeta(); - this.loading = false; - return; - } - } - - // If a note is selected but editor not mounted yet, update selectedNote - if (this.selectedNote) { - const noteItem = items?.[this.selectedNote.id]; - if (noteItem) { - this.selectedNote = { - id: noteItem.id, - title: noteItem.title || "Untitled", - content: noteItem.content || "", - content_plain: noteItem.contentPlain || "", - content_format: (noteItem.contentFormat as Note['content_format']) || undefined, - type: noteItem.type || "NOTE", - tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null, - is_pinned: noteItem.isPinned || false, - url: noteItem.url || null, - language: noteItem.language || null, - fileUrl: noteItem.fileUrl || null, - mimeType: noteItem.mimeType || null, - duration: noteItem.duration ?? null, - sort_order: noteItem.sortOrder || 0, - created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(), - updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(), - }; - } - } - - this.loading = false; - this.render(); - } - - // ── Automerge mutations ── - - private static typeDefaultTitle(type: NoteType): string { - switch (type) { - case 'CODE': return 'Untitled Code Snippet'; - case 'BOOKMARK': return 'Untitled Bookmark'; - case 'CLIP': return 'Untitled Clip'; - case 'IMAGE': return 'Untitled Image'; - case 'AUDIO': return 'Voice Note'; - case 'FILE': return 'Untitled File'; - default: return 'Untitled Note'; - } - } - - private createNoteViaSync(opts: CreateNoteOpts = {}) { - if (!this.doc || !this.selectedNotebook || !this.subscribedDocId) return; - - const noteId = crypto.randomUUID(); - const now = Date.now(); - const notebookId = this.selectedNotebook.id; - const type = opts.type || 'NOTE'; - const title = opts.title || FolkNotesApp.typeDefaultTitle(type); - const contentFormat = type === 'CODE' ? 'html' : 'tiptap-json'; - - const itemData: any = { - id: noteId, notebookId, title, - content: opts.content || "", contentPlain: "", contentFormat, - type, tags: opts.tags || [], isPinned: false, sortOrder: 0, - createdAt: now, updatedAt: now, - url: opts.url || null, language: opts.language || null, - fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null, - duration: opts.duration ?? null, - }; - - const runtime = (window as any).__rspaceOfflineRuntime; - if (runtime?.isInitialized) { - runtime.change(this.subscribedDocId as DocumentId, "Create note", (d: NotebookDoc) => { - if (!d.items) (d as any).items = {}; - d.items[noteId] = itemData; - }); - this.doc = runtime.get(this.subscribedDocId as DocumentId); - } else { - this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => { - if (!d.items) (d as any).items = {}; - d.items[noteId] = itemData; - }); - } - - this.renderFromDoc(); - - // Open the new note - this.selectedNote = { - id: noteId, title, content: opts.content || "", content_plain: "", - content_format: contentFormat, - type, tags: opts.tags || null, is_pinned: false, sort_order: 0, - url: opts.url || null, language: opts.language || null, - fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null, - duration: opts.duration ?? null, - created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), - }; - this.renderNav(); - this.renderMeta(); - this.mountEditor(this.selectedNote); - } - - private updateNoteField(noteId: string, field: string, value: string) { - if (!this.doc || !this.doc.items?.[noteId] || !this.subscribedDocId) return; - - const runtime = (window as any).__rspaceOfflineRuntime; - if (runtime?.isInitialized) { - runtime.change(this.subscribedDocId as DocumentId, `Update ${field}`, (d: NotebookDoc) => { - (d.items[noteId] as any)[field] = value; - d.items[noteId].updatedAt = Date.now(); - }); - this.doc = runtime.get(this.subscribedDocId as DocumentId); - } else { - this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => { - (d.items[noteId] as any)[field] = value; - d.items[noteId].updatedAt = Date.now(); - }); - } - } - - /** Sort notes: pinned first, then by sort_order (if any are set), then by updated_at desc. */ - private sortNotes(notes: Note[]) { - const hasSortOrder = notes.some(n => (n.sort_order || 0) > 0); - notes.sort((a, b) => { - if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1; - if (hasSortOrder) { - const sa = a.sort_order || 0; - const sb = b.sort_order || 0; - if (sa !== sb) return sa - sb; - } - return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); - }); - } - - /** Reorder a note within a notebook's sidebar list. */ - private reorderNote(noteId: string, notebookId: string, targetIndex: number) { - const notes = this.notebookNotes.get(notebookId); - if (!notes) return; - - const srcIdx = notes.findIndex(n => n.id === noteId); - if (srcIdx < 0 || srcIdx === targetIndex) return; - - // Move in the local array - const [note] = notes.splice(srcIdx, 1); - notes.splice(targetIndex, 0, note); - - // Assign sort_order based on new positions - notes.forEach((n, i) => { n.sort_order = i + 1; }); - this.notebookNotes.set(notebookId, notes); - - // Persist to Automerge - const runtime = (window as any).__rspaceOfflineRuntime; - const dataSpace = runtime?.isInitialized ? (runtime.resolveDocSpace?.('rnotes') || this.space) : this.space; - const docId = `${dataSpace}:notes:notebooks:${notebookId}` as DocumentId; - - if (runtime?.isInitialized) { - runtime.change(docId, `Reorder notes`, (d: NotebookDoc) => { - for (const n of notes) { - if (d.items[n.id]) d.items[n.id].sortOrder = n.sort_order!; - } - }); - this.doc = runtime.get(this.subscribedDocId as DocumentId); - } else if (this.doc && this.subscribedDocId === docId) { - this.doc = Automerge.change(this.doc, `Reorder notes`, (d: NotebookDoc) => { - for (const n of notes) { - if (d.items[n.id]) d.items[n.id].sortOrder = n.sort_order!; - } - }); - } - - // Also update selectedNotebook if it matches - if (this.selectedNotebook?.id === notebookId) { - this.selectedNotebook.notes = [...notes]; - } - - this.renderNav(); - } - - /** Move a note from one notebook to another via Automerge docs. */ - private async moveNoteToNotebook(noteId: string, sourceNotebookId: string, targetNotebookId: string) { - if (sourceNotebookId === targetNotebookId) return; - const runtime = (window as any).__rspaceOfflineRuntime; - if (!runtime?.isInitialized) return; - - const dataSpace = runtime.resolveDocSpace?.('rnotes') || this.space; - const sourceDocId = `${dataSpace}:notes:notebooks:${sourceNotebookId}` as DocumentId; - const targetDocId = `${dataSpace}:notes:notebooks:${targetNotebookId}` as DocumentId; - - // Get the note data from source - const sourceDoc = runtime.get(sourceDocId) as NotebookDoc | undefined; - if (!sourceDoc?.items?.[noteId]) return; - - // Deep-clone the note item (plain object from Automerge) - const noteItem = JSON.parse(JSON.stringify(sourceDoc.items[noteId])); - noteItem.notebookId = targetNotebookId; - noteItem.updatedAt = Date.now(); - - // Subscribe to target doc if needed, add the note, then unsubscribe - let targetDoc: NotebookDoc | undefined; - try { - targetDoc = await runtime.subscribe(targetDocId, notebookSchema); - } catch { - return; // target notebook not accessible - } - - // Add to target - runtime.change(targetDocId, `Move note ${noteId}`, (d: NotebookDoc) => { - if (!d.items) (d as any).items = {}; - d.items[noteId] = noteItem; - }); - - // Remove from source - runtime.change(sourceDocId, `Move note ${noteId} out`, (d: NotebookDoc) => { - delete d.items[noteId]; - }); - - // If we're viewing the source notebook, refresh - if (this.subscribedDocId === sourceDocId) { - this.doc = runtime.get(sourceDocId); - this.renderFromDoc(); - } - - // Update sidebar counts - const srcNb = this.notebooks.find(n => n.id === sourceNotebookId); - const tgtNb = this.notebooks.find(n => n.id === targetNotebookId); - if (srcNb) srcNb.note_count = String(Math.max(0, parseInt(srcNb.note_count) - 1)); - if (tgtNb) tgtNb.note_count = String(parseInt(tgtNb.note_count) + 1); - - // Refresh sidebar note cache for source - const srcNotes = this.notebookNotes.get(sourceNotebookId); - if (srcNotes) this.notebookNotes.set(sourceNotebookId, srcNotes.filter(n => n.id !== noteId)); - - // If target is expanded, refresh its notes - if (this.expandedNotebooks.has(targetNotebookId)) { - const tgtDoc = runtime.get(targetDocId) as NotebookDoc | undefined; - if (tgtDoc?.items) { - const notes: Note[] = Object.values(tgtDoc.items).map((item: any) => ({ - id: item.id, title: item.title || 'Untitled', content: item.content || '', - content_plain: item.contentPlain || '', type: item.type || 'NOTE', - tags: item.tags?.length ? Array.from(item.tags) : null, - is_pinned: item.isPinned || false, url: item.url || null, - language: item.language || null, fileUrl: item.fileUrl || null, - mimeType: item.mimeType || null, duration: item.duration ?? null, - sort_order: item.sortOrder || 0, - created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), - updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), - })); - this.notebookNotes.set(targetNotebookId, notes); - } - } - - // Unsubscribe from target if it's not the active notebook - if (this.subscribedDocId !== targetDocId) { - runtime.unsubscribe(targetDocId); - } - - // Close editor if we were editing the moved note - if (this.selectedNote?.id === noteId) { - this.selectedNote = null; - this.renderContent(); - } - - this.renderNav(); - } - - // ── Note summarization ── - - private async summarizeNote(btn: HTMLElement) { - const noteId = this.editorNoteId || this.selectedNote?.id; - if (!noteId) return; - - // Get note content (plain text) - const item = this.doc?.items?.[noteId]; - const content = item?.contentPlain || item?.content || this.selectedNote?.content_plain || ''; - if (!content?.trim()) return; - - // Show loading state on button - btn.classList.add('active'); - btn.style.pointerEvents = 'none'; - - try { - const base = this.getApiBase(); - const res = await fetch(`${base}/api/notes/summarize`, { - method: 'POST', - headers: this.authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ content, model: 'gemini-flash', length: 'medium' }), - }); - - if (!res.ok) { - const err = await res.json().catch(() => ({})); - console.error('[Notes] Summarize error:', err); - return; - } - - const data = await res.json() as { summary: string; model: string }; - - // Save to Automerge doc - if (this.space !== 'demo') { - this.updateNoteField(noteId, 'summary', data.summary); - this.updateNoteField(noteId, 'summaryModel', data.model); - } - - // Update local selected note for immediate display - if (this.selectedNote && this.selectedNote.id === noteId) { - (this.selectedNote as any).summary = data.summary; - (this.selectedNote as any).summaryModel = data.model; - } - - this.renderMeta(); - } catch (err) { - console.error('[Notes] Summarize failed:', err); - } finally { - btn.classList.remove('active'); - btn.style.pointerEvents = ''; - } - } - - private async sendToOpenNotebook() { - const noteId = this.editorNoteId || this.selectedNote?.id; - if (!noteId) return; - - const item = this.doc?.items?.[noteId]; - const content = item?.contentPlain || item?.content || this.selectedNote?.content_plain || ''; - const title = item?.title || this.selectedNote?.title || 'Untitled'; - if (!content?.trim()) return; - - // Disable button during request - const btn = this.metaZone.querySelector('[data-action="send-to-notebook"]') as HTMLButtonElement; - if (btn) { btn.disabled = true; btn.textContent = 'Sending...'; } - - try { - const base = this.getApiBase(); - const res = await fetch(`${base}/api/notes/send-to-notebook`, { - method: 'POST', - headers: this.authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ noteId, title, content }), - }); - - if (!res.ok) { - console.error('[Notes] Send to notebook error:', res.status); - if (btn) { btn.disabled = false; btn.textContent = 'Send to Notebook'; } - return; - } - - const data = await res.json() as { sourceId: string }; - this.updateNoteField(noteId, 'openNotebookSourceId', data.sourceId); - - if (this.selectedNote && this.selectedNote.id === noteId) { - (this.selectedNote as any).openNotebookSourceId = data.sourceId; - } - - this.renderMeta(); - } catch (err) { - console.error('[Notes] Send to notebook failed:', err); - if (btn) { btn.disabled = false; btn.textContent = 'Send to Notebook'; } - } - } - - // ── REST (notebook list + search) ── - - private getApiBase(): string { + private _getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rnotes/); - return match ? match[0] : ""; + return match ? match[0] : ''; } - private authHeaders(extra?: Record): Record { - const headers: Record = { ...extra }; - const token = getAccessToken(); - if (token) headers["Authorization"] = `Bearer ${token}`; - return headers; - } + // ── Data fetching ───────────────────────────────────────────────────────── - private async loadNotebooks() { - this.loading = true; - this.render(); + private async _fetchVaults() { + if (!this._space) return; + this._loading = true; this._render(); try { - const base = this.getApiBase(); - const res = await fetch(`${base}/api/notebooks`, { headers: this.authHeaders() }); - const data = await res.json(); - this.notebooks = data.notebooks || []; - } catch { - this.error = "Failed to load notebooks"; - } - this.loading = false; - this.render(); - } - - private async loadNotebook(id: string) { - this.unsubscribeNotebook(); - await this.subscribeNotebook(id); - this.broadcastPresence(); - - // REST fallback if Automerge doc is empty after 5s - setTimeout(() => { - if (this.subscribedDocId && (!this.doc?.items || Object.keys(this.doc.items).length === 0)) { - this.loadNotebookREST(id); + const base = this._getApiBase(); + const res = await fetch(`${base}/api/vault/list`); + if (res.ok) { + const data = await res.json() as { vaults: VaultMeta[] }; + this._vaults = data.vaults ?? []; } - }, 5000); + } catch { /* ignore */ } + this._loading = false; this._render(); } - private async loadNotebookREST(id: string) { + private async _selectVault(id: string) { + this._selectedVaultId = id; + this._selectedNotePath = null; + this._noteContent = ''; + this._notes = []; + this._render(); try { - const base = this.getApiBase(); - const res = await fetch(`${base}/api/notebooks/${id}`, { headers: this.authHeaders() }); - const data = await res.json(); - this.selectedNotebook = data; - if (data?.notes) { - this.sortNotes(data.notes); - this.notebookNotes.set(id, data.notes); + const base = this._getApiBase(); + const res = await fetch(`${base}/api/vault/${id}/notes`); + if (res.ok) { + const data = await res.json() as { notes: VaultNoteMeta[] }; + this._notes = data.notes ?? []; } - } catch { - this.error = "Failed to load notebook"; - } - this.loading = false; - this.render(); + } catch { /* ignore */ } + this._render(); } - /** Fetch notes for a notebook (sidebar display). */ - private async fetchNotebookNotes(id: string) { + private async _selectNote(path: string) { + if (!this._selectedVaultId) return; + this._selectedNotePath = path; + this._noteContent = ''; + this._render(); try { - const base = this.getApiBase(); - const res = await fetch(`${base}/api/notebooks/${id}`, { headers: this.authHeaders() }); - const data = await res.json(); - if (data?.notes) { - this.sortNotes(data.notes); - this.notebookNotes.set(id, data.notes); - const nbIdx = this.notebooks.findIndex(n => n.id === id); - if (nbIdx >= 0) this.notebooks[nbIdx].note_count = String(data.notes.length); - } - } catch { - // Silently fail — sidebar stays empty for this notebook - } - this.renderNav(); - } - - /** Toggle notebook expansion in sidebar. */ - private expandNotebook(id: string) { - if (this.expandedNotebooks.has(id)) { - this.expandedNotebooks.delete(id); - this.renderNav(); - return; - } - this.expandedNotebooks.add(id); - if (!this.notebookNotes.has(id)) { - if (this.space === "demo") { - const nb = this.demoNotebooks.find(n => n.id === id); - if (nb) this.notebookNotes.set(id, nb.notes); - this.renderNav(); - } else { - this.fetchNotebookNotes(id); - } - } else { - this.renderNav(); - } - } - - /** Open a note for editing from the sidebar. */ - private async openNote(noteId: string, notebookId: string) { - const isDemo = this.space === "demo"; - - // Mobile: slide to editor screen - if (this.isMobile()) { - this.setMobileEditing(true); - } - - // Expand notebook if not expanded - if (!this.expandedNotebooks.has(notebookId)) { - this.expandedNotebooks.add(notebookId); - } - - if (isDemo) { - this.demoLoadNote(noteId); - return; - } - - // Set selected notebook - const nb = this.notebooks.find(n => n.id === notebookId); - if (nb) { - this.selectedNotebook = { ...nb, notes: this.notebookNotes.get(notebookId) || [] }; - } - - // Subscribe to Automerge if needed - const needSubscribe = !this.subscribedDocId || !this.subscribedDocId.endsWith(`:${notebookId}`); - if (needSubscribe) { - await this.loadNotebook(notebookId); - } - - this.loadNote(noteId); - } - - /** Add a new note to a notebook via the sidebar '+' button. */ - private async addNoteToNotebook(notebookId: string) { - const isDemo = this.space === "demo"; - - // Ensure expanded - if (!this.expandedNotebooks.has(notebookId)) { - this.expandedNotebooks.add(notebookId); - } - - // Set selected notebook - const nb = this.notebooks.find(n => n.id === notebookId); - if (!nb) return; - this.selectedNotebook = { ...nb, notes: this.notebookNotes.get(notebookId) || [] }; - - if (isDemo) { - this.demoCreateNote(); - return; - } - - // Ensure subscribed - const needSubscribe = !this.subscribedDocId || !this.subscribedDocId.endsWith(`:${notebookId}`); - if (needSubscribe) { - await this.loadNotebook(notebookId); - } - this.createNoteViaSync(); - } - - /** Show a small dropdown menu near the "+" button with note creation options. */ - private showAddNoteMenu(nbId: string, anchorEl: HTMLElement) { - // Remove any existing menu - this.shadow.querySelector('.add-note-menu')?.remove(); - - const menu = document.createElement('div'); - menu.className = 'add-note-menu'; - - // Position near the anchor - const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect(); - const anchorRect = anchorEl.getBoundingClientRect(); - menu.style.left = `${anchorRect.left - hostRect.left}px`; - menu.style.top = `${anchorRect.bottom - hostRect.top + 4}px`; - - menu.innerHTML = ` - - - - `; - - this.shadow.appendChild(menu); - - const close = () => menu.remove(); - - menu.querySelectorAll('.add-note-menu-item').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const action = (btn as HTMLElement).dataset.action; - close(); - if (action === 'note') this.addNoteToNotebook(nbId); - else if (action === 'url') this.createNoteFromUrl(nbId); - else if (action === 'upload') this.createNoteFromFile(nbId); - }); - }); - - // Close on outside click - const onOutside = (e: Event) => { - if (!menu.contains(e.target as Node)) { - close(); - this.shadow.removeEventListener('click', onOutside); - } - }; - requestAnimationFrame(() => this.shadow.addEventListener('click', onOutside)); - } - - /** Prompt for a URL and create a BOOKMARK note. */ - private async createNoteFromUrl(nbId: string) { - const url = prompt('Enter URL:'); - if (!url) return; - - let title: string; - try { title = new URL(url).hostname; } catch { title = url; } - - // Ensure notebook is selected and subscribed - const nb = this.notebooks.find(n => n.id === nbId); - if (!nb) return; - this.selectedNotebook = { ...nb, notes: this.notebookNotes.get(nbId) || [] }; - if (!this.expandedNotebooks.has(nbId)) this.expandedNotebooks.add(nbId); - - if (this.space === 'demo') { - this.demoCreateNote(); - return; - } - - const needSubscribe = !this.subscribedDocId || !this.subscribedDocId.endsWith(`:${nbId}`); - if (needSubscribe) await this.loadNotebook(nbId); - - this.createNoteViaSync({ type: 'BOOKMARK', url, title }); - } - - /** Open a file picker, upload the file, and create a FILE or IMAGE note. */ - private createNoteFromFile(nbId: string) { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = 'image/*,audio/*,video/*,.pdf,.doc,.docx,.txt,.md,.csv,.json'; - - input.addEventListener('change', async () => { - const file = input.files?.[0]; - if (!file) return; - - // Upload the file - const base = this.getApiBase(); - const fd = new FormData(); - fd.append('file', file, file.name); - - try { - const uploadRes = await fetch(`${base}/api/uploads`, { - method: 'POST', headers: this.authHeaders(), body: fd, - }); - if (!uploadRes.ok) throw new Error('Upload failed'); - const uploadData = await uploadRes.json(); - const fileUrl = uploadData.url || uploadData.path; - - // Determine note type - const mime = file.type || ''; - const type: NoteType = mime.startsWith('image/') ? 'IMAGE' - : mime.startsWith('audio/') ? 'AUDIO' : 'FILE'; - - // Ensure notebook ready - const nb = this.notebooks.find(n => n.id === nbId); - if (!nb) return; - this.selectedNotebook = { ...nb, notes: this.notebookNotes.get(nbId) || [] }; - if (!this.expandedNotebooks.has(nbId)) this.expandedNotebooks.add(nbId); - - if (this.space === 'demo') { - this.demoCreateNote(); - return; - } - - const needSubscribe = !this.subscribedDocId || !this.subscribedDocId.endsWith(`:${nbId}`); - if (needSubscribe) await this.loadNotebook(nbId); - - this.createNoteViaSync({ type, fileUrl, mimeType: mime, title: file.name }); - } catch (err) { - console.error('File upload failed:', err); - alert('Failed to upload file. Please try again.'); - } - }); - - input.click(); - } - - private loadNote(id: string) { - // Note is already in the Automerge doc - if (this.doc?.items?.[id]) { - const item = this.doc.items[id]; - this.selectedNote = { - id: item.id, - title: item.title || "Untitled", - content: item.content || "", - content_plain: item.contentPlain || "", - content_format: (item.contentFormat as Note['content_format']) || undefined, - type: item.type || "NOTE", - tags: item.tags?.length ? Array.from(item.tags) : null, - is_pinned: item.isPinned || false, - url: item.url || null, - language: item.language || null, - fileUrl: item.fileUrl || null, - mimeType: item.mimeType || null, - duration: item.duration ?? null, - sort_order: item.sortOrder || 0, - created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), - updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), - }; - } else if (this.selectedNotebook?.notes) { - this.selectedNote = this.selectedNotebook.notes.find(n => n.id === id) || null; - } - - // Fallback: try sidebar note cache - if (!this.selectedNote) { - for (const [, notes] of this.notebookNotes) { - const found = notes.find(n => n.id === id); - if (found) { this.selectedNote = found; break; } - } - } - - if (this.selectedNote) { - this.renderNav(); - this.renderMeta(); - this.mountEditor(this.selectedNote); - this.broadcastPresence(); - } - } - - private async searchNotes(query: string) { - if (!query.trim()) { - this.searchResults = []; - this.renderNav(); - return; - } - try { - const base = this.getApiBase(); - const res = await fetch(`${base}/api/notes?q=${encodeURIComponent(query)}`, { headers: this.authHeaders() }); - const data = await res.json(); - this.searchResults = data.notes || []; - } catch { - this.searchResults = []; - } - this.renderNav(); - } - - private async createNotebook() { - try { - const base = this.getApiBase(); - const res = await fetch(`${base}/api/notebooks`, { - method: "POST", - headers: this.authHeaders({ "Content-Type": "application/json" }), - body: JSON.stringify({ title: "Untitled Notebook" }), - }); - const nb = await res.json(); - if (nb?.id) { - await this.loadNotebooks(); // Refresh list - this.notebookNotes.set(nb.id, []); - this.expandedNotebooks.add(nb.id); - this.render(); - } else { - await this.loadNotebooks(); - } - } catch { - this.error = "Failed to create notebook"; - this.render(); - } - } - - // ── Tiptap Editor ── - - private mountEditor(note: Note) { - this.destroyEditor(); - this.editorNoteId = note.id; - - const isDemo = this.space === "demo"; - const isAutomerge = !!(this.doc?.items?.[note.id]); - const isEditable = isAutomerge || isDemo; - - // Branch on note type - switch (note.type) { - case 'CODE': this.mountCodeEditor(note, isEditable, isDemo); break; - case 'BOOKMARK': - case 'CLIP': this.mountBookmarkView(note, isEditable, isDemo); break; - case 'IMAGE': this.mountImageView(note, isEditable, isDemo); break; - case 'AUDIO': this.mountAudioView(note, isEditable, isDemo); break; - default: this.mountTiptapEditor(note, isEditable, isDemo); break; - } - - // Mobile: inject back bar and slide to editor - this.contentZone.insertAdjacentHTML('afterbegin', this.mobileBackBarHtml()); - this.contentZone.querySelector('.mobile-back-btn')?.addEventListener('click', () => this.mobileGoBack()); - if (this.isMobile()) this.setMobileEditing(true); - } - - private mountTiptapEditor(note: Note, isEditable: boolean, isDemo: boolean) { - const useYjs = !isDemo && isEditable; - - this.contentZone.innerHTML = ` -
      -
      - - ${isEditable ? this.renderToolbar() : ''} - -
      -
      -
      -
      - `; - - const container = this.shadow.getElementById('tiptap-container'); - if (!container) return; - - if (useYjs) { - this.mountTiptapWithYjs(note, container); - } else { - this.mountTiptapLegacy(note, isEditable, isDemo, container); - } - - this.editor!.registerPlugin(createSlashCommandPlugin(this.editor!, this.shadow)); - - container.addEventListener('slash-insert-image', () => { - if (!this.editor) return; - const { from } = this.editor.view.state.selection; - const coords = this.editor.view.coordsAtPos(from); - const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top); - this.showUrlPopover(rect, 'Enter image URL...').then(url => { - if (url) this.editor!.chain().focus().setImage({ src: url }).run(); - }); - }); - - container.addEventListener('slash-create-typed-note', ((e: CustomEvent) => { - const { type } = e.detail || {}; - if (type && this.selectedNotebook) { - this.createNoteViaSync({ type }); - } - }) as EventListener); - - this.wireTitleInput(note, isEditable, isDemo); - this.attachToolbarListeners(); - this.wireCommentHighlightClicks(); - } - - /** Mount TipTap with Yjs collaboration (real-time co-editing). */ - private mountTiptapWithYjs(note: Note, container: HTMLElement) { - const runtime = (window as any).__rspaceOfflineRuntime; - const spaceSlug = runtime?.space || this.space; - const roomName = `rnotes:${spaceSlug}:${note.id}`; - - // Create Y.Doc - this.ydoc = new Y.Doc(); - const fragment = this.ydoc.getXmlFragment('content'); - - // IndexedDB persistence for offline - this.yIndexedDb = new IndexeddbPersistence(roomName, this.ydoc); - - // Set awareness identity BEFORE connecting provider (avoids anonymous ghost) - const sessionForAwareness = this.getSessionInfo(); - - // Connect Yjs provider over rSpace WebSocket - if (runtime?.isInitialized) { - this.yjsProvider = new RSpaceYjsProvider(note.id, this.ydoc, runtime); - // Pre-set user so the first awareness broadcast has the correct name - this.yjsProvider.awareness.setLocalStateField('user', { - name: sessionForAwareness.username || 'Anonymous', - color: this.userColor(sessionForAwareness.userId || 'anon'), - }); - } - - // Content migration: if Y.Doc fragment is empty and Automerge has content - this.yIndexedDb.on('synced', () => { - if (fragment.length === 0 && note.content) { - // Migrate existing content into Yjs by creating a temp editor, setting content, then destroying - let content: any = ''; - if (note.content_format === 'tiptap-json') { - try { content = JSON.parse(note.content); } catch { content = note.content; } - } else { - content = note.content; - } - if (this.editor && content) { - this.editor.commands.setContent(content, { emitUpdate: false }); - } - // Mark as collab-enabled in Automerge - if (this.doc?.items?.[note.id]) { - this.updateNoteField(note.id, 'collabEnabled', 'true'); - } - } - }); - - // Create editor with Yjs sync/undo plugins registered directly - this.editor = new Editor({ - element: container, - editable: true, - extensions: [ - StarterKit.configure({ - codeBlock: false, - heading: { levels: [1, 2, 3, 4] }, - undoRedo: false, // Yjs has its own undo/redo - link: false, - underline: false, - }), - Link.configure({ openOnClick: false }), - Image, TaskList, TaskItem.configure({ nested: true }), - Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }), - CodeBlockLowlight.configure({ lowlight }), Typography, Underline, - Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }), - CommentMark, - SuggestionInsertMark, - SuggestionDeleteMark, - ], - onSelectionUpdate: () => { this.updateToolbarState(); }, - }); - - // Register Yjs sync and undo plugins - this.editor.registerPlugin(ySyncPlugin(fragment)); - this.editor.registerPlugin(yUndoPlugin()); - - // Register suggestion plugin for track-changes mode - const suggestionPlugin = createSuggestionPlugin( - () => this.suggestingMode, - () => { - const s = this.getSessionInfo(); - return { authorId: s.userId, authorName: s.username }; - }, - ); - this.editor.registerPlugin(suggestionPlugin); - - // Register cursor presence plugin - if (this.yjsProvider) { - const cursorPlugin = yCursorPlugin( - this.yjsProvider.awareness, - { cursorBuilder: this.buildCollabCursor.bind(this) } + const base = this._getApiBase(); + const res = await fetch( + `${base}/api/vault/${this._selectedVaultId}/note/${encodeURIComponent(path)}` ); - this.editor.registerPlugin(cursorPlugin); - - // Update collab status bar when peers change - this.yjsProvider.awareness.on('update', () => { - this.updatePeersIndicator(); - }); - } - - // Initial collab status bar update - this.updatePeersIndicator(); - - // Periodic plaintext sync to Automerge (for search indexing) - this.yjsPlainTextTimer = setInterval(() => { - if (!this.editor || !this.editorNoteId) return; - const plain = this.editor.getText(); - this.updateNoteField(this.editorNoteId, 'contentPlain', plain); - }, 5000); + if (res.ok) { + this._noteContent = await res.text(); + } + } catch { /* ignore */ } + this._render(); } - /** Mount TipTap without Yjs (demo mode / read-only). */ - private mountTiptapLegacy(note: Note, isEditable: boolean, isDemo: boolean, container: HTMLElement) { - let content: any = ''; - if (note.content) { - if (note.content_format === 'tiptap-json') { - try { content = JSON.parse(note.content); } catch { content = note.content; } + private async _uploadVault(form: HTMLFormElement) { + const fd = new FormData(form); + this._uploadStatus = 'Uploading…'; + this._render(); + try { + const base = this._getApiBase(); + const res = await fetch(`${base}/api/vault/upload`, { method: 'POST', body: fd }); + const data = await res.json(); + if (res.ok) { + this._uploadStatus = `Vault uploaded: ${data.name ?? 'done'}`; + this._uploadOpen = false; + await this._fetchVaults(); } else { - content = note.content; + this._uploadStatus = `Error: ${data.error ?? 'upload failed'}`; } + } catch (e: any) { + this._uploadStatus = `Error: ${e.message}`; } - - this.editor = new Editor({ - element: container, - editable: isEditable, - extensions: [ - StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] }, link: false, underline: false }), - Link.configure({ openOnClick: false }), - Image, TaskList, TaskItem.configure({ nested: true }), - Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }), - CodeBlockLowlight.configure({ lowlight }), Typography, Underline, - Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }), - CommentMark, - SuggestionInsertMark, - SuggestionDeleteMark, - ], - content, - onUpdate: ({ editor }) => { - if (this.isRemoteUpdate) return; - if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer); - this.editorUpdateTimer = setTimeout(() => { - const json = JSON.stringify(editor.getJSON()); - const plain = editor.getText(); - const noteId = this.editorNoteId; - if (!noteId) return; - if (isDemo) { - this.demoUpdateNoteField(noteId, "content", json); - this.demoUpdateNoteField(noteId, "content_plain", plain); - this.demoUpdateNoteField(noteId, "content_format", 'tiptap-json'); - } else { - this.updateNoteField(noteId, "content", json); - this.updateNoteField(noteId, "contentPlain", plain); - this.updateNoteField(noteId, "contentFormat", 'tiptap-json'); - } - }, 800); - }, - onSelectionUpdate: () => { this.updateToolbarState(); }, - }); + this._render(); } - /** Build a DOM element for remote collaborator cursor. */ - private buildCollabCursor(user: { name: string; color: string }) { - const cursor = document.createElement('span'); - cursor.className = 'collab-cursor'; - cursor.style.borderLeftColor = user.color; + // ── Rendering ───────────────────────────────────────────────────────────── - const label = document.createElement('span'); - label.className = 'collab-cursor-label'; - label.style.backgroundColor = user.color; - label.textContent = user.name; - cursor.appendChild(label); - - return cursor; + private _render() { + this._shadow.innerHTML = `${this._html()}`; + this._attachEvents(); } - /** Derive a stable color from a user ID string. */ - private userColor(id: string): string { - let hash = 0; - for (let i = 0; i < id.length; i++) { - hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0; - } - const hue = Math.abs(hash) % 360; - return `hsl(${hue}, 70%, 50%)`; - } - - /** Get session info for cursor display. */ - private getSessionInfo(): { username: string; userId: string } { - try { - const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); - const c = sess?.claims; - return { - username: c?.username || c?.displayName || sess?.username || 'Anonymous', - userId: c?.sub || sess?.userId || 'anon', - }; - } catch { - return { username: 'Anonymous', userId: 'anon' }; - } - } - - // ── Presence indicators ── - - /** Start presence broadcasting and listening. Called once when runtime is available. */ - private setupPresence() { - const runtime = (window as any).__rspaceOfflineRuntime; - if (this._presenceUnsub) return; - if (!runtime?.isInitialized) { - setTimeout(() => this.setupPresence(), 2000); - return; - } - - // Listen for presence messages from peers (for sidebar notebook/note dots) - this._presenceUnsub = runtime.onCustomMessage('presence', (msg: any) => { - if (msg.module !== 'rnotes' || !msg.peerId) return; - this._presencePeers.set(msg.peerId, { - peerId: msg.peerId, - username: msg.username || 'Anonymous', - color: msg.color || '#888', - notebookId: msg.notebookId || null, - noteId: msg.noteId || null, - lastSeen: Date.now(), - }); - this.renderPresenceIndicators(); - }); - - // Use shared heartbeat for broadcasting - this._stopPresence = startPresenceHeartbeat(() => ({ - module: 'rnotes', - context: this.selectedNote - ? `${this.selectedNotebook?.title || 'Notebook'} > ${this.selectedNote.title}` - : this.selectedNotebook?.title || '', - notebookId: this.selectedNotebook?.id, - noteId: this.selectedNote?.id, - })); - - // GC: remove stale peers every 15s (for sidebar dots) - this._presenceGC = setInterval(() => { - const cutoff = Date.now() - 20_000; - let changed = false; - for (const [id, peer] of this._presencePeers) { - if (peer.lastSeen < cutoff) { - this._presencePeers.delete(id); - changed = true; - } - } - if (changed) this.renderPresenceIndicators(); - }, 15_000); - } - - /** Broadcast current user position to peers. */ - private broadcastPresence() { - sharedBroadcastPresence({ - module: 'rnotes', - context: this.selectedNote - ? `${this.selectedNotebook?.title || 'Notebook'} > ${this.selectedNote.title}` - : this.selectedNotebook?.title || '', - notebookId: this.selectedNotebook?.id, - noteId: this.selectedNote?.id, - }); - } - - /** Patch presence dots onto sidebar notebook headers and note items. */ - private renderPresenceIndicators() { - // Remove all existing presence dots - this.shadow.querySelectorAll('.presence-dots').forEach(el => el.remove()); - - // Notebook headers in sidebar - this.shadow.querySelectorAll('.sbt-notebook-header[data-toggle-notebook]').forEach(header => { - const nbId = header.dataset.toggleNotebook; - const peers = Array.from(this._presencePeers.values()).filter(p => p.notebookId === nbId); - if (peers.length === 0) return; - header.appendChild(this.buildPresenceDots(peers)); - }); - - // Note items in sidebar - this.shadow.querySelectorAll('.sbt-note[data-note]').forEach(item => { - const noteId = item.dataset.note; - const peers = Array.from(this._presencePeers.values()).filter(p => p.noteId === noteId); - if (peers.length === 0) return; - item.appendChild(this.buildPresenceDots(peers)); - }); - } - - /** Build a presence-dots container for a set of peers. */ - private buildPresenceDots(peers: { username: string; color: string }[]): HTMLSpanElement { - const container = document.createElement('span'); - container.className = 'presence-dots'; - const show = peers.slice(0, 3); - for (const p of show) { - const dot = document.createElement('span'); - dot.className = 'presence-dot'; - dot.style.background = p.color; - dot.title = p.username; - container.appendChild(dot); - } - if (peers.length > 3) { - const more = document.createElement('span'); - more.className = 'presence-dot-more'; - more.textContent = `+${peers.length - 3}`; - container.appendChild(more); - } - return container; - } - - /** Tear down presence listeners and timers. */ - private cleanupPresence() { - this._stopPresence?.(); - this._stopPresence = null; - this._presenceUnsub?.(); - this._presenceUnsub = null; - if (this._presenceGC) { clearInterval(this._presenceGC); this._presenceGC = null; } - this._presencePeers.clear(); - } - - private mountCodeEditor(note: Note, isEditable: boolean, isDemo: boolean) { - const languages = ['javascript', 'typescript', 'python', 'rust', 'go', 'html', 'css', 'json', 'sql', 'bash', 'c', 'cpp', 'java', 'ruby', 'php', 'markdown', 'yaml', 'toml', 'other']; - const currentLang = note.language || 'javascript'; - - this.contentZone.innerHTML = ` -
      - -
      - -
      - -
      - `; - - const textarea = this.shadow.getElementById('code-textarea') as HTMLTextAreaElement; - const langSelect = this.shadow.getElementById('code-lang-select') as HTMLSelectElement; - - if (textarea && isEditable) { - let timer: any; - textarea.addEventListener('input', () => { - clearTimeout(timer); - timer = setTimeout(() => { - if (isDemo) { - this.demoUpdateNoteField(note.id, "content", textarea.value); - this.demoUpdateNoteField(note.id, "content_plain", textarea.value); - } else { - this.updateNoteField(note.id, "content", textarea.value); - this.updateNoteField(note.id, "contentPlain", textarea.value); - } - }, 800); - }); - // Tab inserts a tab character - textarea.addEventListener('keydown', (e) => { - if (e.key === 'Tab') { - e.preventDefault(); - const start = textarea.selectionStart; - textarea.value = textarea.value.substring(0, start) + '\t' + textarea.value.substring(textarea.selectionEnd); - textarea.selectionStart = textarea.selectionEnd = start + 1; - textarea.dispatchEvent(new Event('input')); - } - }); - } - - if (langSelect && isEditable) { - langSelect.addEventListener('change', () => { - if (isDemo) { - this.demoUpdateNoteField(note.id, "language", langSelect.value); - } else { - this.updateNoteField(note.id, "language", langSelect.value); - } - }); - } - - this.wireTitleInput(note, isEditable, isDemo); - } - - private mountBookmarkView(note: Note, isEditable: boolean, isDemo: boolean) { - const hostname = note.url ? (() => { try { return new URL(note.url).hostname; } catch { return note.url; } })() : ''; - const favicon = note.url ? `https://www.google.com/s2/favicons?sz=32&domain=${hostname}` : ''; - - this.contentZone.innerHTML = ` -
      - -
      - ${favicon ? `` : ''} -
      - ${note.url ? `${this.esc(hostname)}` : ''} -
      - -
      -
      -
      -
      -
      - `; - - // Mount tiptap for the excerpt/notes - const container = this.shadow.getElementById('tiptap-container'); - if (!container) return; - - let content: any = ''; - if (note.content) { - if (note.content_format === 'tiptap-json') { - try { content = JSON.parse(note.content); } catch { content = note.content; } - } else { content = note.content; } - } - - this.editor = new Editor({ - element: container, editable: isEditable, - extensions: [ - StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3] }, link: false, underline: false }), - Link.configure({ openOnClick: false }), Image, - Placeholder.configure({ placeholder: note.type === 'CLIP' ? 'Clipped content...' : 'Add notes about this bookmark...' }), - Typography, Underline, Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }), - ], - content, - onUpdate: ({ editor }) => { - if (this.isRemoteUpdate) return; - if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer); - this.editorUpdateTimer = setTimeout(() => { - const json = JSON.stringify(editor.getJSON()); - const plain = editor.getText(); - const noteId = this.editorNoteId; - if (!noteId) return; - if (isDemo) { - this.demoUpdateNoteField(noteId, "content", json); - this.demoUpdateNoteField(noteId, "content_plain", plain); - } else { - this.updateNoteField(noteId, "content", json); - this.updateNoteField(noteId, "contentPlain", plain); - } - }, 800); - }, - }); - - // Wire URL input - const urlInput = this.shadow.getElementById('bookmark-url-input') as HTMLInputElement; - if (urlInput && isEditable) { - let timer: any; - urlInput.addEventListener('input', () => { - clearTimeout(timer); - timer = setTimeout(() => { - if (isDemo) { - this.demoUpdateNoteField(note.id, "url", urlInput.value); - } else { - this.updateNoteField(note.id, "url", urlInput.value); - } - }, 500); - }); - } - - this.wireTitleInput(note, isEditable, isDemo); - } - - private mountImageView(note: Note, isEditable: boolean, isDemo: boolean) { - this.contentZone.innerHTML = ` -
      - - ${note.fileUrl - ? `
      ${this.esc(note.title)}
      ` - : `
      - -
      ` - } -
      -
      - `; - - // Mount tiptap for caption/notes - const container = this.shadow.getElementById('tiptap-container'); - if (container) { - let content: any = ''; - if (note.content) { - if (note.content_format === 'tiptap-json') { - try { content = JSON.parse(note.content); } catch { content = note.content; } - } else { content = note.content; } - } - this.editor = new Editor({ - element: container, editable: isEditable, - extensions: [ - StarterKit.configure({ codeBlock: false, link: false, underline: false }), Link.configure({ openOnClick: false }), - Placeholder.configure({ placeholder: 'Add a caption or notes...' }), Typography, Underline, - Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }), - ], - content, - onUpdate: ({ editor }) => { - if (this.isRemoteUpdate) return; - if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer); - this.editorUpdateTimer = setTimeout(() => { - const json = JSON.stringify(editor.getJSON()); - const plain = editor.getText(); - const noteId = this.editorNoteId; - if (!noteId) return; - if (isDemo) { this.demoUpdateNoteField(noteId, "content", json); this.demoUpdateNoteField(noteId, "content_plain", plain); } - else { this.updateNoteField(noteId, "content", json); this.updateNoteField(noteId, "contentPlain", plain); } - }, 800); - }, - }); - } - - // Wire image URL input - const imgUrlInput = this.shadow.getElementById('image-url-input') as HTMLInputElement; - if (imgUrlInput && isEditable) { - let timer: any; - imgUrlInput.addEventListener('input', () => { - clearTimeout(timer); - timer = setTimeout(() => { - if (isDemo) { this.demoUpdateNoteField(note.id, "fileUrl", imgUrlInput.value); } - else { this.updateNoteField(note.id, "fileUrl", imgUrlInput.value); } - }, 500); - }); - } - - this.wireTitleInput(note, isEditable, isDemo); - } - - private mountAudioView(note: Note, isEditable: boolean, isDemo: boolean) { - const durationStr = note.duration ? `${Math.floor(note.duration / 60)}:${String(note.duration % 60).padStart(2, '0')}` : ''; - - this.contentZone.innerHTML = ` -
      - - ${note.fileUrl - ? `
      - - ${durationStr ? `${durationStr}` : ''} -
      ` - : `
      - -
      ` - } -
      -
      Transcript
      -
      -
      -
      - `; - - // Mount tiptap for transcript - const container = this.shadow.getElementById('tiptap-container'); - if (container) { - let content: any = ''; - if (note.content) { - if (note.content_format === 'tiptap-json') { - try { content = JSON.parse(note.content); } catch { content = note.content; } - } else { content = note.content; } - } - this.editor = new Editor({ - element: container, editable: isEditable, - extensions: [ - StarterKit.configure({ codeBlock: false, link: false, underline: false }), Link.configure({ openOnClick: false }), - Placeholder.configure({ placeholder: 'Transcript will appear here...' }), Typography, Underline, - Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }), - ], - content, - onUpdate: ({ editor }) => { - if (this.isRemoteUpdate) return; - if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer); - this.editorUpdateTimer = setTimeout(() => { - const json = JSON.stringify(editor.getJSON()); - const plain = editor.getText(); - const noteId = this.editorNoteId; - if (!noteId) return; - if (isDemo) { this.demoUpdateNoteField(noteId, "content", json); this.demoUpdateNoteField(noteId, "content_plain", plain); } - else { this.updateNoteField(noteId, "content", json); this.updateNoteField(noteId, "contentPlain", plain); } - }, 800); - }, - }); - } - - this.wireTitleInput(note, isEditable, isDemo); - - // Wire record button - this.shadow.getElementById('btn-start-recording')?.addEventListener('click', () => { - this.startAudioRecording(note, isDemo); - }); - } - - private async startAudioRecording(note: Note, isDemo: boolean) { - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') - ? 'audio/webm;codecs=opus' - : MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'; - - const audioChunks: Blob[] = []; - this.audioSegments = []; - this.audioRecorder = new MediaRecorder(stream, { mimeType }); - - this.audioRecorder.ondataavailable = (e) => { - if (e.data.size > 0) audioChunks.push(e.data); - }; - - // Replace placeholder with recording UI - const placeholder = this.shadow.querySelector('.audio-record-placeholder'); - if (placeholder) { - placeholder.innerHTML = ` -
      -
      -
      - 0:00 - -
      -
      -
      - `; - } - - // Start timer - this.audioRecordingStart = Date.now(); - let elapsed = 0; - this.audioRecordingTimer = setInterval(() => { - elapsed = Math.floor((Date.now() - this.audioRecordingStart) / 1000); - const timerEl = this.shadow.querySelector('.audio-recording-timer'); - if (timerEl) timerEl.textContent = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, '0')}`; - }, 1000); - - // Start speech dictation with segment tracking - if (SpeechDictation.isSupported()) { - this.audioRecordingDictation = new SpeechDictation({ - onInterim: (text) => { - const idx = this.audioSegments.findIndex(s => !s.isFinal); - const ts = Math.floor((Date.now() - this.audioRecordingStart) / 1000); - if (idx >= 0) { - this.audioSegments[idx].text = text; - } else { - this.audioSegments.push({ id: crypto.randomUUID(), text, timestamp: ts, isFinal: false }); - } - this.renderAudioSegments(); - }, - onFinal: (text) => { - const idx = this.audioSegments.findIndex(s => !s.isFinal); - const ts = Math.floor((Date.now() - this.audioRecordingStart) / 1000); - if (idx >= 0) { - this.audioSegments[idx] = { ...this.audioSegments[idx], text, isFinal: true }; - } else { - this.audioSegments.push({ id: crypto.randomUUID(), text, timestamp: ts, isFinal: true }); - } - this.renderAudioSegments(); - }, - }); - this.audioRecordingDictation.start(); - } - - this.audioRecorder.start(1000); - - // Wire stop button - this.shadow.getElementById('btn-stop-recording')?.addEventListener('click', async () => { - // Stop everything - if (this.audioRecordingTimer) { clearInterval(this.audioRecordingTimer); this.audioRecordingTimer = null; } - this.audioRecordingDictation?.stop(); - this.audioRecordingDictation?.destroy(); - this.audioRecordingDictation = null; - - this.audioRecorder!.onstop = async () => { - stream.getTracks().forEach(t => t.stop()); - const blob = new Blob(audioChunks, { type: mimeType }); - const duration = Math.floor((Date.now() - this.audioRecordingStart) / 1000); - - // Upload audio - let fileUrl = ''; - if (!isDemo) { - try { - const base = this.getApiBase(); - const fd = new FormData(); - fd.append('file', blob, 'recording.webm'); - const uploadRes = await fetch(`${base}/api/uploads`, { - method: 'POST', headers: this.authHeaders(), body: fd, - }); - if (uploadRes.ok) { fileUrl = (await uploadRes.json()).url; } - } catch { /* continue without file */ } - } - - // Convert segments to Tiptap JSON - const finalSegments = this.audioSegments.filter(s => s.isFinal); - const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; - let tiptapContent: any = { type: 'doc', content: [{ type: 'paragraph' }] }; - if (finalSegments.length > 0) { - tiptapContent = { - type: 'doc', - content: finalSegments.map(seg => ({ - type: 'paragraph', - content: [ - { type: 'text', marks: [{ type: 'code' }], text: `[${fmt(seg.timestamp)}]` }, - { type: 'text', text: ` ${seg.text}` }, - ], - })), - }; - } - - const contentJson = JSON.stringify(tiptapContent); - const contentPlain = finalSegments.map(s => s.text).join(' '); - - // Update note fields - const noteId = note.id; - if (isDemo) { - if (fileUrl) this.demoUpdateNoteField(noteId, 'fileUrl', fileUrl); - this.demoUpdateNoteField(noteId, 'content', contentJson); - this.demoUpdateNoteField(noteId, 'content_plain', contentPlain); - } else { - if (fileUrl) this.updateNoteField(noteId, 'fileUrl', fileUrl); - this.updateNoteField(noteId, 'duration', String(duration)); - this.updateNoteField(noteId, 'content', contentJson); - this.updateNoteField(noteId, 'contentPlain', contentPlain); - this.updateNoteField(noteId, 'contentFormat', 'tiptap-json'); - } - - // Update local note for immediate display - (note as any).fileUrl = fileUrl || note.fileUrl; - (note as any).duration = duration; - (note as any).content = contentJson; - (note as any).content_plain = contentPlain; - (note as any).content_format = 'tiptap-json'; - - // Re-mount audio view - this.audioRecorder = null; - this.audioSegments = []; - this.destroyEditor(); - this.editorNoteId = noteId; - this.mountAudioView(note, true, isDemo); - }; - - if (this.audioRecorder?.state === 'recording') { - this.audioRecorder.stop(); - } - }); - } catch (err) { - console.error('Failed to start audio recording:', err); - } - } - - private renderAudioSegments() { - const container = this.shadow.querySelector('.audio-live-segments'); - if (!container) return; - const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }; - const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; - - container.innerHTML = this.audioSegments.map(seg => ` -
      - [${fmt(seg.timestamp)}] - ${esc(seg.text)} -
      - `).join(''); - container.scrollTop = container.scrollHeight; - } - - /** Shared title input wiring for all editor types */ - private wireTitleInput(note: Note, _isEditable: boolean, isDemo: boolean) { - const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement; - if (titleInput) { - let titleTimeout: any; - titleInput.addEventListener("input", () => { - clearTimeout(titleTimeout); - titleTimeout = setTimeout(() => { - if (isDemo) { this.demoUpdateNoteField(note.id, "title", titleInput.value); } - else { this.updateNoteField(note.id, "title", titleInput.value); } - }, 500); - }); - } - } - - private destroyEditor() { - if (this.editorUpdateTimer) { - clearTimeout(this.editorUpdateTimer); - this.editorUpdateTimer = null; - } - if (this.yjsPlainTextTimer) { - clearInterval(this.yjsPlainTextTimer); - this.yjsPlainTextTimer = null; - } - if (this.yjsProvider) { - this.yjsProvider.destroy(); - this.yjsProvider = null; - } - if (this.yIndexedDb) { - this.yIndexedDb.destroy(); - this.yIndexedDb = null; - } - if (this.ydoc) { - this.ydoc.destroy(); - this.ydoc = null; - } - if (this.dictation) { - this.dictation.destroy(); - this.dictation = null; - } - if (this.audioRecordingTimer) { - clearInterval(this.audioRecordingTimer); - this.audioRecordingTimer = null; - } - if (this.audioRecordingDictation) { - this.audioRecordingDictation.destroy(); - this.audioRecordingDictation = null; - } - if (this.audioRecorder?.state === 'recording') { - this.audioRecorder.stop(); - } - this.audioRecorder = null; - this.suggestingMode = false; - if (this.editor) { - this.editor.destroy(); - this.editor = null; - } - this.editorNoteId = null; - } - - private renderToolbar(): string { - const btn = (cmd: string, title: string) => - ``; + private _css(): string { return ` -
      -
      - ${btn('bold', 'Bold (Ctrl+B)')} - ${btn('italic', 'Italic (Ctrl+I)')} - ${btn('underline', 'Underline (Ctrl+U)')} - ${btn('strike', 'Strikethrough')} - ${btn('code', 'Inline Code')} +:host { display: flex; flex-direction: column; height: 100%; font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; font-size: 13px; } +* { box-sizing: border-box; margin: 0; padding: 0; } + +/* Toolbar */ +.toolbar { display: flex; align-items: center; gap: 10px; padding: 8px 14px; background: #111; border-bottom: 1px solid #222; flex-shrink: 0; } +.toolbar-title { font-size: 12px; color: #888; margin-right: auto; } +.btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 6px; border: 1px solid #333; background: #1a1a1a; color: #e0e0e0; cursor: pointer; font-size: 12px; white-space: nowrap; transition: background 0.15s; } +.btn:hover { background: #252525; border-color: #444; } +.btn.primary { background: #14b8a6; border-color: #0d9488; color: #fff; } +.btn.primary:hover { background: #0d9488; } +.search-input { background: #1a1a1a; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 12px; padding: 5px 10px; outline: none; width: 200px; } +.search-input:focus { border-color: #14b8a6; } + +/* Layout */ +.layout { display: flex; flex: 1; overflow: hidden; } + +/* Left sidebar */ +.sidebar { width: 250px; flex-shrink: 0; background: #111; border-right: 1px solid #222; display: flex; flex-direction: column; overflow: hidden; } +.sidebar-header { padding: 10px 14px; font-size: 11px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid #1e1e1e; flex-shrink: 0; } +.vault-list { flex: 1; overflow-y: auto; padding: 6px 0; } +.vault-item { padding: 8px 14px; cursor: pointer; border-left: 3px solid transparent; transition: background 0.1s; } +.vault-item:hover { background: #1a1a1a; } +.vault-item.active { background: rgba(20,184,166,0.08); border-left-color: #14b8a6; } +.vault-name { font-size: 13px; color: #e0e0e0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vault-meta { display: flex; align-items: center; gap: 6px; margin-top: 3px; font-size: 11px; color: #666; } +.source-badge { display: inline-flex; align-items: center; gap: 3px; } +.sync-dot { width: 6px; height: 6px; border-radius: 50%; background: #14b8a6; flex-shrink: 0; } +.empty-state { padding: 20px 14px; color: #555; font-size: 12px; line-height: 1.5; } + +/* Center file tree */ +.file-tree { width: 400px; flex-shrink: 0; background: #0d0d0d; border-right: 1px solid #222; display: flex; flex-direction: column; overflow: hidden; } +.tree-search { padding: 8px 10px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0; } +.tree-search .search-input { width: 100%; } +.tree-body { flex: 1; overflow-y: auto; padding: 4px 0; } +.folder-header { display: flex; align-items: center; gap: 6px; padding: 5px 10px; cursor: pointer; color: #888; font-size: 12px; font-weight: 500; user-select: none; } +.folder-header:hover { color: #ccc; } +.folder-header .chevron { transition: transform 0.15s; } +.folder-header .chevron.open { transform: rotate(0deg); } +.folder-header .chevron.closed { transform: rotate(-90deg); } +.folder-files { padding-left: 8px; } +.note-item { display: flex; flex-direction: column; padding: 6px 10px; cursor: pointer; border-left: 3px solid transparent; transition: background 0.1s; } +.note-item:hover { background: #1a1a1a; } +.note-item.active { background: rgba(20,184,166,0.08); border-left-color: #14b8a6; } +.note-title { font-size: 12px; color: #ddd; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.note-footer { display: flex; align-items: center; gap: 4px; margin-top: 3px; flex-wrap: wrap; } +.tag { display: inline-block; padding: 1px 5px; border-radius: 3px; background: rgba(20,184,166,0.12); color: #14b8a6; font-size: 10px; border: 1px solid rgba(20,184,166,0.2); } +.note-time { font-size: 10px; color: #555; margin-left: auto; white-space: nowrap; } +.no-vault { padding: 30px 16px; color: #555; font-size: 12px; text-align: center; line-height: 1.6; } + +/* Preview panel */ +.preview { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #0a0a0a; } +.preview-header { padding: 10px 16px; background: #111; border-bottom: 1px solid #222; flex-shrink: 0; } +.preview-path { font-size: 11px; color: #555; margin-bottom: 4px; word-break: break-all; } +.preview-tags { display: flex; flex-wrap: wrap; gap: 4px; } +.preview-body { flex: 1; overflow-y: auto; padding: 24px 28px; line-height: 1.7; } +.preview-body h1, .preview-body h2, .preview-body h3, .preview-body h4, .preview-body h5, .preview-body h6 { color: #f0f0f0; margin: 1.2em 0 0.4em; font-weight: 600; line-height: 1.3; } +.preview-body h1 { font-size: 1.6em; border-bottom: 1px solid #222; padding-bottom: 0.3em; } +.preview-body h2 { font-size: 1.3em; } +.preview-body h3 { font-size: 1.1em; color: #ddd; } +.preview-body p { color: #c8c8c8; margin: 0.5em 0; } +.preview-body a { color: #14b8a6; text-decoration: none; } +.preview-body a:hover { text-decoration: underline; } +.preview-body a.wikilink { color: #60a5fa; border-bottom: 1px dashed rgba(96,165,250,0.5); } +.preview-body code { background: #1a1a1a; border: 1px solid #333; border-radius: 3px; padding: 1px 4px; font-family: 'Fira Code', monospace; font-size: 12px; color: #c8f; } +.preview-body pre { background: #131313; border: 1px solid #2a2a2a; border-radius: 6px; padding: 14px 16px; overflow-x: auto; margin: 1em 0; } +.preview-body pre code { background: none; border: none; padding: 0; color: #e0e0e0; font-size: 12px; } +.preview-body blockquote { border-left: 3px solid #333; padding-left: 14px; margin: 0.8em 0; color: #888; font-style: italic; } +.preview-body ul, .preview-body ol { padding-left: 1.5em; margin: 0.5em 0; color: #c0c0c0; } +.preview-body li { margin: 0.2em 0; } +.preview-body hr { border: none; border-top: 1px solid #2a2a2a; margin: 1.5em 0; } +.preview-body img { max-width: 100%; border-radius: 4px; } +.preview-body strong { color: #f0f0f0; } +.preview-body em { color: #c8a0f0; } +.preview-body del { color: #666; } +.no-preview { padding: 40px; color: #444; font-size: 13px; text-align: center; line-height: 1.8; } + +/* Upload dialog */ +.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; backdrop-filter: blur(2px); } +.dialog { background: #1a1a1a; border: 1px solid #333; border-radius: 10px; padding: 24px; width: 380px; max-width: 90vw; } +.dialog-title { font-size: 15px; font-weight: 600; color: #f0f0f0; margin-bottom: 18px; } +.field { margin-bottom: 14px; } +.field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; } +.field input, .field select { width: 100%; background: #111; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 13px; padding: 7px 10px; outline: none; } +.field input:focus, .field select:focus { border-color: #14b8a6; } +.field select option { background: #1a1a1a; } +.dialog-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; } +.status-msg { margin-top: 10px; font-size: 12px; color: #14b8a6; min-height: 18px; } + +/* Scrollbar */ +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #444; } + +/* Loading spinner */ +.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #333; border-top-color: #14b8a6; border-radius: 50%; animation: spin 0.7s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + `; + } + + private _html(): string { + const vault = this._vaults.find(v => v.id === this._selectedVaultId); + return ` +
      + ${ICON_SEARCH} ${escHtml(this._space || 'rNotes')} + +
      +
      + ${this._renderSidebar()} + ${this._renderFileTree()} + ${this._renderPreview(vault)} +
      +${this._uploadOpen ? this._renderUploadDialog() : ''} + `; + } + + private _renderSidebar(): string { + if (this._loading) { + return ``; + } + const items = this._vaults.map(v => { + const active = v.id === this._selectedVaultId ? ' active' : ''; + const icon = v.source === 'logseq' ? ICON_LOGSEQ : ICON_OBSIDIAN; + const label = v.source === 'logseq' ? 'Logseq' : 'Obsidian'; + const ago = timeAgo(v.lastSyncedAt); + return ` +
      +
      ${escHtml(v.name)}
      +
      + ${icon} ${label} + + ${escHtml(String(v.totalNotes))} notes + · ${ago} +
      +
      `; + }).join(''); + + return ` +`; + } + + private _renderFileTree(): string { + if (!this._selectedVaultId) { + return `
      Select a vault
      to browse notes
      `; + } + + // Filter by search + const q = this._searchQuery.toLowerCase(); + const filtered = q + ? this._notes.filter(n => n.title.toLowerCase().includes(q) || n.path.toLowerCase().includes(q)) + : this._notes; + + // Group by folder + const folders: Record = {}; + for (const n of filtered) { + const parts = n.path.split('/'); + const folder = parts.length > 1 ? parts.slice(0, -1).join('/') : '(root)'; + (folders[folder] ??= []).push(n); + } + + const folderHtml = Object.entries(folders).sort(([a], [b]) => a.localeCompare(b)).map(([folder, notes]) => { + const isOpen = this._folderOpen[folder] !== false; // default open + const chevronClass = isOpen ? 'open' : 'closed'; + const notesHtml = isOpen ? notes.sort((a, b) => a.title.localeCompare(b.title)).map(n => { + const active = n.path === this._selectedNotePath ? ' active' : ''; + const tags = (n.tags ?? []).slice(0, 3).map(t => `${escHtml(t)}`).join(''); + const ago = timeAgo(n.lastModifiedAt); + return ` +
      +
      ${ICON_FILE} ${escHtml(n.title || n.path.split('/').pop() || n.path)}
      + +
      `; + }).join('') : ''; + + return ` +
      + ${ICON_CHEVRON} + ${ICON_FOLDER} ${escHtml(folder)} ${notes.length} +
      +
      ${notesHtml}
      `; + }).join(''); + + return ` +
      + +
      + ${folderHtml || '
      No notes found
      '} +
      +
      `; + } + + private _renderPreview(vault?: VaultMeta): string { + if (!this._selectedNotePath) { + return `
      Select a note
      to preview it here
      `; + } + const note = this._notes.find(n => n.path === this._selectedNotePath); + const tags = (note?.tags ?? []).map(t => `${escHtml(t)}`).join(''); + const modTime = note ? `Last modified ${timeAgo(note.lastModifiedAt)}` : ''; + + let bodyHtml: string; + if (!this._noteContent) { + bodyHtml = `
      `; + } else { + bodyHtml = renderMarkdown(this._noteContent); + } + + return ` +
      +
      +
      ${escHtml(this._selectedNotePath)} ${modTime ? `· ${modTime}` : ''}
      + ${tags ? `
      ${tags}
      ` : ''} +
      +
      ${bodyHtml}
      +
      `; + } + + private _renderUploadDialog(): string { + return ` +
      +
      +
      ${ICON_UPLOAD} Upload Vault
      +
      +
      + +
      -
      -
      - + +
      -
      -
      - ${btn('bulletList', 'Bullet List')} - ${btn('orderedList', 'Numbered List')} - ${btn('taskList', 'Task List')} +
      + +
      -
      -
      - ${btn('blockquote', 'Blockquote')} - ${btn('codeBlock', 'Code Block')} - ${btn('horizontalRule', 'Divider')} +
      ${escHtml(this._uploadStatus)}
      +
      + +
      -
      -
      - ${btn('link', 'Insert Link')} - ${btn('image', 'Insert Image')} -
      -
      -
      - ${btn('undo', 'Undo (Ctrl+Z)')} - ${btn('redo', 'Redo (Ctrl+Y)')} -
      - ${SpeechDictation.isSupported() ? ` -
      -
      - ${btn('mic', 'Voice Dictation')} -
      ` : ''} -
      -
      - ${btn('summarize', 'Summarize Note')} -
      -
      -
      - - - -
      -
      `; + +
      +
      `; } - private showUrlPopover(anchorRect: DOMRect, placeholder: string): Promise { - return new Promise((resolve) => { - this.shadow.querySelector('.url-popover')?.remove(); + // ── Event wiring ────────────────────────────────────────────────────────── - const popover = document.createElement('div'); - popover.className = 'url-popover'; + private _attachEvents() { + const $ = (id: string) => this._shadow.getElementById(id); - const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect(); - if (window.innerWidth > 640) { - popover.style.left = `${anchorRect.left - hostRect.left}px`; - popover.style.top = `${anchorRect.bottom - hostRect.top + 4}px`; - } - - const input = document.createElement('input'); - input.type = 'url'; - input.placeholder = placeholder; - input.className = 'url-popover__input'; - - const insertBtn = document.createElement('button'); - insertBtn.textContent = 'Insert'; - insertBtn.className = 'url-popover__btn url-popover__btn--insert'; - - const cancelBtn = document.createElement('button'); - cancelBtn.textContent = 'Cancel'; - cancelBtn.className = 'url-popover__btn url-popover__btn--cancel'; - - const btnRow = document.createElement('div'); - btnRow.className = 'url-popover__actions'; - btnRow.append(cancelBtn, insertBtn); - - popover.append(input, btnRow); - this.shadow.appendChild(popover); - input.focus(); - - const cleanup = (value: string | null) => { - popover.remove(); - resolve(value); - }; - - insertBtn.addEventListener('click', () => { - const val = input.value.trim(); - cleanup(val || null); - }); - - cancelBtn.addEventListener('click', () => cleanup(null)); - - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - const val = input.value.trim(); - cleanup(val || null); - } - if (e.key === 'Escape') { - e.preventDefault(); - cleanup(null); - } - }); + // Upload button + $('btn-upload')?.addEventListener('click', () => { + this._uploadOpen = true; this._uploadStatus = ''; this._render(); }); - } - private attachToolbarListeners() { - const toolbar = this.shadow.getElementById('editor-toolbar'); - if (!toolbar || !this.editor) return; + // Close overlay + $('btn-cancel-upload')?.addEventListener('click', () => { + this._uploadOpen = false; this._render(); + }); + $('overlay')?.addEventListener('click', (e) => { + if ((e.target as HTMLElement).id === 'overlay') { this._uploadOpen = false; this._render(); } + }); - // Button clicks via event delegation - toolbar.addEventListener('click', (e) => { - const btn = (e.target as HTMLElement).closest('[data-cmd]') as HTMLElement; - if (!btn || btn.tagName === 'SELECT') return; + // Upload form submit + const uploadForm = $('upload-form') as HTMLFormElement | null; + uploadForm?.addEventListener('submit', async (e) => { e.preventDefault(); - const cmd = btn.dataset.cmd; - if (!this.editor) return; - - switch (cmd) { - case 'bold': this.editor.chain().focus().toggleBold().run(); break; - case 'italic': this.editor.chain().focus().toggleItalic().run(); break; - case 'underline': this.editor.chain().focus().toggleUnderline().run(); break; - case 'strike': this.editor.chain().focus().toggleStrike().run(); break; - case 'code': this.editor.chain().focus().toggleCode().run(); break; - case 'bulletList': this.editor.chain().focus().toggleBulletList().run(); break; - case 'orderedList': this.editor.chain().focus().toggleOrderedList().run(); break; - case 'taskList': this.editor.chain().focus().toggleTaskList().run(); break; - case 'blockquote': this.editor.chain().focus().toggleBlockquote().run(); break; - case 'codeBlock': this.editor.chain().focus().toggleCodeBlock().run(); break; - case 'horizontalRule': this.editor.chain().focus().setHorizontalRule().run(); break; - case 'link': { - const rect = btn.getBoundingClientRect(); - this.showUrlPopover(rect, 'Enter link URL...').then(url => { - if (url) this.editor!.chain().focus().setLink({ href: url }).run(); - else this.editor!.chain().focus().run(); - }); - break; - } - case 'image': { - const rect = btn.getBoundingClientRect(); - this.showUrlPopover(rect, 'Enter image URL...').then(url => { - if (url) this.editor!.chain().focus().setImage({ src: url }).run(); - else this.editor!.chain().focus().run(); - }); - break; - } - case 'undo': this.editor.chain().focus().undo().run(); break; - case 'redo': this.editor.chain().focus().redo().run(); break; - case 'mic': this.toggleDictation(btn); break; - case 'summarize': this.summarizeNote(btn); break; - case 'comment': this.addComment(); break; - case 'toggleSuggesting': this.toggleSuggestingMode(btn); break; - } + await this._uploadVault(uploadForm); }); - // Heading select - const headingSelect = toolbar.querySelector('[data-cmd="heading"]') as HTMLSelectElement; - if (headingSelect) { - headingSelect.addEventListener('change', () => { - if (!this.editor) return; - const val = headingSelect.value; - if (val === 'paragraph') { - this.editor.chain().focus().setParagraph().run(); - } else { - this.editor.chain().focus().setHeading({ level: parseInt(val) as 1 | 2 | 3 | 4 }).run(); + // Vault selection + this._shadow.querySelectorAll('.vault-item').forEach(el => { + el.addEventListener('click', () => { + const id = el.dataset.vaultId; + if (id) this._selectVault(id); + }); + }); + + // Folder toggle + this._shadow.querySelectorAll('.folder-header').forEach(el => { + el.addEventListener('click', () => { + const f = el.dataset.folder; + if (f !== undefined) { + this._folderOpen[f] = !(this._folderOpen[f] !== false); + this._render(); } }); - } - } - - /** Add a comment on the current selection. */ - private addComment() { - if (!this.editor) return; - const { from, to, empty } = this.editor.state.selection; - if (empty) return; // Need selected text - - const threadId = `c_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - const session = this.getSessionInfo(); - - // Apply comment mark to the selection - this.editor.chain().focus() - .setMark('comment', { threadId, resolved: false }) - .run(); - - // Create thread in Automerge or demo storage - const noteId = this.editorNoteId; - if (noteId && this.doc?.items?.[noteId] && this.subscribedDocId) { - const runtime = (window as any).__rspaceOfflineRuntime; - if (runtime?.isInitialized) { - runtime.change(this.subscribedDocId as DocumentId, 'Add comment thread', (d: NotebookDoc) => { - const item = d.items[noteId] as any; - if (!item.comments) item.comments = {}; - item.comments[threadId] = { - id: threadId, - anchor: `${from}-${to}`, - resolved: false, - messages: [], - createdAt: Date.now(), - }; - }); - this.doc = runtime.get(this.subscribedDocId as DocumentId); - } - } else if (this.space === 'demo' && noteId) { - if (!this._demoThreads.has(noteId)) this._demoThreads.set(noteId, {}); - this._demoThreads.get(noteId)![threadId] = { - id: threadId, - anchor: `${from}-${to}`, - resolved: false, - messages: [], - createdAt: Date.now(), - }; - } - - // Open comment panel - this.showCommentPanel(threadId); - } - - /** Toggle between editing and suggesting modes. */ - private toggleSuggestingMode(btn: HTMLElement) { - this.suggestingMode = !this.suggestingMode; - btn.classList.toggle('active', this.suggestingMode); - btn.title = this.suggestingMode ? 'Switch to Editing Mode' : 'Switch to Suggesting Mode'; - - // Update editor's editable state to reflect mode - const container = this.shadow.getElementById('tiptap-container'); - if (container) { - container.classList.toggle('suggesting-mode', this.suggestingMode); - } - - // Show/hide suggestion review bar - this.updateSuggestionReviewBar(); - } - - /** Show a review bar when there are pending suggestions. */ - private updateSuggestionReviewBar() { - let bar = this.shadow.getElementById('suggestion-review-bar'); - if (!this.editor) { - bar?.remove(); - return; - } - - // Count suggestions - const ids = new Set(); - this.editor.state.doc.descendants((node: any) => { - if (!node.isText) return; - for (const mark of node.marks) { - if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') { - ids.add(mark.attrs.suggestionId); - } - } }); - if (ids.size === 0 && !this.suggestingMode) { - bar?.remove(); - return; - } - - if (!bar) { - bar = document.createElement('div'); - bar.id = 'suggestion-review-bar'; - bar.className = 'suggestion-review-bar'; - // Insert after toolbar - const toolbar = this.shadow.getElementById('editor-toolbar'); - if (toolbar?.parentNode) { - toolbar.parentNode.insertBefore(bar, toolbar.nextSibling); - } - } - - bar.innerHTML = ` - ${this.suggestingMode ? 'Suggesting' : 'Editing'} - ${ids.size > 0 ? ` - ${ids.size} suggestion${ids.size !== 1 ? 's' : ''} — review in sidebar - ` : 'Start typing to suggest changes'} - `; - - // Open sidebar to show suggestions when there are any - if (ids.size > 0) this.showCommentPanel(); - } - - /** Show an accept/reject popover near a clicked suggestion mark. */ - private showSuggestionPopover(suggestionId: string, authorName: string, type: 'insert' | 'delete', rect: DOMRect) { - // Remove any existing popover - this.shadow.querySelector('.suggestion-popover')?.remove(); - - const pop = document.createElement('div'); - pop.className = 'suggestion-popover'; - - const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect(); - pop.style.left = `${rect.left - hostRect.left}px`; - pop.style.top = `${rect.bottom - hostRect.top + 4}px`; - - pop.innerHTML = ` -
      - ${this.esc(authorName)} - ${type === 'insert' ? 'Added' : 'Deleted'} -
      -
      - - -
      - `; - - pop.querySelector('.sp-accept')!.addEventListener('click', () => { - if (this.editor) { - acceptSuggestion(this.editor, suggestionId); - this.updateSuggestionReviewBar(); - this.syncSuggestionsToPanel(true); - } - pop.remove(); - }); - pop.querySelector('.sp-reject')!.addEventListener('click', () => { - if (this.editor) { - rejectSuggestion(this.editor, suggestionId); - this.updateSuggestionReviewBar(); - this.syncSuggestionsToPanel(true); - } - pop.remove(); - }); - - this.shadow.appendChild(pop); - - // Close on click outside - const close = (e: Event) => { - if (!pop.contains((e as MouseEvent).target as Node)) { - pop.remove(); - this.shadow.removeEventListener('click', close); - } - }; - setTimeout(() => this.shadow.addEventListener('click', close), 0); - } - - /** Collect all pending suggestions from the editor doc. */ - private collectSuggestions(): { id: string; type: 'insert' | 'delete'; text: string; authorId: string; authorName: string; createdAt: number }[] { - if (!this.editor) return []; - const map = new Map(); - this.editor.state.doc.descendants((node: any) => { - if (!node.isText) return; - for (const mark of node.marks) { - if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') { - const id = mark.attrs.suggestionId; - const existing = map.get(id); - if (existing) { - existing.text += node.text || ''; - } else { - map.set(id, { - id, - type: mark.type.name === 'suggestionInsert' ? 'insert' : 'delete', - text: node.text || '', - authorId: mark.attrs.authorId || '', - authorName: mark.attrs.authorName || 'Unknown', - createdAt: mark.attrs.createdAt || Date.now(), - }); - } - } - } - }); - return Array.from(map.values()); - } - - /** Push current suggestions to the comment panel (debounced to avoid letter-by-letter flicker). */ - private syncSuggestionsToPanel(immediate = false) { - clearTimeout(this._suggestionSyncTimer); - const flush = () => { - const panel = this.shadow.querySelector('notes-comment-panel') as any; - if (!panel) return; - const suggestions = this.collectSuggestions(); - panel.suggestions = suggestions; - const sidebar = this.shadow.getElementById('comment-sidebar'); - if (sidebar && suggestions.length > 0) { - sidebar.classList.add('has-comments'); - } - }; - if (immediate) { - flush(); - } else { - this._suggestionSyncTimer = setTimeout(flush, 400); - } - } - - /** Show comment panel for a specific thread. */ - private showCommentPanel(threadId?: string) { - const sidebar = this.shadow.getElementById('comment-sidebar'); - if (!sidebar) return; - - let panel = this.shadow.querySelector('notes-comment-panel') as any; - if (!panel) { - panel = document.createElement('notes-comment-panel'); - sidebar.appendChild(panel); - // Listen for demo thread mutations from comment panel - panel.addEventListener('comment-demo-mutation', (e: CustomEvent) => { - const { noteId, threads } = e.detail; - if (noteId) this._demoThreads.set(noteId, threads); - }); - // Listen for suggestion accept/reject from comment panel - panel.addEventListener('suggestion-accept', (e: CustomEvent) => { - if (this.editor && e.detail?.suggestionId) { - acceptSuggestion(this.editor, e.detail.suggestionId); - this.updateSuggestionReviewBar(); - this.syncSuggestionsToPanel(true); - } - }); - panel.addEventListener('suggestion-reject', (e: CustomEvent) => { - if (this.editor && e.detail?.suggestionId) { - rejectSuggestion(this.editor, e.detail.suggestionId); - this.updateSuggestionReviewBar(); - this.syncSuggestionsToPanel(true); - } - }); - } - panel.noteId = this.editorNoteId; - panel.doc = this.doc; - panel.subscribedDocId = this.subscribedDocId; - panel.activeThreadId = threadId || null; - panel.editor = this.editor; - panel.space = this.space; - // Pass demo threads if in demo mode - if (this.space === 'demo' && this.editorNoteId) { - panel.demoThreads = this._demoThreads.get(this.editorNoteId) ?? null; - } else { - panel.demoThreads = null; - } - // Pass suggestions - panel.suggestions = this.collectSuggestions(); - - // Show sidebar when there are comments or suggestions - sidebar.classList.add('has-comments'); - } - - /** Hide comment sidebar when no comments exist. */ - private hideCommentPanel() { - const sidebar = this.shadow.getElementById('comment-sidebar'); - if (sidebar) sidebar.classList.remove('has-comments'); - } - - /** Wire click handling on comment highlights and suggestion marks in the editor. */ - private wireCommentHighlightClicks() { - if (!this.editor) return; - - // On selection change, check if cursor is inside a comment mark - this.editor.on('selectionUpdate', () => { - if (!this.editor) return; - const { $from } = this.editor.state.selection; - const commentMark = $from.marks().find(m => m.type.name === 'comment'); - if (commentMark) { - const threadId = commentMark.attrs.threadId; - if (threadId) this.showCommentPanel(threadId); - } - }); - - // On any change, update the suggestion review bar + sidebar panel (debounced) - this.editor.on('update', () => { - this.updateSuggestionReviewBar(); - this.syncSuggestionsToPanel(); // debounced — avoids letter-by-letter flicker - }); - - // Direct click on comment highlight or suggestion marks in the DOM - const container = this.shadow.getElementById('tiptap-container'); - if (container) { - container.addEventListener('click', (e) => { - const target = e.target as HTMLElement; - - // Comment highlights - const highlight = target.closest?.('.comment-highlight') as HTMLElement; - if (highlight) { - const threadId = highlight.getAttribute('data-thread-id'); - if (threadId) this.showCommentPanel(threadId); - return; - } - - // Suggestion marks — show accept/reject popover - const suggestionEl = target.closest?.('.suggestion-insert, .suggestion-delete') as HTMLElement; - if (suggestionEl) { - const suggestionId = suggestionEl.getAttribute('data-suggestion-id'); - const authorName = suggestionEl.getAttribute('data-author-name') || 'Unknown'; - const type = suggestionEl.classList.contains('suggestion-insert') ? 'insert' : 'delete'; - if (suggestionId) { - const rect = suggestionEl.getBoundingClientRect(); - this.showSuggestionPopover(suggestionId, authorName, type as 'insert' | 'delete', rect); - } - return; - } - }); - } - } - - private toggleDictation(btn: HTMLElement) { - if (this.dictation?.isRecording) { - this.dictation.stop(); - btn.classList.remove('recording'); - this.removeDictationPreview(); - return; - } - if (!this.dictation) { - this.dictation = new SpeechDictation({ - onInterim: (text) => { - this.updateDictationPreview(text); - }, - onFinal: (text) => { - this.removeDictationPreview(); - if (this.editor) { - this.editor.chain().focus().insertContent(text + ' ').run(); - } - }, - onStateChange: (recording) => { - btn.classList.toggle('recording', recording); - if (!recording) this.removeDictationPreview(); - }, - onError: (err) => { - console.warn('[Dictation]', err); - btn.classList.remove('recording'); - this.removeDictationPreview(); - }, - }); - } - this.dictation.start(); - } - - private updateDictationPreview(text: string) { - let preview = this.shadow.getElementById('dictation-preview'); - if (!preview) { - preview = document.createElement('div'); - preview.id = 'dictation-preview'; - preview.className = 'dictation-preview'; - const toolbar = this.shadow.getElementById('editor-toolbar'); - if (toolbar) toolbar.insertAdjacentElement('afterend', preview); - else return; - } - preview.textContent = text; - } - - private removeDictationPreview() { - this.shadow.getElementById('dictation-preview')?.remove(); - } - - private updateToolbarState() { - if (!this.editor) return; - const toolbar = this.shadow.getElementById('editor-toolbar'); - if (!toolbar) return; - - // Toggle active class on buttons - toolbar.querySelectorAll('.toolbar-btn[data-cmd]').forEach((btn) => { - const cmd = (btn as HTMLElement).dataset.cmd!; - let isActive = false; - switch (cmd) { - case 'bold': isActive = this.editor!.isActive('bold'); break; - case 'italic': isActive = this.editor!.isActive('italic'); break; - case 'underline': isActive = this.editor!.isActive('underline'); break; - case 'strike': isActive = this.editor!.isActive('strike'); break; - case 'code': isActive = this.editor!.isActive('code'); break; - case 'bulletList': isActive = this.editor!.isActive('bulletList'); break; - case 'orderedList': isActive = this.editor!.isActive('orderedList'); break; - case 'taskList': isActive = this.editor!.isActive('taskList'); break; - case 'blockquote': isActive = this.editor!.isActive('blockquote'); break; - case 'codeBlock': isActive = this.editor!.isActive('codeBlock'); break; - } - btn.classList.toggle('active', isActive); - }); - - // Update heading select - const headingSelect = toolbar.querySelector('[data-cmd="heading"]') as HTMLSelectElement; - if (headingSelect) { - if (this.editor.isActive('heading', { level: 1 })) headingSelect.value = '1'; - else if (this.editor.isActive('heading', { level: 2 })) headingSelect.value = '2'; - else if (this.editor.isActive('heading', { level: 3 })) headingSelect.value = '3'; - else if (this.editor.isActive('heading', { level: 4 })) headingSelect.value = '4'; - else headingSelect.value = 'paragraph'; - } - - // Update comment button state (active when text is selected) - const commentBtn = toolbar.querySelector('[data-cmd="comment"]') as HTMLElement; - if (commentBtn) { - commentBtn.classList.toggle('active', this.editor.isActive('comment')); - const { empty } = this.editor.state.selection; - commentBtn.style.opacity = empty ? '0.4' : '1'; - } - - // Update suggesting mode toggle - const suggestBtn = toolbar.querySelector('[data-cmd="toggleSuggesting"]') as HTMLElement; - if (suggestBtn) { - suggestBtn.classList.toggle('active', this.suggestingMode); - } - - // Update peers count - this.updatePeersIndicator(); - } - - private updatePeersIndicator() { - const peersEl = this.shadow.getElementById('collab-peers'); - const statusBar = this.shadow.getElementById('collab-status-bar'); - - if (!peersEl || !this.yjsProvider) { - if (peersEl) peersEl.style.display = 'none'; - if (statusBar) statusBar.style.display = 'none'; - return; - } - - const connected = this.yjsProvider.isConnected; - const states = this.yjsProvider.awareness.getStates(); - const peerCount = states.size - 1; // Exclude self - - // Toolbar peer dots - if (peerCount > 0) { - peersEl.style.display = 'inline-flex'; - peersEl.innerHTML = ''; - let shown = 0; - for (const [clientId, state] of states) { - if (clientId === this.ydoc?.clientID) continue; - if (shown >= 3) break; - const user = state.user || { name: '?', color: '#888' }; - const dot = document.createElement('span'); - dot.className = 'peer-dot'; - dot.style.backgroundColor = user.color; - dot.title = user.name; - peersEl.appendChild(dot); - shown++; - } - if (peerCount > 3) { - const more = document.createElement('span'); - more.className = 'peer-more'; - more.textContent = `+${peerCount - 3}`; - peersEl.appendChild(more); - } - } else { - peersEl.style.display = 'none'; - } - - // Collab status bar - if (statusBar) { - statusBar.style.display = 'flex'; - const dot = statusBar.querySelector('.collab-status-dot') as HTMLElement; - const text = statusBar.querySelector('.collab-status-text') as HTMLElement; - if (!dot || !text) return; - - if (!connected) { - dot.className = 'collab-status-dot offline'; - text.textContent = 'Offline \u2014 changes will sync when reconnected'; - } else if (peerCount > 0) { - dot.className = 'collab-status-dot live'; - const names: string[] = []; - for (const [clientId, state] of states) { - if (clientId === this.ydoc?.clientID) continue; - names.push(state.user?.name || 'Anonymous'); - } - text.textContent = `Live editing \u00B7 ${peerCount} collaborator${peerCount > 1 ? 's' : ''}`; - text.title = names.join(', '); - } else { - dot.className = 'collab-status-dot synced'; - text.textContent = 'Live sync enabled'; - text.title = ''; - } - } - - // Sidebar collab dot - const sidebarDot = this.navZone.querySelector('.sidebar-collab-dot') as HTMLElement; - if (sidebarDot) { - sidebarDot.className = `sidebar-collab-dot ${connected ? 'connected' : 'disconnected'}`; - } - } - - // ── Helpers ── - - private getNoteIcon(type: string): string { - switch (type) { - case "NOTE": return "\u{1F4DD}"; - case "CODE": return "\u{1F4BB}"; - case "BOOKMARK": return "\u{1F517}"; - case "IMAGE": return "\u{1F5BC}"; - case "AUDIO": return "\u{1F3A4}"; - case "FILE": return "\u{1F4CE}"; - case "CLIP": return "\u2702\uFE0F"; - default: return "\u{1F4C4}"; - } - } - - private formatDate(dateStr: string): string { - const d = new Date(dateStr); - const now = new Date(); - const diffMs = now.getTime() - d.getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - if (diffDays === 0) return "Today"; - if (diffDays === 1) return "Yesterday"; - if (diffDays < 7) return `${diffDays}d ago`; - return d.toLocaleDateString(); - } - - // ── Rendering ── - - private render() { - this.renderNav(); - if (this.selectedNote && this.editor && this.editorNoteId === this.selectedNote.id) { - // Editor already mounted — don't touch contentZone - } else { - this.renderContent(); - } - this.renderMeta(); - this.renderPresenceIndicators(); - this._tour.renderOverlay(); - } - - startTour() { this._tour.start(); } - - private renderNav() { - const isSearching = this.searchQuery.trim().length > 0; - - let treeHtml = ''; - if (isSearching && this.searchResults.length > 0) { - treeHtml = ``; - } else if (isSearching) { - treeHtml = '
      No results
      '; - } else { - treeHtml = this.notebooks.map(nb => { - const isExpanded = this.expandedNotebooks.has(nb.id); - const notes = this.notebookNotes.get(nb.id) || []; - return ` -
      -
      - \u25B6 - - ${this.esc(nb.title)} - ${nb.note_count} - -
      - ${isExpanded ? `
      - ${notes.length > 0 ? notes.map(n => ` -
      - ${this.getNoteIcon(n.type)} - ${this.esc(n.title)} - ${n.is_pinned ? '\u{1F4CC}' : ''} -
      - `).join('') : '
      No notes
      '} -
      ` : ''} -
      - `; - }).join(''); - } - - // Preserve search input focus/cursor position - const prevInput = this.navZone.querySelector('#search-input') as HTMLInputElement; - const hadFocus = prevInput && (prevInput === this.shadow.activeElement); - const selStart = prevInput?.selectionStart; - const selEnd = prevInput?.selectionEnd; - - this.navZone.innerHTML = ` -
      - - - - - - -
      - `; - - // Apply collapsed state - const layout = this.shadow.getElementById('notes-layout'); - if (layout) layout.classList.toggle('sidebar-collapsed', !this.sidebarOpen); - - // Restore search focus - if (hadFocus) { - const newInput = this.navZone.querySelector('#search-input') as HTMLInputElement; - if (newInput) { - newInput.focus(); - if (selStart !== null && selEnd !== null) { - newInput.setSelectionRange(selStart, selEnd); - } - } - } - - this.attachSidebarListeners(); - } - - private renderContent() { - if (this.error) { - this.contentZone.innerHTML = `
      ${this.esc(this.error)}
      `; - return; - } - if (this.loading) { - this.contentZone.innerHTML = '
      Loading...
      '; - return; - } - - // Empty state — no note selected - this.contentZone.innerHTML = ` -
      - - - - - - -

      Select a note from the sidebar

      - ${this.notebooks.length > 0 - ? '' - : ''} -
      - `; - - // Wire CTA button - this.contentZone.querySelector('#btn-empty-new-note')?.addEventListener('click', () => { - const nbId = this.selectedNotebook?.id || (this.notebooks.length > 0 ? this.notebooks[0].id : null); - if (nbId) this.addNoteToNotebook(nbId); - }); - this.contentZone.querySelector('#btn-empty-new-nb')?.addEventListener('click', () => { - this.space === 'demo' ? this.demoCreateNotebook() : this.createNotebook(); - }); - } - - private renderMeta() { - if (this.selectedNote) { - const n = this.selectedNote; - const isAutomerge = !!(this.doc?.items?.[n.id]); - const isDemo = this.space === "demo"; - - // Get summary from Automerge doc or local note object - const item = this.doc?.items?.[n.id]; - const summary = item?.summary || (n as any).summary || ''; - const summaryModel = item?.summaryModel || (n as any).summaryModel || ''; - const openNotebookSourceId = item?.openNotebookSourceId || (n as any).openNotebookSourceId || ''; - - this.metaZone.innerHTML = ` -
      - Type: ${n.type} - Created: ${this.formatDate(n.created_at)} - Updated: ${this.formatDate(n.updated_at)} - ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""} - ${isAutomerge ? 'Live' : ""} - ${isDemo ? 'Demo' : ""} -
      - ${summary ? ` -
      -
      - ${ICONS.summarize} - Summary - ${this.esc(summaryModel)} - - \u{25BC} -
      -
      ${this.esc(summary)}
      -
      ` : ''} - ${!isDemo ? ` -
      - - ${openNotebookSourceId ? 'Indexed' : ''} -
      ` : ''}`; - - // Attach summary panel event listeners - if (summary) { - const header = this.metaZone.querySelector('[data-action="toggle-summary"]'); - header?.addEventListener('click', (e) => { - if ((e.target as HTMLElement).closest('[data-action="regenerate-summary"]')) return; - const panel = this.metaZone.querySelector('.note-summary-panel'); - panel?.classList.toggle('collapsed'); - }); - const regen = this.metaZone.querySelector('[data-action="regenerate-summary"]'); - regen?.addEventListener('click', () => { - const btn = this.shadow.querySelector('[data-cmd="summarize"]') as HTMLElement; - if (btn) this.summarizeNote(btn); - }); - } - - // Send to Notebook button - const sendBtn = this.metaZone.querySelector('[data-action="send-to-notebook"]') as HTMLElement; - if (sendBtn && !openNotebookSourceId) { - sendBtn.addEventListener('click', () => this.sendToOpenNotebook()); - } - } else { - this.metaZone.innerHTML = ''; - } - } - - private toggleSidebar(open?: boolean) { - this.sidebarOpen = open !== undefined ? open : !this.sidebarOpen; - const layout = this.shadow.getElementById('notes-layout'); - if (layout) layout.classList.toggle('sidebar-collapsed', !this.sidebarOpen); - } - - private attachSidebarListeners() { - const isDemo = this.space === "demo"; - - // Sidebar collapse (reopen button is wired once in connectedCallback) - this.shadow.getElementById('sidebar-collapse')?.addEventListener('click', () => this.toggleSidebar(false)); - - // Search - const searchInput = this.shadow.getElementById("search-input") as HTMLInputElement; - let searchTimeout: any; - searchInput?.addEventListener("input", () => { - clearTimeout(searchTimeout); - this.searchQuery = searchInput.value; - searchTimeout = setTimeout(() => { - isDemo ? this.demoSearchNotes(this.searchQuery) : this.searchNotes(this.searchQuery); - }, 300); - }); - - // Create notebook - this.shadow.getElementById("create-notebook")?.addEventListener("click", () => { - isDemo ? this.demoCreateNotebook() : this.createNotebook(); - }); - - // Import / Export - this.shadow.getElementById("btn-import-export")?.addEventListener("click", () => { - this.openImportExportDialog(); - }); - - // Web Clipper download - this.shadow.getElementById("btn-web-clipper")?.addEventListener("click", () => { - window.open(`${this.getApiBase()}/extension/download`, '_blank'); - }); - - // Tour - this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour()); - - // Toggle notebooks - this.shadow.querySelectorAll("[data-toggle-notebook]").forEach(el => { - el.addEventListener("click", (e) => { - if ((e.target as HTMLElement).closest('[data-add-note]')) return; - const id = (el as HTMLElement).dataset.toggleNotebook!; - this.expandNotebook(id); + // Note selection + this._shadow.querySelectorAll('.note-item').forEach(el => { + el.addEventListener('click', () => { + const path = el.dataset.notePath; + if (path) this._selectNote(path); }); }); - // Add note to notebook (context menu on +) - this.shadow.querySelectorAll("[data-add-note]").forEach(el => { - el.addEventListener("click", (e) => { - e.stopPropagation(); - const nbId = (el as HTMLElement).dataset.addNote!; - this.showAddNoteMenu(nbId, el as HTMLElement); - }); + // Tree search + const treeSearch = $('tree-search') as HTMLInputElement | null; + treeSearch?.addEventListener('input', (e) => { + this._searchQuery = (e.target as HTMLInputElement).value; + this._render(); }); - // Click note in tree - this.shadow.querySelectorAll(".sbt-note[data-note]").forEach(el => { - el.addEventListener("click", () => { - const noteId = (el as HTMLElement).dataset.note!; - const nbId = (el as HTMLElement).dataset.notebook!; - this.openNote(noteId, nbId); - }); - }); - - // Click search result - this.shadow.querySelectorAll(".sidebar-search-result[data-note]").forEach(el => { - el.addEventListener("click", () => { - const noteId = (el as HTMLElement).dataset.note!; - const nbId = (el as HTMLElement).dataset.notebook!; - this.openNote(noteId, nbId); - }); - }); - - // Make sidebar notes draggable (cross-rApp + intra-sidebar) - makeDraggableAll(this.shadow, ".sbt-note[data-note]", (el) => { - const title = el.querySelector(".sbt-note-title")?.textContent || ""; - const id = el.dataset.note || ""; - return title ? { title, module: "rnotes", entityId: id, label: "Note", color: "#f59e0b" } : null; - }); - - // Also set native drag data for intra-sidebar notebook moves + cleanup on dragend - this.shadow.querySelectorAll(".sbt-note[data-note]").forEach(el => { - (el as HTMLElement).addEventListener("dragstart", (e) => { - const noteId = (el as HTMLElement).dataset.note!; - const nbId = (el as HTMLElement).dataset.notebook!; - e.dataTransfer?.setData("application/x-rnotes-move", JSON.stringify({ noteId, sourceNotebookId: nbId })); - (el as HTMLElement).style.opacity = "0.4"; - }); - (el as HTMLElement).addEventListener("dragend", () => { - (el as HTMLElement).style.opacity = ""; - this.shadow.querySelectorAll('.sbt-note').forEach(n => - (n as HTMLElement).classList.remove('drag-above', 'drag-below')); - }); - }); - - // Notebook headers accept dropped notes (cross-notebook move) - this.shadow.querySelectorAll(".sbt-notebook-header[data-toggle-notebook]").forEach(el => { - (el as HTMLElement).addEventListener("dragover", (e) => { - if (e.dataTransfer?.types.includes("application/x-rnotes-move")) { - e.preventDefault(); - (el as HTMLElement).classList.add("drop-target"); - } - }); - (el as HTMLElement).addEventListener("dragleave", () => { - (el as HTMLElement).classList.remove("drop-target"); - }); - (el as HTMLElement).addEventListener("drop", (e) => { + // Wikilinks in preview + this._shadow.querySelectorAll('a.wikilink').forEach(a => { + a.addEventListener('click', (e) => { e.preventDefault(); - (el as HTMLElement).classList.remove("drop-target"); - const raw = e.dataTransfer?.getData("application/x-rnotes-move"); - if (!raw) return; - try { - const { noteId, sourceNotebookId } = JSON.parse(raw); - const targetNotebookId = (el as HTMLElement).dataset.toggleNotebook!; - if (noteId && sourceNotebookId && targetNotebookId) { - this.moveNoteToNotebook(noteId, sourceNotebookId, targetNotebookId); - } - } catch {} + const target = a.dataset.target ?? ''; + const match = this._notes.find(n => + n.title.toLowerCase() === target.toLowerCase() || + n.path.toLowerCase().includes(target.toLowerCase()) + ); + if (match) this._selectNote(match.path); }); }); - - // Note items accept drops for intra-notebook reordering - this.shadow.querySelectorAll(".sbt-note[data-note]").forEach(el => { - const noteEl = el as HTMLElement; - noteEl.addEventListener("dragover", (e) => { - if (!e.dataTransfer?.types.includes("application/x-rnotes-move")) return; - e.preventDefault(); - e.stopPropagation(); - // Determine above/below based on cursor position - const rect = noteEl.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - // Clear all indicators in this container - noteEl.closest('.sbt-notes')?.querySelectorAll('.sbt-note').forEach(n => - (n as HTMLElement).classList.remove('drag-above', 'drag-below')); - noteEl.classList.add(e.clientY < midY ? 'drag-above' : 'drag-below'); - }); - noteEl.addEventListener("dragleave", (e) => { - // Only clear if leaving the element entirely (not entering a child) - if (noteEl.contains(e.relatedTarget as Node)) return; - noteEl.classList.remove('drag-above', 'drag-below'); - }); - noteEl.addEventListener("drop", (e) => { - e.preventDefault(); - e.stopPropagation(); - // Clear all indicators - this.shadow.querySelectorAll('.sbt-note').forEach(n => - (n as HTMLElement).classList.remove('drag-above', 'drag-below')); - const raw = e.dataTransfer?.getData("application/x-rnotes-move"); - if (!raw) return; - try { - const { noteId, sourceNotebookId } = JSON.parse(raw); - const targetNbId = noteEl.dataset.notebook!; - // Cross-notebook move — delegate to moveNoteToNotebook - if (sourceNotebookId !== targetNbId) { - this.moveNoteToNotebook(noteId, sourceNotebookId, targetNbId); - return; - } - // Same notebook — reorder - const notes = this.notebookNotes.get(targetNbId); - if (!notes) return; - const targetNoteId = noteEl.dataset.note!; - const targetIdx = notes.findIndex(n => n.id === targetNoteId); - if (targetIdx < 0) return; - // Determine final index based on above/below - const rect = noteEl.getBoundingClientRect(); - const insertAfter = e.clientY >= rect.top + rect.height / 2; - const srcIdx = notes.findIndex(n => n.id === noteId); - let finalIdx = insertAfter ? targetIdx + 1 : targetIdx; - // Adjust if dragging from before the target - if (srcIdx < finalIdx) finalIdx--; - this.reorderNote(noteId, targetNbId, finalIdx); - } catch {} - }); - }); - } - - private demoUpdateNoteField(noteId: string, field: string, value: string) { - if (this.selectedNote && this.selectedNote.id === noteId) { - (this.selectedNote as any)[field] = value; - this.selectedNote.updated_at = new Date().toISOString(); - } - for (const nb of this.demoNotebooks) { - const note = nb.notes.find(n => n.id === noteId); - if (note) { - (note as any)[field] = value; - note.updated_at = new Date().toISOString(); - break; - } - } - if (this.selectedNotebook?.notes) { - const note = this.selectedNotebook.notes.find(n => n.id === noteId); - if (note) { - (note as any)[field] = value; - note.updated_at = new Date().toISOString(); - } - } - } - - // ── Import/Export Dialog ── - - private importExportDialog: ImportExportDialog | null = null; - - private openImportExportDialog(tab: 'import' | 'export' | 'sync' = 'import') { - if (!this.importExportDialog) { - // Dynamically import the dialog component - import('./import-export-dialog').then(() => { - this.importExportDialog = document.createElement('import-export-dialog') as unknown as ImportExportDialog; - this.importExportDialog.setAttribute('space', this.space); - this.shadow.appendChild(this.importExportDialog); - - 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); - }); - } else { - this.showDialog(tab); - } - } - - private showDialog(tab: 'import' | 'export' | 'sync') { - if (!this.importExportDialog) return; - - // Gather notebook list for the dialog - const notebooks = this.notebooks.map(nb => ({ - id: nb.id, - title: nb.title, - })); - - this.importExportDialog.open(notebooks, tab); - } - - private esc(s: string): string { - const d = document.createElement("div"); - d.textContent = s || ""; - return d.innerHTML; - } - - private getStyles(): string { - return ` - :host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); -webkit-tap-highlight-color: transparent; } - * { box-sizing: border-box; } - button, a, input, select, textarea, [role="button"] { touch-action: manipulation; } - - /* ── Sidebar Layout ── */ - #notes-layout { - display: grid; - grid-template-columns: 260px 1fr; - min-height: 400px; - height: 100%; - position: relative; - transition: grid-template-columns 0.2s ease; - } - #notes-layout.sidebar-collapsed { - grid-template-columns: 0px 1fr; - } - #notes-layout.sidebar-collapsed .notes-sidebar { - opacity: 0; - pointer-events: none; - } - #notes-layout.sidebar-collapsed .sidebar-reopen { - opacity: 1; - pointer-events: auto; - } - #nav-zone { overflow: visible; } - #notes-layout.sidebar-collapsed #nav-zone { overflow: hidden; } - .notes-sidebar { - display: flex; - flex-direction: column; - border-right: 1px solid var(--rs-border-subtle); - background: var(--rs-bg-surface); - overflow: visible; - height: 100%; - position: relative; - transition: opacity 0.15s ease; - } - /* Collapse button — right edge, vertically centered on sidebar */ - .sidebar-collapse { - position: absolute; right: -12px; top: 50%; transform: translateY(-50%); - width: 20px; height: 48px; z-index: 10; - border: 1px solid var(--rs-border-subtle, #333); - border-radius: 0 6px 6px 0; - background: var(--rs-bg-surface, #1e1e2e); - color: var(--rs-text-muted, #888); - font-size: 16px; line-height: 1; - cursor: pointer; display: flex; align-items: center; justify-content: center; - transition: color 0.15s, border-color 0.15s, background 0.15s; - } - .sidebar-collapse:hover { - color: var(--rs-text-primary); - border-color: var(--rs-primary, #6366f1); - background: var(--rs-bg-hover, #252538); - } - /* Reopen tab on left edge */ - .sidebar-reopen { - position: absolute; left: 0; top: 50%; transform: translateY(-50%); - width: 20px; height: 48px; z-index: 10; - border: 1px solid var(--rs-border-subtle, #333); - border-left: none; - border-radius: 0 6px 6px 0; - background: var(--rs-bg-surface, #1e1e2e); - color: var(--rs-text-muted, #888); - font-size: 18px; line-height: 1; - cursor: pointer; display: flex; align-items: center; justify-content: center; - opacity: 0; pointer-events: none; - transition: opacity 0.15s ease, color 0.15s, background 0.15s; - } - .sidebar-reopen:hover { - color: var(--rs-text-primary); - background: var(--rs-bg-hover, #252538); - } - .sidebar-header { padding: 12px 12px 8px; } - .sidebar-search { - width: 100%; padding: 8px 12px; border-radius: 6px; - border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); - color: var(--rs-input-text); font-size: 13px; font-family: inherit; - transition: border-color 0.15s; - } - .sidebar-search:focus { border-color: var(--rs-primary); outline: none; } - .sidebar-tree { flex: 1; overflow-y: auto; padding: 4px 0; } - .sidebar-btn-new-nb { - display: flex; align-items: center; justify-content: center; gap: 6px; - width: calc(100% - 24px); margin: 0 12px 8px; padding: 7px; - border-radius: 6px; border: 1px dashed var(--rs-border); - background: transparent; color: var(--rs-text-secondary); - font-size: 13px; font-family: inherit; cursor: pointer; transition: all 0.15s; - } - .sidebar-btn-new-nb:hover { border-color: var(--rs-primary); color: var(--rs-primary); background: rgba(99, 102, 241, 0.05); } - - /* Notebook tree */ - .sbt-notebook-header { - display: flex; align-items: center; gap: 6px; - padding: 6px 12px; cursor: pointer; user-select: none; - transition: background 0.1s; font-size: 13px; - } - .sbt-notebook-header:hover { background: var(--rs-bg-hover); } - .sbt-notebook-header.drop-target { background: rgba(99, 102, 241, 0.15); border: 1px dashed var(--rs-primary, #6366f1); border-radius: 4px; } - .sbt-toggle { - width: 16px; text-align: center; font-size: 10px; - color: var(--rs-text-muted); flex-shrink: 0; - transition: transform 0.15s; - } - .sbt-toggle.expanded { transform: rotate(90deg); } - .sbt-nb-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } - .sbt-nb-title { - flex: 1; font-weight: 500; color: var(--rs-text-primary); - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - } - .sbt-nb-count { font-size: 11px; color: var(--rs-text-muted); flex-shrink: 0; } - .sbt-nb-add { - opacity: 0; border: none; background: none; - color: var(--rs-text-muted); cursor: pointer; - font-size: 16px; line-height: 1; padding: 0 4px; - border-radius: 3px; transition: all 0.15s; flex-shrink: 0; - } - .sbt-notebook-header:hover .sbt-nb-add { opacity: 1; } - .sbt-nb-add:hover { color: var(--rs-primary); background: var(--rs-bg-surface-raised); } - @media (pointer: coarse) { - .sbt-nb-add { opacity: 0.6; } - .sbt-nb-add:active { opacity: 1; color: var(--rs-primary); } - } - .sbt-notes { padding-left: 20px; } - .sbt-note { - display: flex; align-items: center; gap: 8px; - padding: 5px 12px 5px 8px; cursor: pointer; - font-size: 13px; color: var(--rs-text-secondary); - border-radius: 4px; margin: 1px 8px 1px 0; - transition: all 0.1s; overflow: hidden; - } - .sbt-note:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); } - .sbt-note.active { background: var(--rs-primary); color: #fff; } - .sbt-note-icon { font-size: 14px; flex-shrink: 0; } - .sbt-note-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .sbt-note-pin { font-size: 10px; flex-shrink: 0; } - .sbt-note.drag-above { box-shadow: 0 -2px 0 0 var(--rs-primary, #6366f1); } - .sbt-note.drag-below { box-shadow: 0 2px 0 0 var(--rs-primary, #6366f1); } - - .sidebar-footer { - padding: 8px 12px; border-top: 1px solid var(--rs-border-subtle); - display: flex; gap: 6px; flex-wrap: wrap; - } - .sidebar-footer-btn { - padding: 5px 10px; border-radius: 5px; - border: 1px solid var(--rs-border); background: transparent; - color: var(--rs-text-secondary); font-size: 11px; - font-family: inherit; cursor: pointer; transition: all 0.15s; - } - .sidebar-footer-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); } - - /* Add-note context menu */ - .add-note-menu { - position: absolute; z-index: 100; - background: var(--rs-surface, #fff); border: 1px solid var(--rs-border); - border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); - padding: 4px; min-width: 130px; - } - .add-note-menu-item { - display: block; width: 100%; padding: 6px 10px; - border: none; background: transparent; text-align: left; - font-size: 12px; font-family: inherit; cursor: pointer; - color: var(--rs-text-primary); border-radius: 4px; - } - .add-note-menu-item:hover { background: var(--rs-surface-hover, #f3f4f6); } - - /* Sidebar collab info */ - .sidebar-collab-info { - display: flex; align-items: center; gap: 6px; - padding: 6px 12px; font-size: 11px; color: var(--rs-text-muted); - border-top: 1px solid var(--rs-border-subtle); cursor: default; - } - .sidebar-collab-dot { - width: 7px; height: 7px; border-radius: 50%; - flex-shrink: 0; background: #9ca3af; - } - .sidebar-collab-dot.connected { background: #22c55e; } - .sidebar-collab-dot.disconnected { background: #9ca3af; } - - /* Sidebar search results */ - .sidebar-search-results { padding: 4px 0; } - .sidebar-search-result { - display: flex; align-items: center; gap: 8px; - padding: 6px 12px; cursor: pointer; font-size: 13px; - color: var(--rs-text-secondary); transition: background 0.1s; - } - .sidebar-search-result:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); } - .sidebar-search-result-nb { font-size: 10px; color: var(--rs-text-muted); margin-left: auto; } - - /* Right column */ - .notes-right-col { - display: flex; flex-direction: column; overflow: hidden; height: 100%; - } - .notes-right-col #content-zone { flex: 1; overflow-y: auto; padding: 20px; } - .notes-right-col #meta-zone { padding: 0 20px 12px; } - - /* ── Google Docs-like comment sidebar layout ── */ - .editor-with-comments { - display: flex; - gap: 0; - min-height: 100%; - } - .editor-with-comments > .editor-wrapper { - flex: 1; - min-width: 0; - } - .comment-sidebar { - width: 0; - overflow: hidden; - transition: width 0.2s ease; - flex-shrink: 0; - } - .comment-sidebar.has-comments { - width: 280px; - overflow-y: auto; - border-left: 1px solid var(--rs-border, #e5e7eb); - } - @media (max-width: 768px) { - /* Comment sidebar → bottom sheet on all mobile */ - .editor-with-comments { flex-direction: column; } - .comment-sidebar.has-comments { - width: 100%; border-left: none; - border-top: 2px solid var(--rs-border, #e5e7eb); - max-height: 250px; max-height: 40dvh; - min-height: 120px; overflow-y: auto; - border-radius: 12px 12px 0 0; padding-top: 4px; - } - .comment-sidebar.has-comments::before { - content: ''; display: block; width: 32px; height: 4px; - background: var(--rs-border-strong, #d1d5db); border-radius: 2px; - margin: 0 auto 4px; - } - } - - /* Empty state */ - .editor-empty-state { - display: flex; flex-direction: column; align-items: center; - justify-content: center; height: 100%; min-height: 300px; - color: var(--rs-text-muted); gap: 12px; - } - .editor-empty-state svg { width: 48px; height: 48px; opacity: 0.4; } - .editor-empty-state p { font-size: 14px; } - - /* Mobile sidebar (legacy — hidden, replaced by stack nav) */ - .mobile-sidebar-toggle { display: none; } - .sidebar-overlay { display: none; } - - /* ── Navigation ── */ - .rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; transition: background 0.15s; display: flex; align-items: center; gap: 4px; } - .rapp-nav__btn:hover { background: var(--rs-primary-hover); } - - /* ── Presence Indicators ── */ - .presence-dots { display: inline-flex; gap: 2px; align-items: center; margin-left: auto; } - .presence-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; border: 1px solid var(--rs-bg-surface, #fff); flex-shrink: 0; } - .presence-dot-more { font-size: 10px; color: var(--rs-text-muted); margin-left: 2px; } - - /* ── Code Editor ── */ - .code-editor-controls { padding: 4px 12px; display: flex; gap: 8px; align-items: center; } - .code-textarea { - width: 100%; min-height: 400px; padding: 16px 20px; border: none; outline: none; - font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; - font-size: 13px; line-height: 1.6; tab-size: 4; resize: vertical; - background: var(--rs-bg-surface-sunken); color: var(--rs-text-primary); - } - - /* ── Bookmark / Clip View ── */ - .bookmark-card { - display: flex; gap: 12px; align-items: center; - padding: 12px 16px; margin: 0 12px 8px; - background: var(--rs-bg-surface-raised); border-radius: 8px; - } - .bookmark-favicon { border-radius: 4px; flex-shrink: 0; } - .bookmark-info { flex: 1; min-width: 0; } - .bookmark-url { color: var(--rs-primary); font-size: 13px; text-decoration: none; } - .bookmark-url:hover { text-decoration: underline; } - .bookmark-url-input-row { margin-top: 4px; } - .bookmark-url-input { - width: 100%; padding: 6px 10px; border-radius: 6px; - border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); - color: var(--rs-input-text); font-size: 12px; font-family: inherit; - } - - /* ── Image View ── */ - .image-display { padding: 12px 16px; text-align: center; } - .image-preview { max-width: 100%; max-height: 500px; border-radius: 8px; border: 1px solid var(--rs-border-subtle); } - .image-upload-placeholder { padding: 16px; } - - /* ── Audio View ── */ - .audio-player-container { - display: flex; gap: 12px; align-items: center; - padding: 12px 16px; margin: 0 12px; - } - .audio-player { flex: 1; max-width: 100%; height: 40px; } - .audio-duration { font-size: 13px; color: var(--rs-text-muted); font-weight: 500; } - .audio-record-placeholder { padding: 24px; text-align: center; } - .audio-recording-ui { text-align: left; } - .audio-recording-header { - display: flex; align-items: center; gap: 12px; margin-bottom: 8px; - } - .recording-pulse-sm { - width: 12px; height: 12px; border-radius: 50%; - background: var(--rs-error, #ef4444); animation: pulse-recording 1.5s infinite; - } - .audio-recording-timer { - font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; - color: var(--rs-text-primary); - } - .audio-live-segments { - max-height: 200px; overflow-y: auto; padding: 4px 0; - } - .transcript-segment { - display: flex; gap: 8px; padding: 3px 0; font-size: 14px; line-height: 1.6; - } - .transcript-segment.interim { font-style: italic; color: var(--rs-text-muted); } - .segment-time { - flex-shrink: 0; font-family: 'JetBrains Mono', 'Fira Code', monospace; - font-size: 12px; color: var(--rs-text-muted); padding-top: 2px; - } - .segment-text { flex: 1; } - .audio-transcript-section { padding: 0 4px; } - .audio-transcript-label { - font-size: 12px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.05em; color: var(--rs-text-muted); - padding: 8px 16px 0; - } - - /* ── Editor Title ── */ - .editable-title { - background: transparent; border: none; border-bottom: 2px solid transparent; - color: var(--rs-text-primary); font-family: inherit; - font-size: 22px; font-weight: 700; width: 100%; outline: none; - padding: 8px 0; margin-bottom: 4px; transition: border-color 0.15s; - } - .editable-title:focus { border-bottom-color: var(--rs-primary); } - .editable-title::placeholder { color: var(--rs-text-muted); } - - /* ── Sync Badge ── */ - .sync-badge { - display: inline-block; width: 8px; height: 8px; border-radius: 50%; - margin-left: 8px; vertical-align: middle; - } - .sync-badge.connected { background: var(--rs-success); } - .sync-badge.disconnected { background: var(--rs-error); } - - /* ── State Messages ── */ - .empty { text-align: center; color: var(--rs-text-muted); padding: 40px; } - .loading { text-align: center; color: var(--rs-text-secondary); padding: 40px; } - .error { text-align: center; color: var(--rs-error); padding: 20px; } - - /* ── Meta Bar ── */ - .note-meta-bar { - margin-top: 12px; font-size: 12px; color: var(--rs-text-muted); - display: flex; gap: 12px; padding: 8px 0; align-items: center; - } - .meta-live { color: var(--rs-success); font-weight: 500; } - .meta-demo { color: var(--rs-warning); font-weight: 500; } - - /* ── Summary Panel ── */ - .note-summary-panel { - margin-top: 8px; border: 1px solid var(--rs-border-subtle); - border-radius: 8px; background: var(--rs-bg-surface); overflow: hidden; - } - .note-summary-header { - display: flex; align-items: center; gap: 6px; padding: 8px 12px; - font-size: 12px; font-weight: 500; color: var(--rs-text-secondary); - cursor: pointer; user-select: none; - } - .note-summary-header:hover { background: var(--rs-bg-surface-raised); } - .note-summary-icon { display: flex; align-items: center; } - .note-summary-icon svg { width: 14px; height: 14px; } - .note-summary-model { - font-size: 10px; color: var(--rs-text-muted); background: var(--rs-bg-surface-raised); - padding: 1px 6px; border-radius: 3px; margin-left: auto; - } - .note-summary-regen { - background: none; border: none; color: var(--rs-text-muted); cursor: pointer; - font-size: 14px; padding: 0 4px; border-radius: 3px; transition: all 0.15s; - } - .note-summary-regen:hover { color: var(--rs-primary); background: var(--rs-bg-surface-raised); } - .note-summary-chevron { font-size: 8px; color: var(--rs-text-muted); transition: transform 0.2s; } - .note-summary-body { - padding: 8px 12px 12px; font-size: 13px; line-height: 1.6; - color: var(--rs-text-primary); border-top: 1px solid var(--rs-border-subtle); - white-space: pre-wrap; - } - .note-summary-panel.collapsed .note-summary-body { display: none; } - .note-summary-panel.collapsed .note-summary-chevron { transform: rotate(-90deg); } - - /* ── Note Actions Bar ── */ - .note-actions-bar { - display: flex; align-items: center; gap: 8px; margin-top: 8px; - } - .note-action-btn { - display: flex; align-items: center; gap: 6px; - padding: 6px 14px; border-radius: 6px; border: 1px solid var(--rs-border); - background: transparent; color: var(--rs-text-secondary); font-size: 12px; - font-weight: 500; cursor: pointer; transition: all 0.15s; font-family: inherit; - } - .note-action-btn:hover:not(:disabled) { border-color: var(--rs-primary); color: var(--rs-primary); } - .note-action-btn:disabled { opacity: 0.6; cursor: default; } - .note-action-btn svg { flex-shrink: 0; } - .note-action-badge { - display: inline-flex; align-items: center; gap: 4px; - padding: 3px 10px; border-radius: 12px; - background: rgba(34, 197, 94, 0.15); - background: color-mix(in srgb, var(--rs-success, #22c55e) 15%, transparent); - color: var(--rs-success, #22c55e); font-size: 11px; font-weight: 600; - } - - /* ── Editor Toolbar ── */ - .editor-toolbar { - display: flex; flex-wrap: wrap; gap: 2px; align-items: center; - background: var(--rs-toolbar-bg); border: 1px solid var(--rs-toolbar-panel-border); - border-radius: 8px; padding: 4px 6px; margin-bottom: 2px; - } - .toolbar-group { display: flex; gap: 1px; } - .toolbar-sep { width: 1px; height: 20px; background: var(--rs-toolbar-sep); margin: 0 4px; } - .toolbar-btn { - display: flex; align-items: center; justify-content: center; - width: 30px; height: 28px; border: none; border-radius: 4px; - background: transparent; color: var(--rs-text-secondary); cursor: pointer; - font-size: 13px; font-family: inherit; transition: all 0.15s; - } - .toolbar-btn svg { width: 16px; height: 16px; flex-shrink: 0; } - .toolbar-btn:hover { background: var(--rs-toolbar-btn-hover); color: var(--rs-toolbar-btn-text); } - .toolbar-btn.active { background: var(--rs-primary); color: #fff; } - .toolbar-btn.recording { background: var(--rs-error, #ef4444); color: #fff; animation: pulse-recording 1.5s infinite; } - @keyframes pulse-recording { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } - - /* ── Dictation Preview ── */ - .dictation-preview { - padding: 6px 12px; margin: 0 8px 2px; border-radius: 6px; - background: var(--rs-bg-surface-raised); color: var(--rs-text-muted); - font-size: 13px; font-style: italic; line-height: 1.5; - animation: dictation-pulse 2s ease-in-out infinite; - border-left: 3px solid var(--rs-error, #ef4444); - } - @keyframes dictation-pulse { - 0%, 100% { background: var(--rs-bg-surface-raised); } - 50% { background: rgba(239, 68, 68, 0.08); background: color-mix(in srgb, var(--rs-error, #ef4444) 8%, var(--rs-bg-surface-raised)); } - } - .toolbar-select { - padding: 2px 4px; border-radius: 4px; border: 1px solid var(--rs-toolbar-panel-border); - background: var(--rs-toolbar-bg); color: var(--rs-text-secondary); font-size: 12px; cursor: pointer; - font-family: inherit; transition: border-color 0.15s; - } - .toolbar-select:focus { outline: none; border-color: var(--rs-primary); } - - /* ── Tiptap Editor ── */ - .editor-wrapper { - background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); - border-radius: 10px; overflow: hidden; - } - .editor-wrapper .editable-title { padding: 16px 20px 0; } - .editor-wrapper .editor-toolbar { margin: 4px 8px; border-radius: 6px; } - - .tiptap-container .tiptap { - min-height: 300px; padding: 20px 24px; outline: none; - font-size: 15px; line-height: 1.75; color: var(--rs-text-primary); - } - .tiptap-container .tiptap:focus { outline: none; } - - /* ── Prose Styles ── */ - .tiptap-container .tiptap h1 { - font-size: 1.75em; font-weight: 700; margin: 1.2em 0 0.5em; color: var(--rs-text-primary); - padding-bottom: 0.3em; border-bottom: 1px solid var(--rs-border-subtle); - } - .tiptap-container .tiptap h2 { font-size: 1.35em; font-weight: 600; margin: 1em 0 0.4em; color: var(--rs-text-primary); } - .tiptap-container .tiptap h3 { font-size: 1.1em; font-weight: 600; margin: 0.8em 0 0.3em; color: var(--rs-text-secondary); } - .tiptap-container .tiptap h4 { - font-size: 0.95em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - margin: 0.7em 0 0.25em; color: var(--rs-text-muted); - } - .tiptap-container .tiptap p { margin: 0.5em 0; } - .tiptap-container .tiptap blockquote { - border-left: 3px solid var(--rs-primary); padding: 4px 0 4px 16px; margin: 0.8em 0; - color: var(--rs-text-secondary); font-style: italic; - background: var(--rs-bg-surface-raised); border-radius: 0 6px 6px 0; - } - .tiptap-container .tiptap code { - background: var(--rs-bg-surface-raised); padding: 2px 6px; border-radius: 4px; - font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.88em; color: var(--rs-accent); - } - .tiptap-container .tiptap pre { - background: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border-subtle); - border-radius: 8px; padding: 14px 16px; margin: 1em 0; overflow-x: auto; - } - .tiptap-container .tiptap pre code { - background: none; padding: 0; border-radius: 0; color: var(--rs-text-primary); - font-size: 13px; line-height: 1.6; - } - .tiptap-container .tiptap ul, .tiptap-container .tiptap ol { padding-left: 24px; margin: 0.5em 0; } - .tiptap-container .tiptap li { margin: 0.2em 0; } - .tiptap-container .tiptap li p { margin: 0.15em 0; } - .tiptap-container .tiptap li::marker { color: var(--rs-text-muted); } - - /* Task list */ - .tiptap-container .tiptap ul[data-type="taskList"] { list-style: none; padding-left: 4px; } - .tiptap-container .tiptap ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 8px; } - .tiptap-container .tiptap ul[data-type="taskList"] li label { margin-top: 3px; } - .tiptap-container .tiptap ul[data-type="taskList"] li[data-checked="true"] > div > p { - text-decoration: line-through; color: var(--rs-text-muted); - } - .tiptap-container .tiptap ul[data-type="taskList"] li label input[type="checkbox"] { - accent-color: var(--rs-primary); width: 15px; height: 15px; - } - - .tiptap-container .tiptap img { - max-width: 100%; border-radius: 8px; margin: 0.75em 0; - border: 1px solid var(--rs-border-subtle); - } - .tiptap-container .tiptap a { - color: var(--rs-primary-hover); text-decoration: underline; - text-underline-offset: 2px; text-decoration-color: rgba(99, 102, 241, 0.4); - } - .tiptap-container .tiptap a:hover { text-decoration-color: var(--rs-primary-hover); } - .tiptap-container .tiptap hr { border: none; border-top: 1px solid var(--rs-border); margin: 1.5em 0; } - .tiptap-container .tiptap strong { color: var(--rs-text-primary); font-weight: 600; } - .tiptap-container .tiptap em { color: inherit; } - .tiptap-container .tiptap s { color: var(--rs-text-muted); } - .tiptap-container .tiptap u { text-underline-offset: 3px; } - - /* ── Mobile back bar (hidden on desktop) ── */ - .mobile-back-bar { display: none; } - @media (max-width: 768px) { - /* Two-screen horizontal stack: nav (100%) + editor (100%) side-by-side */ - #notes-layout { - display: flex; overflow: hidden; - grid-template-columns: unset; - } - #nav-zone, .notes-right-col { - flex: 0 0 100%; min-width: 0; - transition: transform 0.3s ease; - } - /* Slide both panels left when editing */ - #notes-layout.mobile-editing > #nav-zone, - #notes-layout.mobile-editing > .notes-right-col { - transform: translateX(-100%); - } - /* Sidebar fills screen width */ - .notes-sidebar { width: 100%; position: static; transform: none; box-shadow: none; } - /* Hide old overlay FAB + desktop collapse on mobile */ - .mobile-sidebar-toggle, .sidebar-overlay, .sidebar-collapse, .sidebar-reopen { display: none !important; } - /* Hide empty state on mobile — user sees doc list */ - .editor-empty-state { display: none; } - /* Show back bar */ - .mobile-back-bar { - display: flex; align-items: center; - padding: 8px 12px; border-bottom: 1px solid var(--rs-border-subtle); - background: var(--rs-bg-surface); - } - .mobile-back-btn { - background: none; border: none; color: var(--rs-primary); - font-size: 15px; font-weight: 600; cursor: pointer; - padding: 4px 8px; border-radius: 6px; font-family: inherit; - } - .mobile-back-btn:hover { background: var(--rs-bg-surface-raised); } - /* Tighten editor padding */ - .editor-wrapper .editable-title { padding: 12px 14px 0; } - .tiptap-container .tiptap { padding: 14px 16px; } - .sidebar-footer-btn { min-height: 36px; padding: 7px 12px; } - /* Toolbar: scroll horizontally, bigger touch targets */ - .editor-toolbar { padding: 3px 4px; gap: 1px; overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; } - .toolbar-btn { width: 36px; height: 36px; } - .toolbar-sep { display: none; } - /* Collab tools first so comment/suggest are visible without scrolling */ - .collab-tools { order: -1; border-right: 1px solid var(--rs-toolbar-sep, #e5e7eb); padding-right: 6px; margin-right: 2px; } - /* Suggestion review bar: allow wrapping */ - .suggestion-review-bar { flex-wrap: wrap; height: auto; min-height: 32px; padding: 4px 8px; gap: 6px; } - } - @media (max-width: 480px) { - .rapp-nav__btn { padding: 5px 10px; font-size: 12px; } - .editable-title { font-size: 18px; } - .tiptap-container .tiptap { font-size: 14px; padding: 12px 14px; min-height: 200px; } - .code-textarea { min-height: 200px; } - .image-preview { max-height: 240px; } - .note-actions-bar { flex-wrap: wrap; gap: 6px; } - .note-action-btn { padding: 5px 10px; font-size: 11px; } - } - - /* Placeholder */ - .tiptap-container .tiptap p.is-editor-empty:first-child::before { - content: attr(data-placeholder); - float: left; color: var(--rs-text-muted); pointer-events: none; height: 0; - font-style: italic; - } - - /* ── URL Popover ── */ - .url-popover { - position: absolute; z-index: 110; - background: var(--rs-bg-surface); border: 1px solid var(--rs-border); - border-radius: 10px; box-shadow: var(--rs-shadow-md); - padding: 8px; min-width: 300px; - animation: popover-in 0.15s ease-out; - } - @keyframes popover-in { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } - } - .url-popover__input { - width: 100%; padding: 8px 10px; border-radius: 6px; - border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); - color: var(--rs-input-text); font-size: 13px; font-family: inherit; - outline: none; margin-bottom: 6px; transition: border-color 0.15s; - } - .url-popover__input:focus { border-color: var(--rs-primary); } - .url-popover__actions { display: flex; gap: 6px; justify-content: flex-end; } - .url-popover__btn { - padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600; - cursor: pointer; border: none; transition: all 0.15s; - } - .url-popover__btn--insert { background: var(--rs-primary); color: #fff; } - .url-popover__btn--insert:hover { background: var(--rs-primary-hover); } - .url-popover__btn--cancel { - background: transparent; color: var(--rs-text-secondary); - border: 1px solid var(--rs-border); - } - .url-popover__btn--cancel:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); } - @media (max-width: 640px) { - .url-popover { - position: fixed; left: 8px !important; right: 8px !important; - top: auto !important; bottom: max(env(safe-area-inset-bottom), 8px); - min-width: 0; width: auto; border-radius: 12px 12px 0 0; - } - } - - /* ── Slash Menu ── */ - .slash-menu { - position: absolute; z-index: 100; - background: var(--rs-bg-surface); border: 1px solid var(--rs-border); - border-radius: 10px; box-shadow: var(--rs-shadow-lg); - max-height: 360px; overflow-y: auto; min-width: 240px; - display: none; - } - .slash-menu__header { - padding: 8px 12px 6px; font-size: 11px; font-weight: 600; - text-transform: uppercase; letter-spacing: 0.05em; - color: var(--rs-text-muted); border-bottom: 1px solid var(--rs-border-subtle); - } - .slash-menu-item { - display: flex; align-items: center; gap: 10px; - padding: 8px 12px; cursor: pointer; transition: background 0.1s; - } - .slash-menu-item:last-child { border-radius: 0 0 10px 10px; } - .slash-menu-item:hover, .slash-menu-item.selected { background: var(--rs-bg-hover); } - .slash-menu-icon { - width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; - background: var(--rs-bg-surface-raised); border-radius: 6px; - font-size: 13px; font-weight: 600; color: var(--rs-primary); - flex-shrink: 0; - } - .slash-menu-icon svg { width: 16px; height: 16px; } - .slash-menu-text { flex: 1; } - .slash-menu-title { font-size: 13px; font-weight: 500; color: var(--rs-text-primary); } - .slash-menu-desc { font-size: 11px; color: var(--rs-text-muted); } - .slash-menu-hint { - font-size: 10px; color: var(--rs-text-muted); padding: 1px 6px; - background: var(--rs-bg-surface-raised); border-radius: 3px; margin-left: auto; - } - @media (max-width: 480px) { - .slash-menu { min-width: 200px; max-height: 260px; } - .slash-menu-item { padding: 10px 12px; } - .slash-menu-desc { display: none; } - .slash-menu-hint { display: none; } - } - - /* ── Code highlighting (lowlight) ── */ - .tiptap-container .tiptap .hljs-keyword { color: #c792ea; } - .tiptap-container .tiptap .hljs-string { color: #c3e88d; } - .tiptap-container .tiptap .hljs-number { color: #f78c6c; } - .tiptap-container .tiptap .hljs-comment { color: #546e7a; font-style: italic; } - .tiptap-container .tiptap .hljs-built_in { color: #82aaff; } - .tiptap-container .tiptap .hljs-function { color: #82aaff; } - .tiptap-container .tiptap .hljs-title { color: #82aaff; } - .tiptap-container .tiptap .hljs-attr { color: #ffcb6b; } - .tiptap-container .tiptap .hljs-tag { color: #f07178; } - .tiptap-container .tiptap .hljs-type { color: #ffcb6b; } - - /* ── Collaboration: Remote Cursors ── */ - .collab-cursor { - position: relative; - border-left: 2px solid; - margin-left: -1px; - margin-right: -1px; - pointer-events: none; - } - .collab-cursor-label { - position: absolute; - top: -1.4em; - left: -1px; - font-size: 10px; - font-weight: 600; - color: #fff; - padding: 1px 5px; - border-radius: 3px 3px 3px 0; - white-space: nowrap; - pointer-events: none; - user-select: none; - line-height: 1.4; - } - /* y-prosemirror remote selection highlight */ - .ProseMirror .yRemoteSelection { - background-color: var(--user-color, rgba(99, 102, 241, 0.2)); - } - .ProseMirror .yRemoteSelectionHead { - position: absolute; - border-left: 2px solid var(--user-color, #6366f1); - height: 1.2em; - } - - /* ── Collaboration: Status Bar ── */ - .collab-status-bar { - display: none; align-items: center; gap: 8px; - padding: 4px 16px; height: 28px; - background: var(--rs-bg-hover, rgba(0,0,0,0.03)); - border-top: 1px solid var(--rs-border-subtle); - border-bottom: 1px solid var(--rs-border-subtle); - font-size: 12px; color: var(--rs-text-muted); - } - .collab-status-dot { - width: 8px; height: 8px; border-radius: 50%; - flex-shrink: 0; - background: #9ca3af; - } - .collab-status-dot.live { background: #22c55e; } - .collab-status-dot.synced { background: #3b82f6; } - .collab-status-dot.offline { background: #9ca3af; } - .collab-status-text { white-space: nowrap; } - - /* ── Collaboration: Peers Indicator ── */ - .collab-tools { display: flex; align-items: center; gap: 4px; } - .collab-peers { - display: none; - align-items: center; - gap: 2px; - margin-left: 4px; - } - .peer-dot { - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; - border: 1px solid var(--rs-bg-surface, #fff); - } - .peer-more { - font-size: 10px; - color: var(--rs-text-muted); - margin-left: 2px; - } - - /* ── Collaboration: Comment Highlights (Google Docs style) ── */ - .tiptap-container .tiptap .comment-highlight { - background: rgba(251, 188, 4, 0.2); - border-bottom: 2px solid rgba(251, 188, 4, 0.5); - cursor: pointer; - transition: background 0.15s; - border-radius: 1px; - } - .tiptap-container .tiptap .comment-highlight:hover { - background: rgba(251, 188, 4, 0.35); - } - .tiptap-container .tiptap .comment-highlight.resolved { - background: rgba(251, 188, 4, 0.06); - border-bottom-color: rgba(251, 188, 4, 0.12); - } - - /* ── Collaboration: Suggestions ── */ - .tiptap-container .tiptap .suggestion-insert { - color: #137333; - background: rgba(22, 163, 74, 0.1); - border-bottom: 2px solid rgba(22, 163, 74, 0.4); - text-decoration: none; - cursor: pointer; - } - .tiptap-container .tiptap .suggestion-insert:hover { - background: rgba(22, 163, 74, 0.18); - } - .tiptap-container .tiptap .suggestion-delete { - color: #c5221f; - background: rgba(220, 38, 38, 0.08); - text-decoration: line-through; - text-decoration-color: #c5221f; - cursor: pointer; - } - .tiptap-container .tiptap .suggestion-delete:hover { - background: rgba(220, 38, 38, 0.15); - } - .tiptap-container.suggesting-mode { - border-left: 3px solid #f59e0b; - } - .suggest-toggle.active { - background: #f59e0b !important; - color: #fff !important; - } - - /* ── Suggestion Review Bar ── */ - .suggestion-review-bar { - display: flex; align-items: center; gap: 8px; - padding: 4px 12px; height: 32px; - background: color-mix(in srgb, #f59e0b 8%, var(--rs-bg-surface, #fff)); - border-bottom: 1px solid color-mix(in srgb, #f59e0b 20%, var(--rs-border, #e5e7eb)); - font-size: 12px; color: var(--rs-text-secondary, #666); - } - .srb-label { font-weight: 600; color: #b45309; } - .srb-count { margin-left: auto; color: var(--rs-text-muted, #999); } - .srb-hint { color: var(--rs-text-muted, #999); font-style: italic; } - - /* ── Suggestion Popover (accept/reject on click) ── */ - .suggestion-popover { - position: absolute; - z-index: 100; - background: var(--rs-bg-surface, #fff); - border: 1px solid var(--rs-border, #e5e7eb); - border-radius: 8px; - box-shadow: 0 2px 12px rgba(0,0,0,0.12); - padding: 8px 10px; - display: flex; flex-direction: column; gap: 6px; - min-width: 140px; - font-size: 12px; - } - .sp-header { display: flex; align-items: center; gap: 6px; } - .sp-author { font-weight: 600; color: var(--rs-text-primary, #111); } - .sp-type { color: var(--rs-text-muted, #999); font-size: 11px; } - .sp-actions { display: flex; gap: 6px; } - .sp-btn { - flex: 1; padding: 4px 0; border: 1px solid var(--rs-border, #ddd); - border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 600; - background: var(--rs-bg-surface, #fff); text-align: center; - } - .sp-btn:hover { background: var(--rs-bg-hover, #f5f5f5); } - .sp-accept { color: #137333; border-color: #137333; } - .sp-accept:hover { background: rgba(22, 163, 74, 0.08); } - .sp-reject { color: #c5221f; border-color: #c5221f; } - .sp-reject:hover { background: rgba(220, 38, 38, 0.08); } - `; } } -customElements.define("folk-notes-app", FolkNotesApp); +// ── Register custom element ─────────────────────────────────────────────────── + +if (!customElements.get('folk-notes-app')) { + customElements.define('folk-notes-app', FolkNotesApp); +} diff --git a/modules/rnotes/components/folk-voice-recorder.ts b/modules/rnotes/components/folk-voice-recorder.ts deleted file mode 100644 index df39d1ab..00000000 --- a/modules/rnotes/components/folk-voice-recorder.ts +++ /dev/null @@ -1,578 +0,0 @@ -/** - * — Standalone voice recorder web component. - * - * Full-page recorder with MediaRecorder, SpeechDictation (live), - * and three-tier transcription cascade: - * 1. Server (voice-command-api) - * 2. Live (Web Speech API captured during recording) - * 3. Offline (Parakeet TDT 0.6B in-browser) - * - * Saves AUDIO notes to rNotes via REST API with Tiptap-JSON formatted - * timestamped transcript segments. - */ - -import { SpeechDictation } from '../../../lib/speech-dictation'; -import { transcribeOffline, isModelCached } from '../../../lib/parakeet-offline'; -import type { TranscriptionProgress } from '../../../lib/parakeet-offline'; -import type { TranscriptSegment } from '../../../lib/folk-transcription'; -import { getAccessToken } from '../../../shared/components/rstack-identity'; - -type RecorderState = 'idle' | 'recording' | 'processing' | 'done'; - -class FolkVoiceRecorder extends HTMLElement { - private shadow!: ShadowRoot; - private space = ''; - private state: RecorderState = 'idle'; - private mediaRecorder: MediaRecorder | null = null; - private audioChunks: Blob[] = []; - private dictation: SpeechDictation | null = null; - private segments: TranscriptSegment[] = []; - private liveTranscript = ''; - private finalTranscript = ''; - private recordingStartTime = 0; - private durationTimer: ReturnType | null = null; - private elapsedSeconds = 0; - private audioBlob: Blob | null = null; - private audioUrl: string | null = null; - private progressMessage = ''; - private selectedNotebookId = ''; - private notebooks: { id: string; title: string }[] = []; - private tags = ''; - - constructor() { - super(); - this.shadow = this.attachShadow({ mode: 'open' }); - } - - connectedCallback() { - this.space = this.getAttribute('space') || 'demo'; - this.loadNotebooks(); - this.render(); - } - - disconnectedCallback() { - this.cleanup(); - } - - private cleanup() { - this.stopDurationTimer(); - this.dictation?.destroy(); - this.dictation = null; - if (this.mediaRecorder?.state === 'recording') { - this.mediaRecorder.stop(); - } - this.mediaRecorder = null; - if (this.audioUrl) URL.revokeObjectURL(this.audioUrl); - } - - private getApiBase(): string { - const path = window.location.pathname; - const match = path.match(/^(\/[^/]+)?\/rnotes/); - return match ? match[0] : ''; - } - - private authHeaders(extra?: Record): Record { - const headers: Record = { ...extra }; - const token = getAccessToken(); - if (token) headers['Authorization'] = `Bearer ${token}`; - return headers; - } - - private async loadNotebooks() { - try { - const base = this.getApiBase(); - const res = await fetch(`${base}/api/notebooks`, { headers: this.authHeaders() }); - const data = await res.json(); - this.notebooks = (data.notebooks || []).map((nb: any) => ({ id: nb.id, title: nb.title })); - if (this.notebooks.length > 0 && !this.selectedNotebookId) { - this.selectedNotebookId = this.notebooks[0].id; - } - this.render(); - } catch { /* fallback: empty list */ } - } - - private async startRecording() { - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - - // Determine supported mimeType - const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') - ? 'audio/webm;codecs=opus' - : MediaRecorder.isTypeSupported('audio/webm') - ? 'audio/webm' - : 'audio/mp4'; - - this.audioChunks = []; - this.segments = []; - this.mediaRecorder = new MediaRecorder(stream, { mimeType }); - - this.mediaRecorder.ondataavailable = (e) => { - if (e.data.size > 0) this.audioChunks.push(e.data); - }; - - this.mediaRecorder.onstop = () => { - stream.getTracks().forEach(t => t.stop()); - this.audioBlob = new Blob(this.audioChunks, { type: mimeType }); - if (this.audioUrl) URL.revokeObjectURL(this.audioUrl); - this.audioUrl = URL.createObjectURL(this.audioBlob); - this.processRecording(); - }; - - this.mediaRecorder.start(1000); // 1s timeslice - - // Start live transcription via Web Speech API with segment tracking - this.liveTranscript = ''; - if (SpeechDictation.isSupported()) { - this.dictation = new SpeechDictation({ - onInterim: (text) => { - const interimIdx = this.segments.findIndex(s => !s.isFinal); - if (interimIdx >= 0) { - this.segments[interimIdx].text = text; - } else { - this.segments.push({ - id: crypto.randomUUID(), - text, - timestamp: this.elapsedSeconds, - isFinal: false, - }); - } - this.renderTranscriptSegments(); - }, - onFinal: (text) => { - const interimIdx = this.segments.findIndex(s => !s.isFinal); - if (interimIdx >= 0) { - this.segments[interimIdx] = { ...this.segments[interimIdx], text, isFinal: true }; - } else { - this.segments.push({ - id: crypto.randomUUID(), - text, - timestamp: this.elapsedSeconds, - isFinal: true, - }); - } - this.liveTranscript = this.segments.filter(s => s.isFinal).map(s => s.text).join(' '); - this.renderTranscriptSegments(); - }, - }); - this.dictation.start(); - } - - // Start timer - this.recordingStartTime = Date.now(); - this.elapsedSeconds = 0; - this.durationTimer = setInterval(() => { - this.elapsedSeconds = Math.floor((Date.now() - this.recordingStartTime) / 1000); - const timerEl = this.shadow.querySelector('.recording-timer'); - if (timerEl) timerEl.textContent = this.formatTime(this.elapsedSeconds); - }, 1000); - - this.state = 'recording'; - this.render(); - } catch (err) { - console.error('Failed to start recording:', err); - } - } - - private stopRecording() { - this.stopDurationTimer(); - this.dictation?.stop(); - if (this.mediaRecorder?.state === 'recording') { - this.mediaRecorder.stop(); - } - } - - private stopDurationTimer() { - if (this.durationTimer) { - clearInterval(this.durationTimer); - this.durationTimer = null; - } - } - - /** Targeted DOM update of transcript segments container (avoids full re-render). */ - private renderTranscriptSegments() { - const container = this.shadow.querySelector('.live-transcript-segments'); - if (!container) return; - - const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }; - - container.innerHTML = this.segments.map(seg => ` -
      - [${this.formatTime(seg.timestamp)}] - ${esc(seg.text)} -
      - `).join(''); - - // Auto-scroll to bottom - container.scrollTop = container.scrollHeight; - } - - /** Convert final segments to Tiptap JSON document with timestamped paragraphs. */ - private segmentsToTiptapJSON(): object { - const finalSegments = this.segments.filter(s => s.isFinal); - if (finalSegments.length === 0) return { type: 'doc', content: [{ type: 'paragraph' }] }; - - return { - type: 'doc', - content: finalSegments.map(seg => ({ - type: 'paragraph', - content: [ - { type: 'text', marks: [{ type: 'code' }], text: `[${this.formatTime(seg.timestamp)}]` }, - { type: 'text', text: ` ${seg.text}` }, - ], - })), - }; - } - - private async processRecording() { - this.state = 'processing'; - this.progressMessage = 'Processing recording...'; - this.render(); - - // Three-tier transcription cascade - let transcript = ''; - - // Tier 1: Server transcription - if (this.audioBlob && this.space !== 'demo') { - try { - this.progressMessage = 'Sending to server for transcription...'; - this.render(); - const base = this.getApiBase(); - const formData = new FormData(); - formData.append('file', this.audioBlob, 'recording.webm'); - const res = await fetch(`${base}/api/voice/transcribe`, { - method: 'POST', - headers: this.authHeaders(), - body: formData, - }); - if (res.ok) { - const data = await res.json(); - transcript = data.text || data.transcript || ''; - } - } catch { /* fall through to next tier */ } - } - - // Tier 2: Live transcript from segments - if (!transcript && this.liveTranscript.trim()) { - transcript = this.liveTranscript.trim(); - } - - // Tier 3: Offline Parakeet transcription - if (!transcript && this.audioBlob) { - try { - transcript = await transcribeOffline(this.audioBlob, (p: TranscriptionProgress) => { - this.progressMessage = p.message || 'Processing...'; - this.render(); - }); - } catch { - this.progressMessage = 'Transcription failed. You can still save the recording.'; - this.render(); - } - } - - this.finalTranscript = transcript; - this.state = 'done'; - this.progressMessage = ''; - this.render(); - } - - private async saveNote() { - if (!this.audioBlob || !this.selectedNotebookId) return; - - const base = this.getApiBase(); - - // Upload audio file - let fileUrl = ''; - try { - const formData = new FormData(); - formData.append('file', this.audioBlob, 'recording.webm'); - const uploadRes = await fetch(`${base}/api/uploads`, { - method: 'POST', - headers: this.authHeaders(), - body: formData, - }); - if (uploadRes.ok) { - const uploadData = await uploadRes.json(); - fileUrl = uploadData.url; - } - } catch { /* continue without file */ } - - // Build content: use Tiptap JSON with segments if available, else raw text - const hasFinalSegments = this.segments.some(s => s.isFinal); - const content = hasFinalSegments - ? JSON.stringify(this.segmentsToTiptapJSON()) - : (this.finalTranscript || ''); - const contentFormat = hasFinalSegments ? 'tiptap-json' : undefined; - - // Create the note - const tagList = this.tags.split(',').map(t => t.trim()).filter(Boolean); - tagList.push('voice'); - - try { - const res = await fetch(`${base}/api/notes`, { - method: 'POST', - headers: this.authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ - notebook_id: this.selectedNotebookId, - title: `Voice Note — ${new Date().toLocaleDateString()}`, - content, - content_format: contentFormat, - type: 'AUDIO', - tags: tagList, - file_url: fileUrl, - mime_type: this.audioBlob.type, - duration: this.elapsedSeconds, - }), - }); - if (res.ok) { - this.state = 'idle'; - this.finalTranscript = ''; - this.liveTranscript = ''; - this.segments = []; - this.audioBlob = null; - if (this.audioUrl) { URL.revokeObjectURL(this.audioUrl); this.audioUrl = null; } - this.render(); - // Show success briefly - this.progressMessage = 'Note saved!'; - this.render(); - setTimeout(() => { this.progressMessage = ''; this.render(); }, 2000); - } - } catch (err) { - this.progressMessage = 'Failed to save note'; - this.render(); - } - } - - private discard() { - this.cleanup(); - this.state = 'idle'; - this.finalTranscript = ''; - this.liveTranscript = ''; - this.segments = []; - this.audioBlob = null; - this.audioUrl = null; - this.elapsedSeconds = 0; - this.progressMessage = ''; - this.render(); - } - - private formatTime(s: number): string { - const m = Math.floor(s / 60); - const sec = s % 60; - return `${m}:${String(sec).padStart(2, '0')}`; - } - - private render() { - const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }; - - let body = ''; - switch (this.state) { - case 'idle': - body = ` -
      -
      - - - - -
      -

      Voice Recorder

      -

      Record voice notes with automatic transcription

      -
      - - -
      - - ${isModelCached() ? '

      Offline model cached

      ' : ''} -
      `; - break; - - case 'recording': - body = ` -
      -
      -
      ${this.formatTime(this.elapsedSeconds)}
      -

      Recording...

      -
      - -
      `; - break; - - case 'processing': - body = ` -
      -
      -

      ${esc(this.progressMessage)}

      -
      `; - break; - - case 'done': - body = ` -
      -

      Recording Complete

      - ${this.audioUrl ? `` : ''} -
      Duration: ${this.formatTime(this.elapsedSeconds)}
      -
      - - -
      -
      - - - -
      -
      `; - break; - } - - this.shadow.innerHTML = ` - -
      ${body}
      - ${this.progressMessage && this.state === 'idle' ? `
      ${esc(this.progressMessage)}
      ` : ''} - `; - this.attachListeners(); - - // Re-render segments after DOM is in place (recording state) - if (this.state === 'recording' && this.segments.length > 0) { - this.renderTranscriptSegments(); - } - } - - private attachListeners() { - this.shadow.getElementById('btn-start')?.addEventListener('click', () => this.startRecording()); - this.shadow.getElementById('btn-stop')?.addEventListener('click', () => this.stopRecording()); - this.shadow.getElementById('btn-save')?.addEventListener('click', () => this.saveNote()); - this.shadow.getElementById('btn-discard')?.addEventListener('click', () => this.discard()); - this.shadow.getElementById('btn-copy')?.addEventListener('click', () => { - const textarea = this.shadow.getElementById('transcript-edit') as HTMLTextAreaElement; - if (textarea) navigator.clipboard.writeText(textarea.value); - }); - - const nbSelect = this.shadow.getElementById('notebook-select') as HTMLSelectElement; - if (nbSelect) nbSelect.addEventListener('change', () => { this.selectedNotebookId = nbSelect.value; }); - - const tagsInput = this.shadow.getElementById('tags-input') as HTMLInputElement; - if (tagsInput) tagsInput.addEventListener('input', () => { this.tags = tagsInput.value; }); - - const transcriptEdit = this.shadow.getElementById('transcript-edit') as HTMLTextAreaElement; - if (transcriptEdit) transcriptEdit.addEventListener('input', () => { this.finalTranscript = transcriptEdit.value; }); - } - - private getStyles(): string { - return ` - :host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); } - * { box-sizing: border-box; } - - .voice-recorder { - max-width: 600px; margin: 0 auto; padding: 40px 20px; - display: flex; flex-direction: column; align-items: center; text-align: center; - } - - h2 { font-size: 24px; font-weight: 700; margin: 16px 0 4px; } - h3 { font-size: 18px; font-weight: 600; margin: 0 0 16px; } - .recorder-subtitle { color: var(--rs-text-muted); margin: 0 0 24px; } - - .recorder-icon { color: var(--rs-primary); margin-bottom: 8px; } - - .recorder-config { - display: flex; flex-direction: column; gap: 12px; width: 100%; - max-width: 400px; margin-bottom: 24px; text-align: left; - } - .recorder-config label { font-size: 13px; color: var(--rs-text-secondary); display: flex; flex-direction: column; gap: 4px; } - .recorder-config select, .recorder-config input { - padding: 8px 12px; border-radius: 6px; border: 1px solid var(--rs-input-border); - background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 14px; font-family: inherit; - } - - .record-btn { - padding: 14px 36px; border-radius: 50px; border: none; - background: var(--rs-error, #ef4444); color: #fff; font-size: 16px; font-weight: 600; - cursor: pointer; transition: all 0.2s; - } - .record-btn:hover { transform: scale(1.05); filter: brightness(1.1); } - - .model-status { font-size: 11px; color: var(--rs-text-muted); margin-top: 12px; } - - /* Recording state */ - .recorder-recording { display: flex; flex-direction: column; align-items: center; gap: 16px; } - .recording-pulse { - width: 80px; height: 80px; border-radius: 50%; - background: var(--rs-error, #ef4444); animation: pulse 1.5s infinite; - } - @keyframes pulse { - 0% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } - 70% { transform: scale(1.05); opacity: 0.8; box-shadow: 0 0 0 20px rgba(239, 68, 68, 0); } - 100% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } - } - .recording-timer { font-size: 48px; font-weight: 700; font-variant-numeric: tabular-nums; } - .recording-status { color: var(--rs-error, #ef4444); font-weight: 500; } - - /* Live transcript segments */ - .live-transcript-segments { - width: 100%; max-width: 500px; max-height: 250px; overflow-y: auto; - text-align: left; padding: 8px 0; - } - .transcript-segment { - display: flex; gap: 8px; padding: 4px 12px; border-radius: 4px; - font-size: 14px; line-height: 1.6; - } - .transcript-segment.interim { - font-style: italic; color: var(--rs-text-muted); - background: var(--rs-bg-surface-raised); - } - .segment-time { - flex-shrink: 0; font-family: 'JetBrains Mono', 'Fira Code', monospace; - font-size: 12px; color: var(--rs-text-muted); padding-top: 2px; - } - .segment-text { flex: 1; } - - .stop-btn { - padding: 12px 32px; border-radius: 50px; border: none; - background: var(--rs-text-primary); color: var(--rs-bg-surface); font-size: 15px; font-weight: 600; - cursor: pointer; - } - - /* Processing */ - .recorder-processing { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 40px; } - .processing-spinner { - width: 48px; height: 48px; border: 3px solid var(--rs-border); - border-top-color: var(--rs-primary); border-radius: 50%; animation: spin 0.8s linear infinite; - } - @keyframes spin { to { transform: rotate(360deg); } } - - /* Done */ - .recorder-done { display: flex; flex-direction: column; align-items: center; gap: 12px; width: 100%; } - .result-audio { width: 100%; max-width: 500px; height: 40px; margin-bottom: 8px; } - .result-duration { font-size: 13px; color: var(--rs-text-muted); } - .transcript-section { width: 100%; max-width: 500px; text-align: left; } - .transcript-section label { font-size: 12px; font-weight: 600; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.05em; } - .transcript-textarea { - width: 100%; min-height: 120px; padding: 12px; margin-top: 4px; - border-radius: 8px; border: 1px solid var(--rs-input-border); - background: var(--rs-input-bg); color: var(--rs-input-text); - font-size: 14px; font-family: inherit; line-height: 1.6; resize: vertical; - } - .result-actions { display: flex; gap: 8px; margin-top: 8px; } - .save-btn { - padding: 10px 24px; border-radius: 8px; border: none; - background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; - } - .copy-btn, .discard-btn { - padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; - border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary); - } - .discard-btn { color: var(--rs-error, #ef4444); border-color: var(--rs-error, #ef4444); } - - .toast { - position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); - padding: 10px 20px; border-radius: 8px; background: var(--rs-primary); color: #fff; - font-size: 13px; font-weight: 500; z-index: 100; - } - `; - } -} - -customElements.define('folk-voice-recorder', FolkVoiceRecorder); diff --git a/modules/rnotes/components/import-export-dialog.ts b/modules/rnotes/components/import-export-dialog.ts deleted file mode 100644 index a9eda96c..00000000 --- a/modules/rnotes/components/import-export-dialog.ts +++ /dev/null @@ -1,1003 +0,0 @@ -/** - * — Modal dialog for importing, exporting, and syncing notes. - * - * 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. - */ - -import { getModuleApiBase } from "../../../shared/url-helpers"; - -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' | 'sync' = 'import'; - private activeSource: 'files' | 'obsidian' | 'logseq' | 'notion' | 'google-docs' | 'evernote' | 'roam' = 'files'; - private notebooks: NotebookOption[] = []; - private connections: ConnectionStatus & { evernote?: { connected: boolean }; roam?: { connected: boolean } } = { - notion: { connected: false }, - google: { connected: false }, - logseq: { connected: true }, - obsidian: { connected: true }, - }; - private selectedFiles: File[] = []; - private remotePages: RemotePage[] = []; - private selectedPages = new Set(); - private importing = false; - private exporting = false; - private syncing = false; - private syncStatuses: Record = {}; - private statusMessage = ''; - private statusType: 'info' | 'success' | 'error' = 'info'; - private selectedFile: File | null = null; - 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' | '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'); - } - - /** Close the dialog. */ - close() { - (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.remove('open'); - } - - private async loadConnections() { - try { - const res = await fetch(`${getModuleApiBase("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(`${getModuleApiBase("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(`${getModuleApiBase("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 === '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(`${getModuleApiBase("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) { - const fileTypeHint: Record = { - obsidian: 'ZIP file', logseq: 'ZIP file', - evernote: '.enex file', roam: 'JSON file', - }; - this.setStatus(`Please select a ${fileTypeHint[this.activeSource] || 'file'}`, 'error'); - this.importing = false; - return; - } - - 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(`${getModuleApiBase("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(`${getModuleApiBase("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(`${getModuleApiBase("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 = `${getModuleApiBase("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(`${getModuleApiBase("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(`${getModuleApiBase("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 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 = fileBased.includes(this.activeSource) || (this.connections as any)[sourceConnKey]?.connected || false; - - this.shadow.innerHTML = ` - -
      -
      -
      -

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

      - -
      - -
      - - - -
      - - ${this.activeTab !== 'sync' ? (() => { - const sources = this.activeTab === 'export' - ? (['obsidian', 'logseq', 'notion', 'google-docs'] as const) - : (['files', 'obsidian', 'logseq', 'notion', 'google-docs', 'evernote', 'roam'] as const); - return `
      - ${sources.map(s => ` - - `).join('')} -
      `; - })() : ''} - -
      - ${this.activeTab === 'sync' ? this.renderSyncTab() : 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('')} -
      -
      - - -
      - `; - } - - // Generic file import - if (this.activeSource === 'files') { - return ` -
      -

      Drop files to import as notes

      - - -

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

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

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

      - - -

      or drag & drop here

      -
      -
      - - -
      - `; - } - - private renderExportTab(isApiSource: boolean, isConnected: boolean): string { - if (isApiSource && !isConnected) { - return ` -
      -

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

      - -
      `; - } - - return ` -
      - - -
      - `; - } - - private renderSyncTab(): string { - const statusEntries = Object.entries(this.syncStatuses); - const hasApiNotes = statusEntries.some(([_, s]) => s.source === 'notion' || s.source === 'google-docs'); - const hasFileNotes = statusEntries.some(([_, s]) => s.source === 'obsidian' || s.source === 'logseq'); - - return ` -
      - - -
      - - ${this.targetNotebookId ? ` -
      - ${statusEntries.length === 0 - ? '

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

      ' - : `

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

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

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

      -
      - - -
      -
      - ` : ''} - ` : ''} - `; - } - - private relativeTime(ts: number): string { - const diff = Date.now() - ts; - if (diff < 60000) return 'just now'; - if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; - if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; - return `${Math.floor(diff / 86400000)}d ago`; - } - - private async loadSyncStatus(notebookId: string) { - try { - const res = await fetch(`${getModuleApiBase("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(`${getModuleApiBase("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(`${getModuleApiBase("rnotes")}/api/sync/upload`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, - body: formData, - }); - - const data = await res.json(); - if (data.ok) { - this.setStatus(`Sync: ${data.synced} updated, ${data.conflicts} conflicts`, 'success'); - this.dispatchEvent(new CustomEvent('sync-complete', { detail: data })); - this.loadSyncStatus(this.targetNotebookId); - } else { - this.setStatus(data.error || 'Sync failed', 'error'); - } - } catch (err) { - this.setStatus(`Sync error: ${(err as Error).message}`, 'error'); - } - - this.syncing = false; - } - - private sourceName(s: string): string { - const names: Record = { - files: 'Files', - obsidian: 'Obsidian', - logseq: 'Logseq', - notion: 'Notion', - 'google-docs': 'Google Docs', - evernote: 'Evernote', - roam: 'Roam', - }; - return names[s] || s; - } - - private sourceIcon(s: string): string { - const icons: Record = { - files: '', - obsidian: '', - logseq: '', - notion: '', - 'google-docs': '', - evernote: '', - roam: '', - }; - return icons[s] || ''; - } - - 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; - // 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'); - }); - }); - - // 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.selectedFiles = []; - 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', () => { - 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 - 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 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; - 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()); - - // 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 { - 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); -webkit-backdrop-filter: blur(4px); 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; flex-wrap: wrap; - 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); } - - .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; } - `; - } -} - -customElements.define('import-export-dialog', ImportExportDialog); - -export { ImportExportDialog }; diff --git a/modules/rnotes/components/notes-demo.ts b/modules/rnotes/components/notes-demo.ts deleted file mode 100644 index 6d085124..00000000 --- a/modules/rnotes/components/notes-demo.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * rNotes demo — client-side WebSocket controller. - * - * Connects to rSpace via DemoSync, populates note cards, - * packing list checkboxes, sidebar, and notebook header. - */ - -import { DemoSync, type DemoShape } from "../../../lib/demo-sync-vanilla"; - -// ── Helpers ── - -function shapesByType(shapes: Record, type: string): DemoShape[] { - return Object.values(shapes).filter((s) => s.type === type); -} - -function shapeByType(shapes: Record, type: string): DemoShape | undefined { - return Object.values(shapes).find((s) => s.type === type); -} - -function $(id: string): HTMLElement | null { - return document.getElementById(id); -} - -// ── Simple markdown renderer ── - -function renderMarkdown(text: string): string { - if (!text) return ""; - const lines = text.split("\n"); - const out: string[] = []; - let inCodeBlock = false; - let codeLang = ""; - let codeLines: string[] = []; - let inList: "ul" | "ol" | null = null; - - function flushList() { - if (inList) { out.push(inList === "ul" ? "
" : ""); inList = null; } - } - - function flushCode() { - if (inCodeBlock) { - const escaped = codeLines.join("\n").replace(//g, ">"); - out.push(`
${codeLang ? `
${codeLang}
` : ""}
${escaped}
`); - inCodeBlock = false; - codeLines = []; - codeLang = ""; - } - } - - for (const raw of lines) { - const line = raw; - - // Code fence - if (line.startsWith("```")) { - if (inCodeBlock) { flushCode(); } else { flushList(); inCodeBlock = true; codeLang = line.slice(3).trim(); } - continue; - } - if (inCodeBlock) { codeLines.push(line); continue; } - - // Blank line - if (!line.trim()) { flushList(); continue; } - - // Headings - if (line.startsWith("### ")) { flushList(); out.push(`

${inlineFormat(line.slice(4))}

`); continue; } - if (line.startsWith("## ")) { flushList(); out.push(`

${inlineFormat(line.slice(3))}

`); continue; } - if (line.startsWith("# ")) { flushList(); out.push(`

${inlineFormat(line.slice(2))}

`); continue; } - if (line.startsWith("#### ")) { flushList(); out.push(`

${inlineFormat(line.slice(5))}

`); continue; } - if (line.startsWith("##### ")) { flushList(); out.push(`
${inlineFormat(line.slice(6))}
`); continue; } - - // Blockquote - if (line.startsWith("> ")) { flushList(); out.push(`

${inlineFormat(line.slice(2))}

`); continue; } - - // Unordered list - const ulMatch = line.match(/^[-*]\s+(.+)/); - if (ulMatch) { - if (inList !== "ul") { flushList(); out.push("
    "); inList = "ul"; } - out.push(`
  • ${inlineFormat(ulMatch[1])}
  • `); - continue; - } - - // Ordered list - const olMatch = line.match(/^(\d+)\.\s+(.+)/); - if (olMatch) { - if (inList !== "ol") { flushList(); out.push("
      "); inList = "ol"; } - out.push(`
    1. ${olMatch[1]}.${inlineFormat(olMatch[2])}
    2. `); - continue; - } - - // Paragraph - flushList(); - out.push(`

      ${inlineFormat(line)}

      `); - } - - flushCode(); - flushList(); - return out.join("\n"); -} - -function inlineFormat(text: string): string { - return text - .replace(/`([^`]+)`/g, "$1") - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/\*([^*]+)\*/g, "$1"); -} - -// ── Note card rendering ── - -const TAG_COLORS: Record = { - planning: "rgba(245,158,11,0.15)", - travel: "rgba(20,184,166,0.15)", - food: "rgba(251,146,60,0.15)", - gear: "rgba(168,85,247,0.15)", - safety: "rgba(239,68,68,0.15)", - accommodation: "rgba(59,130,246,0.15)", -}; - -function renderNoteCard(note: DemoShape, expanded: boolean): string { - const title = (note.title as string) || "Untitled"; - const content = (note.content as string) || ""; - const tags = (note.tags as string[]) || []; - const lastEdited = note.lastEdited as string; - const synced = note.synced !== false; - - const preview = content.split("\n").slice(0, 3).join(" ").slice(0, 120); - const previewText = preview.replace(/[#*>`\-]/g, "").trim(); - - return ` -
      -
      -
      -

      ${escHtml(title)}

      - ${synced ? `synced` : ""} -
      - ${expanded - ? `
      ${renderMarkdown(content)}
      ` - : `

      ${escHtml(previewText)}${content.length > 120 ? "..." : ""}

      ` - } -
      -
      - ${tags.map((t) => `${escHtml(t)}`).join("")} -
      - ${lastEdited ? `${formatRelative(lastEdited)}` : ""} -
      -
      -
      `; -} - -function escHtml(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); -} - -function formatRelative(iso: string): string { - try { - const d = new Date(iso); - const now = Date.now(); - const diff = now - d.getTime(); - if (diff < 60_000) return "just now"; - if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`; - if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`; - return d.toLocaleDateString("en", { month: "short", day: "numeric" }); - } catch { return ""; } -} - -// ── Packing list rendering ── - -function renderPackingList(packingList: DemoShape): string { - const items = (packingList.items as Array<{ name: string; packed: boolean; category: string }>) || []; - if (items.length === 0) return ""; - - // Group by category - const groups: Record = {}; - for (const item of items) { - const cat = item.category || "General"; - if (!groups[cat]) groups[cat] = []; - groups[cat].push(item); - } - - const checked = items.filter((i) => i.packed).length; - const pct = Math.round((checked / items.length) * 100); - - let html = ` -
      -
      - Packing Checklist - ${checked}/${items.length} packed (${pct}%) -
      -
      -
      -
      -
      -
      `; - - for (const [cat, catItems] of Object.entries(groups)) { - html += `
      -

      ${escHtml(cat)}

      `; - for (let i = 0; i < catItems.length; i++) { - const item = catItems[i]; - const globalIdx = items.indexOf(item); - html += ` -
      -
      - ${item.packed ? `` : ""} -
      - ${escHtml(item.name)} -
      `; - } - html += `
      `; - } - - html += `
      `; - return html; -} - -// ── Avatars ── - -const AVATAR_COLORS = ["#14b8a6", "#06b6d4", "#8b5cf6", "#f59e0b", "#f43f5e"]; - -function renderAvatars(members: string[]): string { - if (!members.length) return ""; - return members.map((name, i) => - `
      ${name[0]}
      ` - ).join("") + `${members.length} collaborators`; -} - -// ── Main ── - -const expandedNotes = new Set(); - -const sync = new DemoSync({ filter: ["folk-notebook", "folk-note", "folk-packing-list"] }); - -function render(shapes: Record) { - const notebook = shapeByType(shapes, "folk-notebook"); - const notes = shapesByType(shapes, "folk-note").sort((a, b) => { - const aTime = a.lastEdited ? new Date(a.lastEdited as string).getTime() : 0; - const bTime = b.lastEdited ? new Date(b.lastEdited as string).getTime() : 0; - return bTime - aTime; - }); - const packingList = shapeByType(shapes, "folk-packing-list"); - - // Hide loading skeleton - const loading = $("rd-loading"); - if (loading) loading.style.display = "none"; - - // Notebook header - if (notebook) { - const nbTitle = $("rd-nb-title"); - const nbCount = $("rd-nb-count"); - const nbDesc = $("rd-nb-desc"); - const sbTitle = $("rd-sb-nb-title"); - const sbCount = $("rd-sb-note-count"); - const sbNum = $("rd-sb-notes-num"); - - if (nbTitle) nbTitle.textContent = (notebook.name as string) || "Trip Notebook"; - if (nbCount) nbCount.textContent = `${notes.length} notes`; - if (nbDesc) nbDesc.textContent = (notebook.description as string) || ""; - if (sbTitle) sbTitle.textContent = (notebook.name as string) || "Trip Notebook"; - if (sbCount) sbCount.textContent = `${notes.length} note${notes.length !== 1 ? "s" : ""}`; - if (sbNum) sbNum.textContent = String(notes.length); - } - - // Notes count - const notesCount = $("rd-notes-count"); - if (notesCount) notesCount.textContent = `${notes.length} note${notes.length !== 1 ? "s" : ""}`; - - // Notes container - const container = $("rd-notes-container"); - const empty = $("rd-notes-empty"); - if (container) { - if (notes.length === 0) { - container.innerHTML = ""; - if (empty) empty.style.display = "block"; - } else { - if (empty) empty.style.display = "none"; - container.innerHTML = notes.map((n) => renderNoteCard(n, expandedNotes.has(n.id))).join(""); - } - } - - // Packing list - const packSection = $("rd-packing-section"); - const packContainer = $("rd-packing-container"); - if (packingList && packSection && packContainer) { - packSection.style.display = "block"; - packContainer.innerHTML = renderPackingList(packingList); - } - - // Avatars — extract from notebook members or note authors - const members = (notebook?.members as string[]) || []; - const avatarsEl = $("rd-avatars"); - if (avatarsEl && members.length > 0) { - avatarsEl.innerHTML = renderAvatars(members); - } -} - -// ── Event listeners ── - -sync.addEventListener("snapshot", ((e: CustomEvent) => { - render(e.detail.shapes); -}) as EventListener); - -sync.addEventListener("connected", () => { - const dot = $("rd-hero-dot"); - const label = $("rd-hero-label"); - if (dot) dot.style.background = "#10b981"; - if (label) label.textContent = "Live — Connected to rSpace"; - const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null; - if (resetBtn) resetBtn.disabled = false; -}); - -sync.addEventListener("disconnected", () => { - const dot = $("rd-hero-dot"); - const label = $("rd-hero-label"); - if (dot) dot.style.background = "#64748b"; - if (label) label.textContent = "Reconnecting..."; - const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null; - if (resetBtn) resetBtn.disabled = true; -}); - -// ── Event delegation ── - -document.addEventListener("click", (e) => { - const target = e.target as HTMLElement; - - // Note card expand/collapse - const noteCard = target.closest("[data-note-id]"); - if (noteCard) { - const id = noteCard.dataset.noteId!; - if (expandedNotes.has(id)) expandedNotes.delete(id); - else expandedNotes.add(id); - render(sync.shapes); - return; - } - - // Packing checkbox toggle - const packItem = target.closest("[data-pack-idx]"); - if (packItem) { - const idx = parseInt(packItem.dataset.packIdx!, 10); - const packingList = shapeByType(sync.shapes, "folk-packing-list"); - if (packingList) { - const items = [...(packingList.items as Array<{ name: string; packed: boolean; category: string }>)]; - items[idx] = { ...items[idx], packed: !items[idx].packed }; - sync.updateShape(packingList.id, { items }); - } - return; - } - - // Reset button - if (target.closest("#rd-reset-btn")) { - sync.resetDemo().catch((err) => console.error("[Notes] Reset failed:", err)); - } -}); - -// ── Start ── - -sync.connect(); diff --git a/modules/rnotes/components/notes.css b/modules/rnotes/components/notes.css deleted file mode 100644 index 3b415915..00000000 --- a/modules/rnotes/components/notes.css +++ /dev/null @@ -1,7 +0,0 @@ -/* Notes module — dark theme (host-level styles) */ -folk-notes-app { - display: block; - min-height: 400px; - padding: 0; - position: relative; -} diff --git a/modules/rnotes/components/slash-command.ts b/modules/rnotes/components/slash-command.ts deleted file mode 100644 index d751e571..00000000 --- a/modules/rnotes/components/slash-command.ts +++ /dev/null @@ -1,308 +0,0 @@ -/** - * Slash command ProseMirror plugin for Tiptap. - * - * Detects '/' typed at the start of an empty block and shows a floating menu - * with block type options. Keyboard navigation: arrow keys + Enter + Escape. - */ - -import { Plugin, PluginKey } from '@tiptap/pm/state'; -import type { EditorView } from '@tiptap/pm/view'; -import type { Editor } from '@tiptap/core'; - -/** Inline SVG icons for slash menu items (16×16, stroke-based, currentColor) */ -const SLASH_ICONS: Record = { - text: '', - heading1: '1', - heading2: '2', - heading3: '3', - bulletList: '', - orderedList: '123', - taskList: '', - codeBlock: '', - blockquote: '', - horizontalRule: '', - image: '', -}; - -export interface SlashMenuItem { - title: string; - icon: string; - description: string; - command: (editor: Editor) => void; -} - -export const SLASH_ITEMS: SlashMenuItem[] = [ - { - title: 'Text', - icon: 'text', - description: 'Plain paragraph text', - command: (e) => e.chain().focus().setParagraph().run(), - }, - { - title: 'Heading 1', - icon: 'heading1', - description: 'Large section heading', - command: (e) => e.chain().focus().setHeading({ level: 1 }).run(), - }, - { - title: 'Heading 2', - icon: 'heading2', - description: 'Medium section heading', - command: (e) => e.chain().focus().setHeading({ level: 2 }).run(), - }, - { - title: 'Heading 3', - icon: 'heading3', - description: 'Small section heading', - command: (e) => e.chain().focus().setHeading({ level: 3 }).run(), - }, - { - title: 'Bullet List', - icon: 'bulletList', - description: 'Unordered bullet list', - command: (e) => e.chain().focus().toggleBulletList().run(), - }, - { - title: 'Numbered List', - icon: 'orderedList', - description: 'Ordered numbered list', - command: (e) => e.chain().focus().toggleOrderedList().run(), - }, - { - title: 'Task List', - icon: 'taskList', - description: 'Checklist with checkboxes', - command: (e) => e.chain().focus().toggleTaskList().run(), - }, - { - title: 'Code Block', - icon: 'codeBlock', - description: 'Syntax-highlighted code block', - command: (e) => e.chain().focus().toggleCodeBlock().run(), - }, - { - title: 'Blockquote', - icon: 'blockquote', - description: 'Indented quote block', - command: (e) => e.chain().focus().toggleBlockquote().run(), - }, - { - title: 'Horizontal Rule', - icon: 'horizontalRule', - description: 'Visual divider line', - command: (e) => e.chain().focus().setHorizontalRule().run(), - }, - { - title: 'Image', - icon: 'image', - description: 'Insert an image from URL', - command: (e) => { - const event = new CustomEvent('slash-insert-image', { bubbles: true, composed: true }); - (e.view.dom as HTMLElement).dispatchEvent(event); - }, - }, - { - title: 'Code Snippet', - icon: 'codeBlock', - description: 'Create a new code snippet note', - command: (e) => { - const event = new CustomEvent('slash-create-typed-note', { bubbles: true, composed: true, detail: { type: 'CODE' } }); - (e.view.dom as HTMLElement).dispatchEvent(event); - }, - }, - { - title: 'Voice Note', - icon: 'text', - description: 'Create a new voice recording note', - command: (e) => { - const event = new CustomEvent('slash-create-typed-note', { bubbles: true, composed: true, detail: { type: 'AUDIO' } }); - (e.view.dom as HTMLElement).dispatchEvent(event); - }, - }, -]; - -const pluginKey = new PluginKey('slashCommand'); - -export function createSlashCommandPlugin(editor: Editor, shadowRoot: ShadowRoot): Plugin { - let menuEl: HTMLDivElement | null = null; - let selectedIndex = 0; - let filteredItems: SlashMenuItem[] = []; - let query = ''; - let active = false; - let triggerPos = -1; - - function show(view: EditorView) { - if (!menuEl) { - menuEl = document.createElement('div'); - menuEl.className = 'slash-menu'; - shadowRoot.appendChild(menuEl); - } - active = true; - selectedIndex = 0; - query = ''; - filteredItems = SLASH_ITEMS; - updateMenuContent(); - positionMenu(view); - menuEl.style.display = 'block'; - } - - function hide() { - active = false; - query = ''; - triggerPos = -1; - if (menuEl) menuEl.style.display = 'none'; - } - - function updateMenuContent() { - if (!menuEl) return; - menuEl.innerHTML = `
      Insert block
      ` + - filteredItems - .map( - (item, i) => - `
      - ${SLASH_ICONS[item.icon] || item.icon} -
      -
      ${item.title}
      -
      ${item.description}
      -
      - ${i === selectedIndex ? 'Enter' : ''} -
      `, - ) - .join(''); - - // Click handlers - menuEl.querySelectorAll('.slash-menu-item').forEach((el) => { - el.addEventListener('pointerdown', (e) => { - e.preventDefault(); - const idx = parseInt((el as HTMLElement).dataset.index || '0'); - executeItem(idx); - }); - el.addEventListener('pointerenter', () => { - selectedIndex = parseInt((el as HTMLElement).dataset.index || '0'); - updateMenuContent(); - }); - }); - } - - function positionMenu(view: EditorView) { - if (!menuEl) return; - const { from } = view.state.selection; - const coords = view.coordsAtPos(from); - const shadowHost = shadowRoot.host as HTMLElement; - const hostRect = shadowHost.getBoundingClientRect(); - - let left = coords.left - hostRect.left; - const menuWidth = 240; - const maxLeft = window.innerWidth - menuWidth - 8 - hostRect.left; - left = Math.max(4, Math.min(left, maxLeft)); - menuEl.style.left = `${left}px`; - menuEl.style.top = `${coords.bottom - hostRect.top + 4}px`; - } - - function filterItems() { - const q = query.toLowerCase(); - filteredItems = q - ? SLASH_ITEMS.filter( - (item) => - item.title.toLowerCase().includes(q) || item.description.toLowerCase().includes(q), - ) - : SLASH_ITEMS; - selectedIndex = Math.min(selectedIndex, Math.max(0, filteredItems.length - 1)); - updateMenuContent(); - } - - function executeItem(index: number) { - const item = filteredItems[index]; - if (!item) return; - - // Delete the slash + query text - const { state } = editor.view; - const tr = state.tr.delete(triggerPos, state.selection.from); - editor.view.dispatch(tr); - - item.command(editor); - hide(); - } - - return new Plugin({ - key: pluginKey, - props: { - handleKeyDown(view, event) { - if (active) { - if (event.key === 'ArrowDown') { - event.preventDefault(); - selectedIndex = (selectedIndex + 1) % filteredItems.length; - updateMenuContent(); - return true; - } - if (event.key === 'ArrowUp') { - event.preventDefault(); - selectedIndex = (selectedIndex - 1 + filteredItems.length) % filteredItems.length; - updateMenuContent(); - return true; - } - if (event.key === 'Enter') { - event.preventDefault(); - executeItem(selectedIndex); - return true; - } - if (event.key === 'Escape') { - event.preventDefault(); - hide(); - return true; - } - if (event.key === 'Backspace') { - if (query.length === 0) { - // Backspace deletes the '/', close menu - hide(); - return false; // let ProseMirror handle the deletion - } - query = query.slice(0, -1); - filterItems(); - return false; // let ProseMirror handle the deletion - } - if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) { - query += event.key; - filterItems(); - if (filteredItems.length === 0) { - hide(); - } - return false; // let ProseMirror insert the character - } - } - return false; - }, - - handleTextInput(view, from, to, text) { - if (text === '/' && !active) { - // Check if cursor is at start of an empty block - const { $from } = view.state.selection; - const isAtStart = $from.parentOffset === 0; - const isEmpty = $from.parent.textContent === ''; - if (isAtStart && isEmpty) { - triggerPos = from; - // Defer show to after the '/' is inserted - setTimeout(() => show(view), 0); - } - } - return false; - }, - }, - - view() { - return { - update(view) { - if (active && menuEl) { - positionMenu(view); - } - }, - destroy() { - if (menuEl) { - menuEl.remove(); - menuEl = null; - } - }, - }; - }, - }); -} diff --git a/modules/rnotes/components/suggestion-marks.ts b/modules/rnotes/components/suggestion-marks.ts deleted file mode 100644 index b4a3678e..00000000 --- a/modules/rnotes/components/suggestion-marks.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * TipTap mark extensions for track-changes suggestions. - * - * SuggestionInsert: wraps text that was inserted in suggesting mode (green underline). - * SuggestionDelete: wraps text that was marked for deletion in suggesting mode (red strikethrough). - * - * Both marks are stored in the Yjs document and sync in real-time. - * Accept/reject logic is handled by the suggestion-plugin. - */ - -import { Mark, mergeAttributes } from '@tiptap/core'; - -export const SuggestionInsertMark = Mark.create({ - name: 'suggestionInsert', - - addAttributes() { - return { - suggestionId: { default: null }, - authorId: { default: null }, - authorName: { default: null }, - createdAt: { default: null }, - }; - }, - - parseHTML() { - return [ - { - tag: 'span[data-suggestion-insert]', - getAttrs: (el) => { - const element = el as HTMLElement; - return { - suggestionId: element.getAttribute('data-suggestion-id'), - authorId: element.getAttribute('data-author-id'), - authorName: element.getAttribute('data-author-name'), - createdAt: Number(element.getAttribute('data-created-at')) || null, - }; - }, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return [ - 'span', - mergeAttributes({ - class: 'suggestion-insert', - 'data-suggestion-insert': '', - 'data-suggestion-id': HTMLAttributes.suggestionId, - 'data-author-id': HTMLAttributes.authorId, - 'data-author-name': HTMLAttributes.authorName, - 'data-created-at': HTMLAttributes.createdAt, - }), - 0, - ]; - }, -}); - -export const SuggestionDeleteMark = Mark.create({ - name: 'suggestionDelete', - - addAttributes() { - return { - suggestionId: { default: null }, - authorId: { default: null }, - authorName: { default: null }, - createdAt: { default: null }, - }; - }, - - parseHTML() { - return [ - { - tag: 'span[data-suggestion-delete]', - getAttrs: (el) => { - const element = el as HTMLElement; - return { - suggestionId: element.getAttribute('data-suggestion-id'), - authorId: element.getAttribute('data-author-id'), - authorName: element.getAttribute('data-author-name'), - createdAt: Number(element.getAttribute('data-created-at')) || null, - }; - }, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return [ - 'span', - mergeAttributes({ - class: 'suggestion-delete', - 'data-suggestion-delete': '', - 'data-suggestion-id': HTMLAttributes.suggestionId, - 'data-author-id': HTMLAttributes.authorId, - 'data-author-name': HTMLAttributes.authorName, - 'data-created-at': HTMLAttributes.createdAt, - }), - 0, - ]; - }, -}); diff --git a/modules/rnotes/components/suggestion-plugin.ts b/modules/rnotes/components/suggestion-plugin.ts deleted file mode 100644 index 8fa42ae7..00000000 --- a/modules/rnotes/components/suggestion-plugin.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * ProseMirror plugin that intercepts user input in "suggesting" mode - * and converts edits into track-changes marks instead of direct mutations. - * - * In suggesting mode: - * - Typed text → inserted with `suggestionInsert` mark (green underline) - * - Backspace/Delete → text NOT deleted, marked with `suggestionDelete` (red strikethrough) - * - Select + type → old text gets `suggestionDelete`, new text gets `suggestionInsert` - * - Paste → same as select + type - * - * Uses ProseMirror props (handleTextInput, handleKeyDown, handlePaste) rather - * than filterTransaction for reliability. - */ - -import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; -import type { EditorView } from '@tiptap/pm/view'; -import type { Slice } from '@tiptap/pm/model'; -import type { Editor } from '@tiptap/core'; - -const pluginKey = new PluginKey('suggestion-plugin'); - -function makeSuggestionId(): string { - return `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; -} - -// ── Typing session tracker ── -// Reuses the same suggestionId while the user types consecutively, -// so an entire typed word/phrase becomes ONE suggestion in the sidebar. -let _sessionSuggestionId: string | null = null; -let _sessionNextPos: number = -1; // the position where the next char is expected - -function getOrCreateSessionId(insertPos: number): string { - if (_sessionSuggestionId && insertPos === _sessionNextPos) { - return _sessionSuggestionId; - } - _sessionSuggestionId = makeSuggestionId(); - return _sessionSuggestionId; -} - -function advanceSession(id: string, nextPos: number): void { - _sessionSuggestionId = id; - _sessionNextPos = nextPos; -} - -function resetSession(): void { - _sessionSuggestionId = null; - _sessionNextPos = -1; -} - -/** - * Create the suggestion mode ProseMirror plugin. - * @param getSuggesting - callback that returns current suggesting mode state - * @param getAuthor - callback that returns { authorId, authorName } - */ -export function createSuggestionPlugin( - getSuggesting: () => boolean, - getAuthor: () => { authorId: string; authorName: string }, -): Plugin { - return new Plugin({ - key: pluginKey, - - props: { - /** Intercept typed text — insert with suggestionInsert mark. */ - handleTextInput(view: EditorView, from: number, to: number, text: string): boolean { - if (!getSuggesting()) return false; - - const { state } = view; - const { authorId, authorName } = getAuthor(); - // Reuse session ID for consecutive typing at the same position - const suggestionId = (from !== to) - ? makeSuggestionId() // replacement → new suggestion - : getOrCreateSessionId(from); // plain insert → batch with session - const tr = state.tr; - - // If there's a selection (replacement), mark the selected text as deleted - if (from !== to) { - // Check if selected text is all suggestionInsert from the same author - // → if so, just replace it (editing your own suggestion) - const ownInsert = isOwnSuggestionInsert(state, from, to, authorId); - if (ownInsert) { - tr.replaceWith(from, to, state.schema.text(text, [ - state.schema.marks.suggestionInsert.create({ - suggestionId: ownInsert, authorId, authorName, createdAt: Date.now(), - }), - ])); - tr.setMeta('suggestion-applied', true); - view.dispatch(tr); - return true; - } - - const deleteMark = state.schema.marks.suggestionDelete.create({ - suggestionId, authorId, authorName, createdAt: Date.now(), - }); - tr.addMark(from, to, deleteMark); - } - - // Insert the new text with insert mark after the (marked-for-deletion) text - const insertPos = to; - const insertMark = state.schema.marks.suggestionInsert.create({ - suggestionId, authorId, authorName, createdAt: Date.now(), - }); - tr.insert(insertPos, state.schema.text(text, [insertMark])); - tr.setMeta('suggestion-applied', true); - - // Place cursor after the inserted text - const newCursorPos = insertPos + text.length; - tr.setSelection(TextSelection.create(tr.doc, newCursorPos)); - - view.dispatch(tr); - advanceSession(suggestionId, newCursorPos); - return true; - }, - - /** Intercept Backspace/Delete — mark text as deleted instead of removing. */ - handleKeyDown(view: EditorView, event: KeyboardEvent): boolean { - if (!getSuggesting()) return false; - if (event.key !== 'Backspace' && event.key !== 'Delete') return false; - resetSession(); // break typing session on delete actions - - const { state } = view; - const { from, to, empty } = state.selection; - const { authorId, authorName } = getAuthor(); - - let deleteFrom = from; - let deleteTo = to; - - if (empty) { - if (event.key === 'Backspace') { - if (from === 0) return true; - deleteFrom = from - 1; - deleteTo = from; - } else { - if (from >= state.doc.content.size) return true; - deleteFrom = from; - deleteTo = from + 1; - } - - // Don't cross block boundaries - const $from = state.doc.resolve(deleteFrom); - const $to = state.doc.resolve(deleteTo); - if ($from.parent !== $to.parent) return true; - } - - // Backspace/delete on own suggestionInsert → actually remove it - const ownInsert = isOwnSuggestionInsert(state, deleteFrom, deleteTo, authorId); - if (ownInsert) { - const tr = state.tr; - tr.delete(deleteFrom, deleteTo); - tr.setMeta('suggestion-applied', true); - view.dispatch(tr); - return true; - } - - // Already marked as suggestionDelete → skip past it - if (isAlreadySuggestionDelete(state, deleteFrom, deleteTo)) { - const tr = state.tr; - const newPos = event.key === 'Backspace' ? deleteFrom : deleteTo; - tr.setSelection(TextSelection.create(state.doc, newPos)); - view.dispatch(tr); - return true; - } - - // Mark the text as deleted - const suggestionId = makeSuggestionId(); - const tr = state.tr; - const deleteMark = state.schema.marks.suggestionDelete.create({ - suggestionId, authorId, authorName, createdAt: Date.now(), - }); - tr.addMark(deleteFrom, deleteTo, deleteMark); - tr.setMeta('suggestion-applied', true); - - if (event.key === 'Backspace') { - tr.setSelection(TextSelection.create(tr.doc, deleteFrom)); - } else { - tr.setSelection(TextSelection.create(tr.doc, deleteTo)); - } - - view.dispatch(tr); - return true; - }, - - /** Intercept paste — insert pasted text as a suggestion. */ - handlePaste(view: EditorView, _event: ClipboardEvent, slice: Slice): boolean { - if (!getSuggesting()) return false; - resetSession(); // paste is a discrete action, break typing session - - const { state } = view; - const { from, to } = state.selection; - const { authorId, authorName } = getAuthor(); - const suggestionId = makeSuggestionId(); - const tr = state.tr; - - // Mark selected text as deleted - if (from !== to) { - const deleteMark = state.schema.marks.suggestionDelete.create({ - suggestionId, authorId, authorName, createdAt: Date.now(), - }); - tr.addMark(from, to, deleteMark); - } - - // Extract text from slice and insert with mark - let pastedText = ''; - slice.content.forEach((node: any) => { - if (pastedText) pastedText += '\n'; - pastedText += node.textContent; - }); - - if (pastedText) { - const insertPos = to; - const insertMark = state.schema.marks.suggestionInsert.create({ - suggestionId, authorId, authorName, createdAt: Date.now(), - }); - tr.insert(insertPos, state.schema.text(pastedText, [insertMark])); - tr.setMeta('suggestion-applied', true); - tr.setSelection(TextSelection.create(tr.doc, insertPos + pastedText.length)); - } - - view.dispatch(tr); - return true; - }, - }, - }); -} - -/** Check if the range is entirely covered by suggestionInsert marks from the same author. */ -function isOwnSuggestionInsert( - state: { doc: any; schema: any }, - from: number, - to: number, - authorId: string, -): string | null { - let allOwn = true; - let foundId: string | null = null; - state.doc.nodesBetween(from, to, (node: any) => { - if (!node.isText) return; - const mark = node.marks.find( - (m: any) => m.type.name === 'suggestionInsert' && m.attrs.authorId === authorId - ); - if (!mark) { - allOwn = false; - } else if (!foundId) { - foundId = mark.attrs.suggestionId; - } - }); - return allOwn && foundId ? foundId : null; -} - -/** Check if the range is already entirely covered by suggestionDelete marks. */ -function isAlreadySuggestionDelete(state: { doc: any }, from: number, to: number): boolean { - let allDeleted = true; - state.doc.nodesBetween(from, to, (node: any) => { - if (!node.isText) return; - if (!node.marks.find((m: any) => m.type.name === 'suggestionDelete')) allDeleted = false; - }); - return allDeleted; -} - - -/** - * Accept a suggestion: insertions stay, deletions are removed. - */ -export function acceptSuggestion(editor: Editor, suggestionId: string) { - const { state } = editor; - const { tr } = state; - - // Collect ranges first, apply from end→start to preserve positions - const deleteRanges: [number, number][] = []; - const insertRanges: [number, number, any][] = []; - - state.doc.descendants((node: any, pos: number) => { - if (!node.isText) return; - const deleteMark = node.marks.find( - (m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId - ); - if (deleteMark) { - deleteRanges.push([pos, pos + node.nodeSize]); - return; - } - const insertMark = node.marks.find( - (m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId - ); - if (insertMark) { - insertRanges.push([pos, pos + node.nodeSize, insertMark]); - } - }); - - for (const [from, to] of deleteRanges.sort((a, b) => b[0] - a[0])) { - tr.delete(from, to); - } - for (const [from, to, mark] of insertRanges.sort((a, b) => b[0] - a[0])) { - tr.removeMark(from, to, mark); - } - - if (tr.docChanged) { - tr.setMeta('suggestion-accept', true); - editor.view.dispatch(tr); - } -} - -/** - * Reject a suggestion: insertions are removed, deletions stay. - */ -export function rejectSuggestion(editor: Editor, suggestionId: string) { - const { state } = editor; - const { tr } = state; - - const insertRanges: [number, number][] = []; - const deleteRanges: [number, number, any][] = []; - - state.doc.descendants((node: any, pos: number) => { - if (!node.isText) return; - const insertMark = node.marks.find( - (m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId - ); - if (insertMark) { - insertRanges.push([pos, pos + node.nodeSize]); - return; - } - const deleteMark = node.marks.find( - (m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId - ); - if (deleteMark) { - deleteRanges.push([pos, pos + node.nodeSize, deleteMark]); - } - }); - - for (const [from, to] of insertRanges.sort((a, b) => b[0] - a[0])) { - tr.delete(from, to); - } - for (const [from, to, mark] of deleteRanges.sort((a, b) => b[0] - a[0])) { - tr.removeMark(from, to, mark); - } - - if (tr.docChanged) { - tr.setMeta('suggestion-reject', true); - editor.view.dispatch(tr); - } -} - -/** Accept all suggestions in the document. */ -export function acceptAllSuggestions(editor: Editor) { - const ids = new Set(); - editor.state.doc.descendants((node: any) => { - if (!node.isText) return; - for (const mark of node.marks) { - if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') { - ids.add(mark.attrs.suggestionId); - } - } - }); - for (const id of ids) acceptSuggestion(editor, id); -} - -/** Reject all suggestions in the document. */ -export function rejectAllSuggestions(editor: Editor) { - const ids = new Set(); - editor.state.doc.descendants((node: any) => { - if (!node.isText) return; - for (const mark of node.marks) { - if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') { - ids.add(mark.attrs.suggestionId); - } - } - }); - for (const id of ids) rejectSuggestion(editor, id); -} diff --git a/modules/rnotes/converters/evernote.ts b/modules/rnotes/converters/evernote.ts deleted file mode 100644 index 0aefb68e..00000000 --- a/modules/rnotes/converters/evernote.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Evernote ENEX → rNotes converter. - * - * Import: Parse .enex XML (ENML — strict HTML subset inside ) - * Convert ENML → markdown via Turndown. - * Extract base64 attachments, save to /data/files/uploads/. - * File-based import (.enex), no auth needed. - */ - -import TurndownService from 'turndown'; -import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap'; -import { registerConverter, hashContent } from './index'; -import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; -import type { NoteItem } from '../schemas'; - -const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); - -// Custom Turndown rules for ENML-specific elements -turndown.addRule('enMedia', { - filter: (node) => node.nodeName === 'EN-MEDIA', - replacement: (_content, node) => { - const el = node as Element; - const hash = el.getAttribute('hash') || ''; - const type = el.getAttribute('type') || ''; - if (type.startsWith('image/')) { - return `![image](resource:${hash})`; - } - return `[attachment](resource:${hash})`; - }, -}); - -turndown.addRule('enTodo', { - filter: (node) => node.nodeName === 'EN-TODO', - replacement: (_content, node) => { - const el = node as Element; - const checked = el.getAttribute('checked') === 'true'; - return checked ? '[x] ' : '[ ] '; - }, -}); - -/** Simple XML tag content extractor (avoids needing a full DOM parser on server). */ -function extractTagContent(xml: string, tagName: string): string[] { - const results: string[] = []; - const openTag = `<${tagName}`; - const closeTag = ``; - let pos = 0; - - while (true) { - const start = xml.indexOf(openTag, pos); - if (start === -1) break; - - // Find end of opening tag (handles attributes) - const tagEnd = xml.indexOf('>', start); - if (tagEnd === -1) break; - - const end = xml.indexOf(closeTag, tagEnd); - if (end === -1) break; - - results.push(xml.substring(tagEnd + 1, end)); - pos = end + closeTag.length; - } - - return results; -} - -/** Extract a single tag's text content. */ -function extractSingleTag(xml: string, tagName: string): string { - const results = extractTagContent(xml, tagName); - return results[0]?.trim() || ''; -} - -/** Extract attribute value from a tag. */ -function extractAttribute(xml: string, attrName: string): string { - const match = xml.match(new RegExp(`${attrName}="([^"]*)"`, 'i')); - return match?.[1] || ''; -} - -/** Parse a single element from ENEX. */ -function parseNote(noteXml: string): { - title: string; - content: string; - tags: string[]; - created?: string; - updated?: string; - resources: { hash: string; mime: string; data: Uint8Array; filename?: string }[]; -} { - const title = extractSingleTag(noteXml, 'title') || 'Untitled'; - - // Extract ENML content (inside CDATA) - let enml = extractSingleTag(noteXml, 'content'); - // Strip CDATA wrapper if present - enml = enml.replace(/^\s*\s*$/, ''); - - const tags: string[] = []; - const tagMatches = extractTagContent(noteXml, 'tag'); - for (const t of tagMatches) { - tags.push(t.trim().toLowerCase().replace(/\s+/g, '-')); - } - - const created = extractSingleTag(noteXml, 'created'); - const updated = extractSingleTag(noteXml, 'updated'); - - // Extract resources (attachments) - const resources: { hash: string; mime: string; data: Uint8Array; filename?: string }[] = []; - const resourceBlocks = extractTagContent(noteXml, 'resource'); - for (const resXml of resourceBlocks) { - const mime = extractSingleTag(resXml, 'mime'); - const b64Data = extractSingleTag(resXml, 'data'); - const encoding = extractAttribute(resXml, 'encoding') || 'base64'; - - // Extract recognition hash or compute from data - let hash = ''; - const recognition = extractSingleTag(resXml, 'recognition'); - if (recognition) { - // Try to get hash from recognition XML - const hashMatch = recognition.match(/objID="([^"]+)"/); - if (hashMatch) hash = hashMatch[1]; - } - - // Extract resource attributes - const resAttrs = extractSingleTag(resXml, 'resource-attributes'); - const filename = resAttrs ? extractSingleTag(resAttrs, 'file-name') : undefined; - - if (b64Data && encoding === 'base64') { - try { - // Decode base64 - const cleaned = b64Data.replace(/\s/g, ''); - const binary = atob(cleaned); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - - // Compute MD5-like hash for matching en-media tags - if (!hash) { - hash = simpleHash(bytes); - } - - resources.push({ hash, mime, data: bytes, filename }); - } catch { /* skip malformed base64 */ } - } - } - - return { title, content: enml, tags, created, updated, resources }; -} - -/** Simple hash for resource matching when recognition hash is missing. */ -function simpleHash(data: Uint8Array): string { - let h = 0; - for (let i = 0; i < Math.min(data.length, 1024); i++) { - h = ((h << 5) - h) + data[i]; - h |= 0; - } - return Math.abs(h).toString(16); -} - -const evernoteConverter: NoteConverter = { - id: 'evernote', - name: 'Evernote', - requiresAuth: false, - - async import(input: ImportInput): Promise { - if (!input.fileData) { - throw new Error('Evernote import requires an .enex file'); - } - - const enexXml = new TextDecoder().decode(input.fileData); - const noteBlocks = extractTagContent(enexXml, 'note'); - - if (noteBlocks.length === 0) { - return { notes: [], notebookTitle: 'Evernote Import', warnings: ['No notes found in ENEX file'] }; - } - - const notes: ConvertedNote[] = []; - const warnings: string[] = []; - - for (const noteXml of noteBlocks) { - try { - const parsed = parseNote(noteXml); - - // Build resource hash→filename map for en-media replacement - const resourceMap = new Map(); - for (const res of parsed.resources) { - const ext = res.mime.includes('jpeg') || res.mime.includes('jpg') ? 'jpg' - : res.mime.includes('png') ? 'png' - : res.mime.includes('gif') ? 'gif' - : res.mime.includes('webp') ? 'webp' - : res.mime.includes('pdf') ? 'pdf' - : 'bin'; - const fname = res.filename || `evernote-${res.hash}.${ext}`; - resourceMap.set(res.hash, { filename: fname, data: res.data, mimeType: res.mime }); - } - - // Convert ENML to markdown - let markdown = turndown.turndown(parsed.content); - - // Resolve resource: references to actual file paths - const attachments: { filename: string; data: Uint8Array; mimeType: string }[] = []; - markdown = markdown.replace(/resource:([a-f0-9]+)/g, (_match, hash) => { - const res = resourceMap.get(hash); - if (res) { - attachments.push(res); - return `/data/files/uploads/${res.filename}`; - } - return `resource:${hash}`; - }); - - const tiptapJson = markdownToTiptap(markdown); - const contentPlain = extractPlainTextFromTiptap(tiptapJson); - - notes.push({ - title: parsed.title, - content: tiptapJson, - contentPlain, - markdown, - tags: parsed.tags, - attachments: attachments.length > 0 ? attachments : undefined, - sourceRef: { - source: 'evernote', - externalId: `enex:${parsed.title}`, - lastSyncedAt: Date.now(), - contentHash: hashContent(markdown), - }, - }); - } catch (err) { - warnings.push(`Failed to parse note: ${(err as Error).message}`); - } - } - - return { notes, notebookTitle: 'Evernote Import', warnings }; - }, - - async export(): Promise { - throw new Error('Evernote export is not supported — use Evernote\'s native import'); - }, -}; - -registerConverter(evernoteConverter); diff --git a/modules/rnotes/converters/file-import.ts b/modules/rnotes/converters/file-import.ts deleted file mode 100644 index 0b9baf52..00000000 --- a/modules/rnotes/converters/file-import.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Generic file import for rNotes. - * - * Handles direct import of individual files: - * - .md / .txt → parse as markdown/text - * - .html → convert via Turndown - * - .jpg / .png / .webp / .gif → create IMAGE note with stored file - * - * All produce ConvertedNote with sourceRef.source = 'manual'. - */ - -import TurndownService from 'turndown'; -import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap'; -import { hashContent } from './index'; -import type { ConvertedNote } from './index'; - -const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); - -/** Dispatch file import by extension / MIME type. */ -export function importFile( - filename: string, - data: Uint8Array, - mimeType?: string, -): ConvertedNote { - const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); - const textContent = () => new TextDecoder().decode(data); - - if (ext === '.md' || ext === '.markdown') { - return importMarkdownFile(filename, textContent()); - } - if (ext === '.txt') { - return importTextFile(filename, textContent()); - } - if (ext === '.html' || ext === '.htm') { - return importHtmlFile(filename, textContent()); - } - if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'].includes(ext)) { - return importImageFile(filename, data, mimeType || guessMime(ext)); - } - - // Default: treat as text - try { - return importTextFile(filename, textContent()); - } catch { - // Binary file — store as FILE note - return importBinaryFile(filename, data, mimeType || 'application/octet-stream'); - } -} - -/** Import a markdown file. */ -export function importMarkdownFile(filename: string, content: string): ConvertedNote { - const title = titleFromFilename(filename); - const tiptapJson = markdownToTiptap(content); - const contentPlain = extractPlainTextFromTiptap(tiptapJson); - - return { - title, - content: tiptapJson, - contentPlain, - markdown: content, - tags: [], - sourceRef: { - source: 'manual', - externalId: `file:${filename}`, - lastSyncedAt: Date.now(), - contentHash: hashContent(content), - }, - }; -} - -/** Import a plain text file — wrap as simple note. */ -export function importTextFile(filename: string, content: string): ConvertedNote { - const title = titleFromFilename(filename); - const tiptapJson = markdownToTiptap(content); - const contentPlain = content; - - return { - title, - content: tiptapJson, - contentPlain, - markdown: content, - tags: [], - sourceRef: { - source: 'manual', - externalId: `file:${filename}`, - lastSyncedAt: Date.now(), - contentHash: hashContent(content), - }, - }; -} - -/** Import an HTML file — convert via Turndown. */ -export function importHtmlFile(filename: string, html: string): ConvertedNote { - const title = titleFromFilename(filename); - const markdown = turndown.turndown(html); - const tiptapJson = markdownToTiptap(markdown); - const contentPlain = extractPlainTextFromTiptap(tiptapJson); - - return { - title, - content: tiptapJson, - contentPlain, - markdown, - tags: [], - sourceRef: { - source: 'manual', - externalId: `file:${filename}`, - lastSyncedAt: Date.now(), - contentHash: hashContent(markdown), - }, - }; -} - -/** Import an image file — create IMAGE note with stored file reference. */ -export function importImageFile(filename: string, data: Uint8Array, mimeType: string): ConvertedNote { - const title = titleFromFilename(filename); - const md = `![${title}](/data/files/uploads/${filename})`; - const tiptapJson = markdownToTiptap(md); - - return { - title, - content: tiptapJson, - contentPlain: title, - markdown: md, - tags: [], - type: 'IMAGE', - attachments: [{ filename, data, mimeType }], - sourceRef: { - source: 'manual', - externalId: `file:${filename}`, - lastSyncedAt: Date.now(), - contentHash: hashContent(String(data.length)), - }, - }; -} - -/** Import a binary/unknown file as a FILE note. */ -function importBinaryFile(filename: string, data: Uint8Array, mimeType: string): ConvertedNote { - const title = titleFromFilename(filename); - const md = `[${filename}](/data/files/uploads/${filename})`; - const tiptapJson = markdownToTiptap(md); - - return { - title, - content: tiptapJson, - contentPlain: title, - markdown: md, - tags: [], - type: 'FILE', - attachments: [{ filename, data, mimeType }], - sourceRef: { - source: 'manual', - externalId: `file:${filename}`, - lastSyncedAt: Date.now(), - contentHash: hashContent(String(data.length)), - }, - }; -} - -function titleFromFilename(filename: string): string { - return filename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' '); -} - -function guessMime(ext: string): string { - const mimes: Record = { - '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', - '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', - '.bmp': 'image/bmp', - }; - return mimes[ext] || 'application/octet-stream'; -} diff --git a/modules/rnotes/converters/google-docs.ts b/modules/rnotes/converters/google-docs.ts deleted file mode 100644 index 231991c8..00000000 --- a/modules/rnotes/converters/google-docs.ts +++ /dev/null @@ -1,329 +0,0 @@ -/** - * Google Docs ↔ rNotes converter. - * - * Import: Google Docs API structural JSON → markdown → TipTap JSON - * Export: TipTap JSON → Google Docs batch update requests - */ - -import { markdownToTiptap, tiptapToMarkdown, 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 DOCS_API_BASE = 'https://docs.googleapis.com/v1'; -const DRIVE_API_BASE = 'https://www.googleapis.com/drive/v3'; - -/** Fetch from Google APIs with auth. */ -async function googleFetch(url: string, token: string, opts: RequestInit = {}): Promise { - const res = await fetch(url, { - ...opts, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - ...opts.headers, - }, - }); - - if (!res.ok) { - const body = await res.text(); - throw new Error(`Google API error ${res.status}: ${body}`); - } - return res.json(); -} - -/** Convert Google Docs structural elements to markdown. */ -function structuralElementToMarkdown(element: any, inlineObjects?: Record): string { - if (element.paragraph) { - return paragraphToMarkdown(element.paragraph, inlineObjects); - } - if (element.table) { - return tableToMarkdown(element.table); - } - if (element.sectionBreak) { - return '\n---\n'; - } - return ''; -} - -/** Convert a Google Docs paragraph to markdown (with inline image resolution context). */ -function paragraphToMarkdown(paragraph: any, inlineObjects?: Record): string { - const style = paragraph.paragraphStyle?.namedStyleType || 'NORMAL_TEXT'; - const elements = paragraph.elements || []; - let text = ''; - - for (const el of elements) { - if (el.textRun) { - text += textRunToMarkdown(el.textRun); - } else if (el.inlineObjectElement) { - const objectId = el.inlineObjectElement.inlineObjectId; - const obj = inlineObjects?.[objectId]; - if (obj) { - const imageProps = obj.inlineObjectProperties?.embeddedObject?.imageProperties; - const contentUri = imageProps?.contentUri; - if (contentUri) { - text += `![image](${contentUri})`; - } else { - text += `![image](inline-object-${objectId})`; - } - } else { - text += `![image](inline-object)`; - } - } - } - - // Remove trailing newline that Google Docs adds to every paragraph - text = text.replace(/\n$/, ''); - - // Apply heading styles - switch (style) { - case 'HEADING_1': return `# ${text}`; - case 'HEADING_2': return `## ${text}`; - case 'HEADING_3': return `### ${text}`; - case 'HEADING_4': return `#### ${text}`; - case 'HEADING_5': return `##### ${text}`; - case 'HEADING_6': return `###### ${text}`; - default: return text; - } -} - -/** Convert a Google Docs TextRun to markdown with formatting. */ -function textRunToMarkdown(textRun: any): string { - let text = textRun.content || ''; - const style = textRun.textStyle || {}; - - // Don't apply formatting to whitespace-only text - if (!text.trim()) return text; - - if (style.bold) text = `**${text.trim()}** `; - if (style.italic) text = `*${text.trim()}* `; - if (style.strikethrough) text = `~~${text.trim()}~~ `; - if (style.link?.url) text = `[${text.trim()}](${style.link.url})`; - - return text; -} - -/** Convert a Google Docs table to markdown. */ -function tableToMarkdown(table: any): string { - const rows = table.tableRows || []; - if (rows.length === 0) return ''; - - const mdRows: string[] = []; - for (let r = 0; r < rows.length; r++) { - const cells = rows[r].tableCells || []; - const cellTexts = cells.map((cell: any) => { - const content = (cell.content || []) - .map((el: any) => structuralElementToMarkdown(el)) - .join('') - .trim(); - return content || ' '; - }); - mdRows.push(`| ${cellTexts.join(' | ')} |`); - - // Separator after header - if (r === 0) { - mdRows.push(`| ${cellTexts.map(() => '---').join(' | ')} |`); - } - } - - return mdRows.join('\n'); -} - -/** Convert TipTap markdown to Google Docs batchUpdate requests. */ -function markdownToGoogleDocsRequests(md: string): any[] { - const requests: any[] = []; - const lines = md.split('\n'); - let index = 1; // Google Docs indexes start at 1 - - for (const line of lines) { - if (!line && lines.indexOf(line) < lines.length - 1) { - // Empty line → insert newline - requests.push({ - insertText: { location: { index }, text: '\n' }, - }); - index += 1; - continue; - } - - // Headings - const headingMatch = line.match(/^(#{1,6})\s+(.+)/); - if (headingMatch) { - const level = headingMatch[1].length; - const text = headingMatch[2] + '\n'; - requests.push({ - insertText: { location: { index }, text }, - }); - requests.push({ - updateParagraphStyle: { - range: { startIndex: index, endIndex: index + text.length }, - paragraphStyle: { namedStyleType: `HEADING_${level}` }, - fields: 'namedStyleType', - }, - }); - index += text.length; - continue; - } - - // Regular text - const text = line.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '') + '\n'; - requests.push({ - insertText: { location: { index }, text }, - }); - - // Apply bullet/list styles - if (line.match(/^[-*]\s+/)) { - requests.push({ - createParagraphBullets: { - range: { startIndex: index, endIndex: index + text.length }, - bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE', - }, - }); - } else if (line.match(/^\d+\.\s+/)) { - requests.push({ - createParagraphBullets: { - range: { startIndex: index, endIndex: index + text.length }, - bulletPreset: 'NUMBERED_DECIMAL_ALPHA_ROMAN', - }, - }); - } - - index += text.length; - } - - return requests; -} - -const googleDocsConverter: NoteConverter = { - id: 'google-docs', - name: 'Google Docs', - requiresAuth: true, - - async import(input: ImportInput): Promise { - const token = input.accessToken; - if (!token) throw new Error('Google Docs import requires an access token. Connect your Google account first.'); - if (!input.pageIds || input.pageIds.length === 0) { - throw new Error('No Google Docs selected for import'); - } - - const notes: ConvertedNote[] = []; - const warnings: string[] = []; - - for (const docId of input.pageIds) { - try { - // Fetch document - const doc = await googleFetch(`${DOCS_API_BASE}/documents/${docId}`, token); - const title = doc.title || 'Untitled'; - - // 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, inlineObjects); - if (md) mdParts.push(md); - } - - const markdown = mdParts.join('\n\n'); - 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: hashContent(markdown), - }, - }); - } catch (err) { - warnings.push(`Failed to import doc ${docId}: ${(err as Error).message}`); - } - } - - return { notes, notebookTitle: 'Google Docs Import', warnings }; - }, - - async export(notes: NoteItem[], opts: ExportOptions): Promise { - const token = opts.accessToken; - if (!token) throw new Error('Google Docs export requires an access token. Connect your Google account first.'); - - const warnings: string[] = []; - const results: any[] = []; - - for (const note of notes) { - try { - // Create a new Google Doc - const doc = await googleFetch(`${DOCS_API_BASE}/documents`, token, { - method: 'POST', - body: JSON.stringify({ title: note.title }), - }); - - // Convert to markdown - let md: string; - if (note.contentFormat === 'tiptap-json' && note.content) { - md = tiptapToMarkdown(note.content); - } else { - md = note.content?.replace(/<[^>]*>/g, '').trim() || ''; - } - - // Build batch update requests - const requests = markdownToGoogleDocsRequests(md); - - if (requests.length > 0) { - await googleFetch(`${DOCS_API_BASE}/documents/${doc.documentId}:batchUpdate`, token, { - method: 'POST', - body: JSON.stringify({ requests }), - }); - } - - // Move to folder if parentId specified - if (opts.parentId) { - await googleFetch( - `${DRIVE_API_BASE}/files/${doc.documentId}?addParents=${opts.parentId}`, - token, - { method: 'PATCH', body: JSON.stringify({}) } - ); - } - - results.push({ noteId: note.id, googleDocId: doc.documentId }); - } catch (err) { - warnings.push(`Failed to export "${note.title}": ${(err as Error).message}`); - } - } - - const data = new TextEncoder().encode(JSON.stringify({ exported: results, warnings })); - return { - data, - filename: 'google-docs-export-results.json', - mimeType: 'application/json', - }; - }, -}; - -registerConverter(googleDocsConverter); diff --git a/modules/rnotes/converters/index.ts b/modules/rnotes/converters/index.ts deleted file mode 100644 index 6e1f5628..00000000 --- a/modules/rnotes/converters/index.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Converter registry and shared types for rNotes import/export. - * - * All source-specific converters implement NoteConverter. - * ConvertedNote is the intermediate format between external sources and NoteItem. - */ - -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 { - title: string; - content: string; // TipTap JSON string - contentPlain: string; // Plain text for search - markdown: string; // Original/generated markdown (for canvas shapes) - tags: string[]; - 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 { - notes: ConvertedNote[]; - notebookTitle: string; - warnings: string[]; -} - -export interface ExportResult { - data: Uint8Array; - filename: string; - mimeType: string; -} - -export interface NoteConverter { - id: string; - name: string; - requiresAuth: boolean; - - /** Import from external source into ConvertedNote[] */ - import(input: ImportInput): Promise; - - /** Export NoteItems to external format */ - export(notes: NoteItem[], opts: ExportOptions): Promise; -} - -export interface ImportInput { - /** ZIP file data for file-based sources (Logseq, Obsidian) */ - fileData?: Uint8Array; - /** Page/doc IDs for API-based sources (Notion, Google Docs) */ - pageIds?: string[]; - /** Whether to import recursively (sub-pages) */ - recursive?: boolean; - /** Access token for authenticated sources */ - accessToken?: string; -} - -export interface ExportOptions { - /** Notebook title for the export */ - notebookTitle?: string; - /** Access token for authenticated sources */ - accessToken?: string; - /** Target parent page/folder ID for API-based exports */ - parentId?: string; -} - -// ── Converter registry ── - -const converters = new Map(); - -export function registerConverter(converter: NoteConverter): void { - converters.set(converter.id, converter); -} - -export function getConverter(id: string): NoteConverter | undefined { - ensureConvertersLoaded(); - return converters.get(id); -} - -export function getAllConverters(): NoteConverter[] { - ensureConvertersLoaded(); - return Array.from(converters.values()); -} - -// ── Lazy-load converters to avoid circular init ── -// Each converter imports registerConverter from this file; importing them -// synchronously at the module level causes a "Cannot access before -// initialization" error in Bun because the converters Map hasn't been -// assigned yet when the circular import triggers registerConverter(). -let _loaded = false; -export function ensureConvertersLoaded(): void { - if (_loaded) return; - _loaded = true; - require('./obsidian'); - require('./logseq'); - require('./notion'); - require('./google-docs'); - require('./evernote'); - require('./roam'); -} diff --git a/modules/rnotes/converters/logseq.ts b/modules/rnotes/converters/logseq.ts deleted file mode 100644 index fc3ecb2b..00000000 --- a/modules/rnotes/converters/logseq.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Logseq converter — re-exports from shared and registers with rNotes converter system. - */ -import { logseqConverter } from '../../../shared/converters/logseq'; -import { registerConverter } from './index'; - -export { logseqConverter }; - -registerConverter(logseqConverter); diff --git a/modules/rnotes/converters/markdown-tiptap.ts b/modules/rnotes/converters/markdown-tiptap.ts deleted file mode 100644 index 4cf11150..00000000 --- a/modules/rnotes/converters/markdown-tiptap.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Re-export from shared location. - * Markdown ↔ TipTap conversion is now shared across modules. - */ -export { - markdownToTiptap, - tiptapToMarkdown, - extractPlainTextFromTiptap, -} from '../../../shared/markdown-tiptap'; diff --git a/modules/rnotes/converters/notion.ts b/modules/rnotes/converters/notion.ts deleted file mode 100644 index 99fb1490..00000000 --- a/modules/rnotes/converters/notion.ts +++ /dev/null @@ -1,461 +0,0 @@ -/** - * Notion ↔ rNotes converter. - * - * Import: Notion API block types → markdown → TipTap JSON - * Export: TipTap JSON → Notion block format, creates pages via API - */ - -import { markdownToTiptap, tiptapToMarkdown, 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 NOTION_API_VERSION = '2022-06-28'; -const NOTION_API_BASE = 'https://api.notion.com/v1'; - -/** Rate-limited fetch for Notion API (3 req/s). */ -let lastRequestTime = 0; -async function notionFetch(url: string, opts: RequestInit & { token: string }): Promise { - const now = Date.now(); - const elapsed = now - lastRequestTime; - if (elapsed < 334) { // ~3 req/s - await new Promise(r => setTimeout(r, 334 - elapsed)); - } - lastRequestTime = Date.now(); - - const res = await fetch(url, { - ...opts, - headers: { - 'Authorization': `Bearer ${opts.token}`, - 'Notion-Version': NOTION_API_VERSION, - 'Content-Type': 'application/json', - ...opts.headers, - }, - }); - - if (!res.ok) { - const body = await res.text(); - throw new Error(`Notion API error ${res.status}: ${body}`); - } - return res.json(); -} - -/** Convert a Notion rich text array to markdown. */ -function richTextToMarkdown(richText: any[]): string { - if (!richText) return ''; - return richText.map((rt: any) => { - let text = rt.plain_text || ''; - const ann = rt.annotations || {}; - if (ann.code) text = `\`${text}\``; - if (ann.bold) text = `**${text}**`; - if (ann.italic) text = `*${text}*`; - if (ann.strikethrough) text = `~~${text}~~`; - if (rt.href) text = `[${text}](${rt.href})`; - return text; - }).join(''); -} - -/** Convert a Notion block to markdown. */ -function blockToMarkdown(block: any, indent = ''): string { - const type = block.type; - const data = block[type]; - if (!data) return ''; - - switch (type) { - case 'paragraph': - return `${indent}${richTextToMarkdown(data.rich_text)}`; - - case 'heading_1': - return `# ${richTextToMarkdown(data.rich_text)}`; - - case 'heading_2': - return `## ${richTextToMarkdown(data.rich_text)}`; - - case 'heading_3': - return `### ${richTextToMarkdown(data.rich_text)}`; - - case 'bulleted_list_item': - return `${indent}- ${richTextToMarkdown(data.rich_text)}`; - - case 'numbered_list_item': - return `${indent}1. ${richTextToMarkdown(data.rich_text)}`; - - case 'to_do': { - const checked = data.checked ? 'x' : ' '; - return `${indent}- [${checked}] ${richTextToMarkdown(data.rich_text)}`; - } - - case 'toggle': - return `${indent}- ${richTextToMarkdown(data.rich_text)}`; - - case 'code': { - const lang = data.language || ''; - const code = richTextToMarkdown(data.rich_text); - return `\`\`\`${lang}\n${code}\n\`\`\``; - } - - case 'quote': - return `> ${richTextToMarkdown(data.rich_text)}`; - - case 'callout': { - const icon = data.icon?.emoji || ''; - return `> ${icon} ${richTextToMarkdown(data.rich_text)}`; - } - - case 'divider': - return '---'; - - case 'image': { - const url = data.file?.url || data.external?.url || ''; - const caption = data.caption ? richTextToMarkdown(data.caption) : ''; - return `![${caption}](${url})`; - } - - case 'bookmark': - return `[${data.url}](${data.url})`; - - case 'table': { - // Tables are handled via children blocks - return ''; - } - - case 'table_row': { - const cells = (data.cells || []).map((cell: any[]) => richTextToMarkdown(cell)); - return `| ${cells.join(' | ')} |`; - } - - case 'child_page': - return `**${data.title}** (sub-page)`; - - case 'child_database': - return `**${data.title}** (database)`; - - default: - // Try to extract rich_text if available - if (data.rich_text) { - return richTextToMarkdown(data.rich_text); - } - return ''; - } -} - -/** Convert TipTap markdown content to Notion blocks. */ -function markdownToNotionBlocks(md: string): any[] { - const lines = md.split('\n'); - const blocks: any[] = []; - - let i = 0; - while (i < lines.length) { - const line = lines[i]; - - // Empty line - if (!line.trim()) { - i++; - continue; - } - - // Headings - const headingMatch = line.match(/^(#{1,3})\s+(.+)/); - if (headingMatch) { - const level = headingMatch[1].length; - const text = headingMatch[2]; - const type = `heading_${level}` as string; - blocks.push({ - type, - [type]: { - rich_text: [{ type: 'text', text: { content: text } }], - }, - }); - i++; - continue; - } - - // Code blocks - if (line.startsWith('```')) { - const lang = line.slice(3).trim(); - const codeLines: string[] = []; - i++; - while (i < lines.length && !lines[i].startsWith('```')) { - codeLines.push(lines[i]); - i++; - } - blocks.push({ - type: 'code', - code: { - rich_text: [{ type: 'text', text: { content: codeLines.join('\n') } }], - language: lang || 'plain text', - }, - }); - i++; // skip closing ``` - continue; - } - - // Blockquotes - if (line.startsWith('> ')) { - blocks.push({ - type: 'quote', - quote: { - rich_text: [{ type: 'text', text: { content: line.slice(2) } }], - }, - }); - i++; - continue; - } - - // Task list items - const taskMatch = line.match(/^- \[([ x])\]\s+(.+)/); - if (taskMatch) { - blocks.push({ - type: 'to_do', - to_do: { - rich_text: [{ type: 'text', text: { content: taskMatch[2] } }], - checked: taskMatch[1] === 'x', - }, - }); - i++; - continue; - } - - // Bullet list items - if (line.match(/^[-*]\s+/)) { - blocks.push({ - type: 'bulleted_list_item', - bulleted_list_item: { - rich_text: [{ type: 'text', text: { content: line.replace(/^[-*]\s+/, '') } }], - }, - }); - i++; - continue; - } - - // Numbered list items - if (line.match(/^\d+\.\s+/)) { - blocks.push({ - type: 'numbered_list_item', - numbered_list_item: { - rich_text: [{ type: 'text', text: { content: line.replace(/^\d+\.\s+/, '') } }], - }, - }); - i++; - continue; - } - - // Horizontal rule - if (line.match(/^---+$/)) { - blocks.push({ type: 'divider', divider: {} }); - i++; - continue; - } - - // Default: paragraph - blocks.push({ - type: 'paragraph', - paragraph: { - rich_text: [{ type: 'text', text: { content: line } }], - }, - }); - i++; - } - - return blocks; -} - -const notionConverter: NoteConverter = { - id: 'notion', - name: 'Notion', - requiresAuth: true, - - async import(input: ImportInput): Promise { - const token = input.accessToken; - if (!token) throw new Error('Notion import requires an access token. Connect your Notion account first.'); - if (!input.pageIds || input.pageIds.length === 0) { - throw new Error('No Notion pages selected for import'); - } - - const notes: ConvertedNote[] = []; - const warnings: string[] = []; - - for (const pageId of input.pageIds) { - try { - // Fetch page metadata - const page = await notionFetch(`${NOTION_API_BASE}/pages/${pageId}`, { - method: 'GET', - token, - }); - - // Extract title - const titleProp = page.properties?.title || page.properties?.Name; - const title = titleProp?.title?.[0]?.plain_text || 'Untitled'; - - // Fetch all blocks (paginated) - const allBlocks: any[] = []; - let cursor: string | undefined; - do { - const url = `${NOTION_API_BASE}/blocks/${pageId}/children?page_size=100${cursor ? `&start_cursor=${cursor}` : ''}`; - const result = await notionFetch(url, { method: 'GET', token }); - allBlocks.push(...(result.results || [])); - cursor = result.has_more ? result.next_cursor : undefined; - } while (cursor); - - // Handle table rows specially - const mdParts: string[] = []; - let inTable = false; - let tableRowIndex = 0; - - for (const block of allBlocks) { - if (block.type === 'table') { - inTable = true; - tableRowIndex = 0; - // Fetch table children - const tableChildren = await notionFetch( - `${NOTION_API_BASE}/blocks/${block.id}/children?page_size=100`, - { method: 'GET', token } - ); - for (const child of tableChildren.results || []) { - const rowMd = blockToMarkdown(child); - mdParts.push(rowMd); - if (tableRowIndex === 0) { - // Add separator after header - const cellCount = (child.table_row?.cells || []).length; - mdParts.push(`| ${Array(cellCount).fill('---').join(' | ')} |`); - } - tableRowIndex++; - } - inTable = false; - } else { - const md = blockToMarkdown(block); - if (md) mdParts.push(md); - } - } - - const markdown = mdParts.join('\n\n'); - const tiptapJson = markdownToTiptap(markdown); - const contentPlain = extractPlainTextFromTiptap(tiptapJson); - - // Extract tags from Notion properties - const tags: string[] = []; - if (page.properties) { - for (const [key, value] of Object.entries(page.properties) as [string, any][]) { - if (value.type === 'multi_select') { - tags.push(...(value.multi_select || []).map((s: any) => s.name.toLowerCase())); - } else if (value.type === 'select' && value.select) { - tags.push(value.select.name.toLowerCase()); - } - } - } - - notes.push({ - title, - content: tiptapJson, - contentPlain, - markdown, - tags: [...new Set(tags)], - sourceRef: { - source: 'notion', - externalId: pageId, - lastSyncedAt: Date.now(), - contentHash: hashContent(markdown), - }, - }); - - // Recursively import child pages if requested - if (input.recursive) { - for (const block of allBlocks) { - if (block.type === 'child_page') { - try { - const childResult = await this.import({ - ...input, - pageIds: [block.id], - recursive: true, - }); - notes.push(...childResult.notes); - warnings.push(...childResult.warnings); - } catch (err) { - warnings.push(`Failed to import child page "${block.child_page?.title}": ${(err as Error).message}`); - } - } - } - } - } catch (err) { - warnings.push(`Failed to import page ${pageId}: ${(err as Error).message}`); - } - } - - return { notes, notebookTitle: 'Notion Import', warnings }; - }, - - async export(notes: NoteItem[], opts: ExportOptions): Promise { - const token = opts.accessToken; - if (!token) throw new Error('Notion export requires an access token. Connect your Notion account first.'); - - const warnings: string[] = []; - const results: any[] = []; - - for (const note of notes) { - try { - // Convert to markdown first - let md: string; - if (note.contentFormat === 'tiptap-json' && note.content) { - md = tiptapToMarkdown(note.content); - } else { - md = note.content?.replace(/<[^>]*>/g, '').trim() || ''; - } - - // Convert markdown to Notion blocks - const blocks = markdownToNotionBlocks(md); - - // Create page in Notion - // If parentId is provided, create as child page; otherwise create in workspace root - const parent = opts.parentId - ? { page_id: opts.parentId } - : { type: 'page_id' as const, page_id: opts.parentId || '' }; - - // For workspace-level pages, we need a database or page parent - // Default to creating standalone pages - const createBody: any = { - parent: opts.parentId - ? { page_id: opts.parentId } - : { type: 'workspace', workspace: true }, - properties: { - title: { - title: [{ type: 'text', text: { content: note.title } }], - }, - }, - children: blocks.slice(0, 100), // Notion limit: 100 blocks per request - }; - - const page = await notionFetch(`${NOTION_API_BASE}/pages`, { - method: 'POST', - token, - body: JSON.stringify(createBody), - }); - - results.push({ noteId: note.id, notionPageId: page.id }); - - // If more than 100 blocks, append in batches - if (blocks.length > 100) { - for (let i = 100; i < blocks.length; i += 100) { - const batch = blocks.slice(i, i + 100); - await notionFetch(`${NOTION_API_BASE}/blocks/${page.id}/children`, { - method: 'PATCH', - token, - body: JSON.stringify({ children: batch }), - }); - } - } - } catch (err) { - warnings.push(`Failed to export "${note.title}": ${(err as Error).message}`); - } - } - - // Return results as JSON since we don't produce a file - const data = new TextEncoder().encode(JSON.stringify({ exported: results, warnings })); - return { - data, - filename: 'notion-export-results.json', - mimeType: 'application/json', - }; - }, -}; - -registerConverter(notionConverter); diff --git a/modules/rnotes/converters/obsidian.ts b/modules/rnotes/converters/obsidian.ts deleted file mode 100644 index 942e0016..00000000 --- a/modules/rnotes/converters/obsidian.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Obsidian converter — re-exports from shared and registers with rNotes converter system. - */ -import { obsidianConverter } from '../../../shared/converters/obsidian'; -import { registerConverter } from './index'; - -export { obsidianConverter }; - -registerConverter(obsidianConverter); diff --git a/modules/rnotes/converters/roam.ts b/modules/rnotes/converters/roam.ts deleted file mode 100644 index 5a828b8a..00000000 --- a/modules/rnotes/converters/roam.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Roam Research JSON → rNotes converter. - * - * Import: Roam JSON export ([{ title, children: [{ string, children }] }]) - * Converts recursive tree → indented markdown bullets. - * Handles Roam syntax: ((block-refs)), {{embed}}, ^^highlight^^, [[page refs]] - */ - -import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap'; -import { registerConverter, hashContent } from './index'; -import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; -import type { NoteItem } from '../schemas'; - -interface RoamBlock { - string?: string; - uid?: string; - children?: RoamBlock[]; - 'create-time'?: number; - 'edit-time'?: number; -} - -interface RoamPage { - title: string; - uid?: string; - children?: RoamBlock[]; - 'create-time'?: number; - 'edit-time'?: number; -} - -/** Convert Roam block tree to indented markdown. */ -function blocksToMarkdown(blocks: RoamBlock[], depth = 0): string { - const lines: string[] = []; - - for (const block of blocks) { - if (!block.string && (!block.children || block.children.length === 0)) continue; - - if (block.string) { - const indent = ' '.repeat(depth); - const text = convertRoamSyntax(block.string); - lines.push(`${indent}- ${text}`); - } - - if (block.children && block.children.length > 0) { - lines.push(blocksToMarkdown(block.children, depth + 1)); - } - } - - return lines.join('\n'); -} - -/** Convert Roam-specific syntax to standard markdown. */ -function convertRoamSyntax(text: string): string { - // [[page references]] → [page references](page references) - text = text.replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)'); - - // ((block refs)) → (ref) - text = text.replace(/\(\(([a-zA-Z0-9_-]+)\)\)/g, '(ref:$1)'); - - // {{embed: ((ref))}} → (embedded ref) - text = text.replace(/\{\{embed:\s*\(\(([^)]+)\)\)\}\}/g, '> (embedded: $1)'); - - // {{[[TODO]]}} and {{[[DONE]]}} - text = text.replace(/\{\{\[\[TODO\]\]\}\}/g, '- [ ]'); - text = text.replace(/\{\{\[\[DONE\]\]\}\}/g, '- [x]'); - - // ^^highlight^^ → ==highlight== (or just **highlight**) - text = text.replace(/\^\^([^^]+)\^\^/g, '**$1**'); - - // **bold** already valid markdown - // __italic__ → *italic* - text = text.replace(/__([^_]+)__/g, '*$1*'); - - return text; -} - -/** Extract tags from Roam page content (inline [[refs]] and #tags). */ -function extractRoamTags(blocks: RoamBlock[]): string[] { - const tags = new Set(); - - function walk(items: RoamBlock[]) { - for (const block of items) { - if (block.string) { - // [[page refs]] - const pageRefs = block.string.match(/\[\[([^\]]+)\]\]/g); - if (pageRefs) { - for (const ref of pageRefs) { - const tag = ref.slice(2, -2).toLowerCase().replace(/\s+/g, '-'); - if (tag.length <= 30) tags.add(tag); // Skip very long refs - } - } - // #tags - const hashTags = block.string.match(/#([a-zA-Z0-9_-]+)/g); - if (hashTags) { - for (const t of hashTags) tags.add(t.slice(1).toLowerCase()); - } - } - if (block.children) walk(block.children); - } - } - - walk(blocks); - return Array.from(tags).slice(0, 20); // Cap tags -} - -const roamConverter: NoteConverter = { - id: 'roam', - name: 'Roam Research', - requiresAuth: false, - - async import(input: ImportInput): Promise { - if (!input.fileData) { - throw new Error('Roam import requires a JSON file'); - } - - const jsonStr = new TextDecoder().decode(input.fileData); - let pages: RoamPage[]; - try { - pages = JSON.parse(jsonStr); - } catch { - throw new Error('Invalid Roam Research JSON format'); - } - - if (!Array.isArray(pages)) { - throw new Error('Expected a JSON array of Roam pages'); - } - - const notes: ConvertedNote[] = []; - const warnings: string[] = []; - - for (const page of pages) { - try { - if (!page.title) continue; - - const children = page.children || []; - const markdown = children.length > 0 - ? blocksToMarkdown(children) - : ''; - - if (!markdown.trim() && children.length === 0) continue; // Skip empty pages - - const tiptapJson = markdownToTiptap(markdown); - const contentPlain = extractPlainTextFromTiptap(tiptapJson); - const tags = extractRoamTags(children); - - notes.push({ - title: page.title, - content: tiptapJson, - contentPlain, - markdown, - tags, - sourceRef: { - source: 'roam', - externalId: page.uid || page.title, - lastSyncedAt: Date.now(), - contentHash: hashContent(markdown), - }, - }); - } catch (err) { - warnings.push(`Failed to parse page "${page.title}": ${(err as Error).message}`); - } - } - - return { notes, notebookTitle: 'Roam Research Import', warnings }; - }, - - async export(): Promise { - throw new Error('Roam Research export is not supported — use Roam\'s native import'); - }, -}; - -registerConverter(roamConverter); diff --git a/modules/rnotes/converters/sync.ts b/modules/rnotes/converters/sync.ts deleted file mode 100644 index 88f46f06..00000000 --- a/modules/rnotes/converters/sync.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Sync service for rNotes — handles re-fetching, conflict detection, - * and merging for imported notes. - * - * Conflict policy: - * - Remote-only-changed → auto-update - * - Local-only-changed → keep local - * - Both changed → mark conflict (stores remote version in conflictContent) - */ - -import type { NoteItem, SourceRef } from '../schemas'; -import { getConverter, hashContent } from './index'; -import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap'; - -export interface SyncResult { - action: 'unchanged' | 'updated' | 'conflict' | 'error'; - remoteHash?: string; - error?: string; - updatedContent?: string; // TipTap JSON of remote content - updatedPlain?: string; - updatedMarkdown?: string; -} - -/** Sync a single Notion note by re-fetching from API. */ -export async function syncNotionNote(note: NoteItem, token: string): Promise { - if (!note.sourceRef || note.sourceRef.source !== 'notion') { - return { action: 'error', error: 'Note is not from Notion' }; - } - - try { - const converter = getConverter('notion'); - if (!converter) return { action: 'error', error: 'Notion converter not available' }; - - const result = await converter.import({ - pageIds: [note.sourceRef.externalId], - accessToken: token, - }); - - if (result.notes.length === 0) { - return { action: 'error', error: 'Could not fetch page from Notion' }; - } - - const remote = result.notes[0]; - const remoteHash = remote.sourceRef.contentHash || ''; - const localHash = note.sourceRef.contentHash || ''; - - // Compare hashes - if (remoteHash === localHash) { - return { action: 'unchanged' }; - } - - // Check if local was modified since last sync - const currentLocalHash = hashContent( - note.contentFormat === 'tiptap-json' ? tiptapToMarkdown(note.content) : note.content - ); - const localModified = currentLocalHash !== localHash; - - if (!localModified) { - // Only remote changed — auto-update - return { - action: 'updated', - remoteHash, - updatedContent: remote.content, - updatedPlain: remote.contentPlain, - updatedMarkdown: remote.markdown, - }; - } - - // Both changed — conflict - return { - action: 'conflict', - remoteHash, - updatedContent: remote.content, - updatedPlain: remote.contentPlain, - updatedMarkdown: remote.markdown, - }; - } catch (err) { - return { action: 'error', error: (err as Error).message }; - } -} - -/** Sync a single Google Docs note by re-fetching from API. */ -export async function syncGoogleDocsNote(note: NoteItem, token: string): Promise { - if (!note.sourceRef || note.sourceRef.source !== 'google-docs') { - return { action: 'error', error: 'Note is not from Google Docs' }; - } - - try { - const converter = getConverter('google-docs'); - if (!converter) return { action: 'error', error: 'Google Docs converter not available' }; - - const result = await converter.import({ - pageIds: [note.sourceRef.externalId], - accessToken: token, - }); - - if (result.notes.length === 0) { - return { action: 'error', error: 'Could not fetch doc from Google Docs' }; - } - - const remote = result.notes[0]; - const remoteHash = remote.sourceRef.contentHash || ''; - const localHash = note.sourceRef.contentHash || ''; - - if (remoteHash === localHash) { - return { action: 'unchanged' }; - } - - const currentLocalHash = hashContent( - note.contentFormat === 'tiptap-json' ? tiptapToMarkdown(note.content) : note.content - ); - const localModified = currentLocalHash !== localHash; - - if (!localModified) { - return { - action: 'updated', - remoteHash, - updatedContent: remote.content, - updatedPlain: remote.contentPlain, - updatedMarkdown: remote.markdown, - }; - } - - return { - action: 'conflict', - remoteHash, - updatedContent: remote.content, - updatedPlain: remote.contentPlain, - updatedMarkdown: remote.markdown, - }; - } catch (err) { - return { action: 'error', error: (err as Error).message }; - } -} - -/** Sync file-based notes by re-parsing a ZIP and matching by externalId. */ -export async function syncFileBasedNotes( - notes: NoteItem[], - zipData: Uint8Array, - source: 'obsidian' | 'logseq', -): Promise> { - const results = new Map(); - - try { - const converter = getConverter(source); - if (!converter) { - for (const n of notes) results.set(n.id, { action: 'error', error: `${source} converter not available` }); - return results; - } - - const importResult = await converter.import({ fileData: zipData }); - const remoteMap = new Map(); - for (const rn of importResult.notes) { - remoteMap.set(rn.sourceRef.externalId, rn); - } - - for (const note of notes) { - if (!note.sourceRef) { - results.set(note.id, { action: 'error', error: 'No sourceRef' }); - continue; - } - - const remote = remoteMap.get(note.sourceRef.externalId); - if (!remote) { - results.set(note.id, { action: 'unchanged' }); // Not found in ZIP — keep as-is - continue; - } - - const remoteHash = remote.sourceRef.contentHash || ''; - const localHash = note.sourceRef.contentHash || ''; - - if (remoteHash === localHash) { - results.set(note.id, { action: 'unchanged' }); - continue; - } - - const currentLocalHash = hashContent( - note.contentFormat === 'tiptap-json' ? tiptapToMarkdown(note.content) : note.content - ); - const localModified = currentLocalHash !== localHash; - - if (!localModified) { - results.set(note.id, { - action: 'updated', - remoteHash, - updatedContent: remote.content, - updatedPlain: remote.contentPlain, - updatedMarkdown: remote.markdown, - }); - } else { - results.set(note.id, { - action: 'conflict', - remoteHash, - updatedContent: remote.content, - updatedPlain: remote.contentPlain, - updatedMarkdown: remote.markdown, - }); - } - } - } catch (err) { - for (const n of notes) { - results.set(n.id, { action: 'error', error: (err as Error).message }); - } - } - - return results; -} diff --git a/modules/rnotes/landing.ts b/modules/rnotes/landing.ts index 9cc8ac48..35ddc748 100644 --- a/modules/rnotes/landing.ts +++ b/modules/rnotes/landing.ts @@ -1,239 +1,62 @@ /** - * Notes module landing page — static HTML, no React. + * rNotes module landing page — vault browser for Obsidian & Logseq. */ export function renderLanding(): string { return `
      rNotes -

      (You)rNotes, your thoughts unbound.

      -

      Capture Everything, Find Anything, and Share your Insights

      +

      Your vaults, synced and browsable.

      +

      Obsidian & Logseq Vault Sync

      - Notes, clips, voice recordings, and live transcription — all in one place. - Speak and watch your words appear in real time, or drop in audio and video files to transcribe offline. + Upload your Obsidian or Logseq vault, browse files, search across notes, + and visualize wikilink graphs — all from your rSpace.

      -

      - - Start Guided Tour → - -

      - -
      -
      -

      Live Transcription Demo

      -

      Try it right here — click the mic and start speaking.

      - -
      - - - - -
      - -
      - -
      -
      Click mic to start
      -
      00:00
      -
      - -
      - - -
      - Your transcript will appear here… -
      -
      - - -
      - ● Live streaming - 🎵 Audio file upload - 🎥 Video transcription - 🔌 Offline (Parakeet.js) -
      -
      -
      -
      - - - - -
      +
      -

      What rNotes Handles

      +

      What rNotes Does

      -
      📝
      -

      Rich Text Notes

      -

      Write with a full TipTap editor — formatting, code blocks, checklists, and embeds. Dual-format storage keeps Markdown portable.

      +
      📁
      +

      Vault Upload

      +

      Upload your Obsidian or Logseq vault as a ZIP. Metadata is indexed — titles, tags, frontmatter, and wikilinks.

      -
      🎤
      -

      Voice & Transcription

      -

      Record voice notes with live transcription via Web Speech API. Drop in audio or video files and get full transcripts — all in the browser.

      +
      🔎
      +

      Search & Browse

      +

      Full file tree with folder grouping, search by title or tags, and read-only markdown preview.

      -
      🏷
      -

      Tagging & Organization

      -

      Tag freely, organize into notebooks, and search everything. Filtered views surface the right cards at the right time.

      +
      🔗
      +

      Wikilink Graph

      +

      Visualize how your notes connect via wikilinks. See the knowledge graph of your vault at a glance.

      - -
      + +
      -

      Chrome Extension

      -

      Clip pages, record voice notes, and transcribe — right from the toolbar.

      -
      +

      Supported Vault Sources

      +
      -
      📋
      -

      Web Clipper

      -

      Save any page as a note with one click — article text, selection, or full HTML.

      +
      +

      Obsidian

      +

      ZIP your vault folder and upload. Frontmatter, tags, and wikilinks are fully parsed.

      -
      🎤
      -

      Voice Recording

      -

      Press Ctrl+Shift+V to start recording and transcribing from any tab.

      +
      +

      Logseq

      +

      Export your Logseq graph as a ZIP. Outliner structure, properties, and page links are preserved.

      -
      -
      🔓
      -

      Article Unlock

      -

      Bypass soft paywalls by fetching archived versions — read the article, then save it to your notebook.

      -
      -
      -
      🔌
      -

      Offline Transcription

      -

      Parakeet.js runs entirely in-browser — your audio never leaves the device.

      -
      -
      -
      - - ⬇ Download Extension - -

      - Unzip, then load unpacked at chrome://extensions -

      @@ -242,177 +65,36 @@ export function renderLanding(): string {

      How It Works

      -
      +
      1
      -

      Live Transcribe

      -

      Speak and watch words appear in real time via the Web Speech API. No uploads, no waiting.

      +

      Upload

      +

      ZIP your vault folder and upload it to rNotes. The file stays on the server, only metadata is indexed.

      2
      -

      Audio & Video

      -

      Drop files and get full transcripts via Parakeet, running entirely in-browser. Supports MP3, WAV, MP4, and more.

      +

      Browse

      +

      Explore your vault's file tree, search notes, and preview markdown content — all read-only.

      3
      -

      Notebooks & Tags

      -

      Organize transcripts alongside notes, clips, code, and files. Tag, search, and filter across everything.

      -
      -
      -
      4
      -

      Private & Offline

      -

      Parakeet.js runs in-browser — audio never leaves your device. Works offline once the model is cached.

      +

      Connect

      +

      View wikilink graphs, follow backlinks, and discover connections across your knowledge base.

      - -
      -
      -

      Memory Cards

      -

      - Every note is a Memory Card — a typed, structured unit of knowledge with hierarchy, - properties, and attachments. Designed for round-trip interoperability with Logseq. -

      -
      - -
      -
      🏷
      -

      7 Card Types

      -
      - note - link - task - idea - person - reference - file -
      -

      Each card type has distinct styling and behavior. Typed notes surface in filtered views and canvas visualizations.

      -
      - - -
      -
      📂
      -

      Hierarchy & Properties

      -

      - Nest cards under parents to build knowledge trees. Add structured - key:: value - properties — compatible with Logseq's property syntax. -

      -
      -
      type:: idea
      -
      status:: doing
      -
      tags:: #research, #web3
      -
      -
      - - -
      -
      🔄
      -

      Import & Export

      -

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

      -
      - Logseq - Obsidian - Notion - Google Docs - Evernote - Roam - Files -
      -
      - - -
      -
      📄
      -

      Dual Format Storage

      -

      - Every card stores rich TipTap JSON for editing and portable Markdown for search, export, and interoperability. - Write once, read anywhere. -

      -
      - - -
      -
      📎
      -

      Structured Attachments

      -

      - Attach images, PDFs, audio, and files to any card with roles (primary, preview, supporting) and captions. - Thumbnails render inline. -

      -
      - - -
      -
      -

      FUN, Not CRUD

      -

      - Forget, - Update, - New — - nothing is permanently destroyed. Forgotten cards are archived and can be remembered at any time. -

      -
      -
      -
      -
      - - -
      -
      -

      Built on Open Source

      -

      The libraries and tools that power rNotes.

      -
      -
      -

      Automerge

      -

      Local-first CRDT for conflict-free real-time collaboration. Your notes sync across devices without a central server.

      -
      -
      -

      Web Speech API

      -

      Browser-native live transcription — speak and watch your words appear in real time.

      -
      -
      -

      Parakeet.js

      -

      NVIDIA’s in-browser speech recognition. Transcribe audio and video files offline — nothing leaves your device.

      -
      -
      -

      Hono

      -

      Ultra-fast, lightweight API framework powering the rNotes backend.

      -
      -
      -
      -
      - - +
      -

      Your Data, Protected

      -

      How rNotes keeps your information safe.

      -
      -
      -
      🔒
      -

      End-to-End Encryption

      - Coming Soon -

      All content encrypted before it leaves your device. Not even the server can read it.

      -
      -
      -
      🕵
      -

      Zero-Knowledge Architecture

      - Coming Soon -

      The server processes your requests without ever seeing your data in the clear.

      -
      -
      -
      🏠
      -

      Self-Hosted

      -

      Run on your own infrastructure. Your server, your rules, your data.

      -
      +

      Looking for the Rich Editor?

      +

      + The full TipTap editor with notebooks, voice transcription, AI summarization, + and import from 6 sources has moved to rDocs. +

      +
      @@ -420,11 +102,10 @@ export function renderLanding(): string {
      -

      (You)rNotes, your thoughts unbound.

      -

      Try the demo or create a space to get started.

      +

      Sync Your Vault

      +

      Upload your Obsidian or Logseq vault to get started.

      diff --git a/modules/rnotes/local-first-client.ts b/modules/rnotes/local-first-client.ts deleted file mode 100644 index e016f553..00000000 --- a/modules/rnotes/local-first-client.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * rNotes Local-First Client - * - * Wraps the shared local-first stack (DocSyncManager + EncryptedDocStore) - * into a notes-specific API. This replaces the manual WebSocket + REST - * approach in folk-notes-app with proper offline support and encryption. - * - * Usage: - * const client = new NotesLocalFirstClient(space); - * await client.init(); - * const notebooks = client.listNotebooks(); - * client.onChange(docId, (doc) => { ... }); - * client.disconnect(); - */ - -import * as Automerge from '@automerge/automerge'; -import { DocumentManager } from '../../shared/local-first/document'; -import type { DocumentId } from '../../shared/local-first/document'; -import { EncryptedDocStore } from '../../shared/local-first/storage'; -import { DocSyncManager } from '../../shared/local-first/sync'; -import { DocCrypto } from '../../shared/local-first/crypto'; -import { notebookSchema, notebookDocId } from './schemas'; -import type { NotebookDoc, NoteItem, NotebookMeta } from './schemas'; - -export class NotesLocalFirstClient { - #space: string; - #documents: DocumentManager; - #store: EncryptedDocStore; - #sync: DocSyncManager; - #initialized = false; - - constructor(space: string, docCrypto?: DocCrypto) { - this.#space = space; - this.#documents = new DocumentManager(); - this.#store = new EncryptedDocStore(space, docCrypto); - this.#sync = new DocSyncManager({ - documents: this.#documents, - store: this.#store, - }); - - // Register the notebook schema - this.#documents.registerSchema(notebookSchema); - } - - get isConnected(): boolean { return this.#sync.isConnected; } - get isInitialized(): boolean { return this.#initialized; } - - /** - * Initialize: open IndexedDB, load cached docs, connect to sync server. - */ - async init(): Promise { - if (this.#initialized) return; - - // Open IndexedDB store - await this.#store.open(); - - // Load all cached notebook docs from IndexedDB in parallel - const cachedIds = await this.#store.listByModule('notes', 'notebooks'); - const cached = await this.#store.loadMany(cachedIds); - for (const [docId, binary] of cached) { - this.#documents.open(docId, notebookSchema, binary); - } - - // Preload sync states in parallel before connecting - await this.#sync.preloadSyncStates(cachedIds); - - // Connect to sync server - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; - try { - await this.#sync.connect(wsUrl, this.#space); - } catch { - console.warn('[NotesClient] WebSocket connection failed, working offline'); - } - - this.#initialized = true; - } - - /** - * Subscribe to a specific notebook doc for real-time sync. - */ - async subscribeNotebook(notebookId: string): Promise { - const docId = notebookDocId(this.#space, notebookId) as DocumentId; - - // Open or get existing doc - let doc = this.#documents.get(docId); - if (!doc) { - // Try loading from IndexedDB - const binary = await this.#store.load(docId); - if (binary) { - doc = this.#documents.open(docId, notebookSchema, binary); - } else { - // Create empty placeholder — server will fill via sync - doc = this.#documents.open(docId, notebookSchema); - } - } - - // Subscribe for sync - await this.#sync.subscribe([docId]); - - return doc ?? null; - } - - /** - * Unsubscribe from a notebook's sync. - */ - unsubscribeNotebook(notebookId: string): void { - const docId = notebookDocId(this.#space, notebookId) as DocumentId; - this.#sync.unsubscribe([docId]); - } - - /** - * Get a notebook doc (already opened). - */ - getNotebook(notebookId: string): NotebookDoc | undefined { - const docId = notebookDocId(this.#space, notebookId) as DocumentId; - return this.#documents.get(docId); - } - - /** - * List all notebook IDs for this space. - */ - listNotebookIds(): string[] { - return this.#documents.list(this.#space, 'notes'); - } - - /** - * Update a note within a notebook (creates if it doesn't exist). - */ - updateNote(notebookId: string, noteId: string, changes: Partial): void { - const docId = notebookDocId(this.#space, notebookId) as DocumentId; - this.#sync.change(docId, `Update note ${noteId}`, (d) => { - if (!d.items[noteId]) { - d.items[noteId] = { - id: noteId, - notebookId, - authorId: null, - title: '', - content: '', - contentPlain: '', - type: 'NOTE', - url: null, - language: null, - fileUrl: null, - mimeType: null, - fileSize: null, - duration: null, - isPinned: false, - sortOrder: 0, - tags: [], - createdAt: Date.now(), - updatedAt: Date.now(), - ...changes, - }; - } else { - const item = d.items[noteId]; - Object.assign(item, changes); - item.updatedAt = Date.now(); - } - }); - } - - /** - * Delete a note from a notebook. - */ - deleteNote(notebookId: string, noteId: string): void { - const docId = notebookDocId(this.#space, notebookId) as DocumentId; - this.#sync.change(docId, `Delete note ${noteId}`, (d) => { - delete d.items[noteId]; - }); - } - - /** - * Update notebook metadata. - */ - updateNotebook(notebookId: string, changes: Partial): void { - const docId = notebookDocId(this.#space, notebookId) as DocumentId; - this.#sync.change(docId, 'Update notebook', (d) => { - Object.assign(d.notebook, changes); - d.notebook.updatedAt = Date.now(); - }); - } - - /** - * Listen for changes to a notebook doc. - */ - onChange(notebookId: string, cb: (doc: NotebookDoc) => void): () => void { - const docId = notebookDocId(this.#space, notebookId) as DocumentId; - return this.#sync.onChange(docId, cb as (doc: any) => void); - } - - /** - * Listen for connection/disconnection events. - */ - onConnect(cb: () => void): () => void { - return this.#sync.onConnect(cb); - } - - onDisconnect(cb: () => void): () => void { - return this.#sync.onDisconnect(cb); - } - - /** - * Flush all pending saves to IndexedDB and disconnect. - */ - async disconnect(): Promise { - await this.#sync.flush(); - this.#sync.disconnect(); - } -} diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index 56f91a1c..01e9de1d 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -1,1784 +1,564 @@ /** - * Notes module — notebooks, rich-text notes, voice transcription. + * rNotes module — vault sync and browse for Obsidian and Logseq. * - * Port of rnotes-online (Next.js + Prisma → Hono + postgres.js). - * Supports multiple note types: text, code, bookmark, audio, image, file. - * - * Local-first: All data stored exclusively in Automerge documents via SyncServer. + * Replaces the old full-editor module (~1784 lines). + * Rich editing is now in rDocs. This module handles: + * - ZIP vault uploads (Obsidian / Logseq) + * - Automerge metadata storage (titles, tags, hashes, wikilinks) + * - On-demand note content served from ZIP on disk + * - Graph data for wikilink visualization + * - Browser-extension compat shims → rdocs */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; +import JSZip from "jszip"; +import { createHash } from "crypto"; +import { mkdir, writeFile, readFile, unlink } from "fs/promises"; +import { join } from "path"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import { resolveDataSpace } from "../../shared/scope-resolver"; import { verifyToken, extractToken } from "../../server/auth"; import { renderLanding } from "./landing"; -import { notebookSchema, notebookDocId, connectionsDocId, createNoteItem } from "./schemas"; -import type { NotebookDoc, NoteItem, ConnectionsDoc } from "./schemas"; -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 { vaultSchema, vaultDocId } from "./schemas"; +import type { VaultDoc, VaultNoteMeta } from "./schemas"; 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(); // ── SyncServer ref (set during onInit) ── + let _syncServer: SyncServer | null = null; -// ── Automerge helpers ── +// ── Constants ── -/** Lazily ensure a notebook doc exists for a given space + notebookId. */ -function ensureDoc(space: string, notebookId: string): NotebookDoc { - const docId = notebookDocId(space, notebookId); - let doc = _syncServer!.getDoc(docId); - if (!doc) { - doc = Automerge.change(Automerge.init(), 'init', (d) => { - const init = notebookSchema.init(); - Object.assign(d, init); - d.meta.spaceSlug = space; - d.notebook.id = notebookId; - }); - _syncServer!.setDoc(docId, doc); - } - return doc; -} +const VAULT_UPLOAD_DIR = "/data/files/uploads/vaults"; -/** Generate a URL-safe slug from a title. */ -function slugify(title: string): string { - return title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 80) || "untitled"; -} +// ── Helpers ── -/** Generate a compact unique ID (timestamp + random suffix). */ -function newId(): string { - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; -} - -// ── Automerge ↔ REST conversion helpers ── - -/** List all notebook docs for a space from the SyncServer. */ -function listNotebooks(space: string): { docId: string; doc: NotebookDoc }[] { +/** Find all vault docIds for a space by scanning known prefix. */ +function findVaultDocIds(space: string): string[] { if (!_syncServer) return []; - const results: { docId: string; doc: NotebookDoc }[] = []; - const prefix = `${space}:notes:notebooks:`; - for (const docId of _syncServer.listDocs()) { - if (docId.startsWith(prefix)) { - const doc = _syncServer.getDoc(docId); - if (doc && doc.notebook && doc.notebook.title) results.push({ docId, doc }); + const prefix = `${space}:rnotes:vaults:`; + return _syncServer.getDocIds().filter((id) => id.startsWith(prefix)); +} + +interface ParsedNote { + path: string; + title: string; + tags: string[]; + frontmatter: Record; + contentHash: string; + sizeBytes: number; + wikilinks: string[]; +} + +/** + * Parse a ZIP buffer and extract metadata for each .md file. + * Returns an array of parsed note metadata objects. + */ +async function parseVaultZip( + buffer: ArrayBuffer, + source: "obsidian" | "logseq", +): Promise { + const zip = await JSZip.loadAsync(buffer); + const results: ParsedNote[] = []; + + for (const [relativePath, zipEntry] of Object.entries(zip.files)) { + if (zipEntry.dir) continue; + if (!relativePath.endsWith(".md") && !relativePath.endsWith(".markdown")) continue; + // Skip hidden / system files + if (relativePath.includes("/.") || relativePath.startsWith(".")) continue; + + const content = await zipEntry.async("string"); + const sizeBytes = content.length; + const contentHash = createHash("sha256").update(content).digest("hex"); + + // Parse frontmatter (YAML between --- delimiters) + const frontmatter: Record = {}; + let bodyStart = 0; + if (content.startsWith("---")) { + const end = content.indexOf("\n---", 3); + if (end !== -1) { + const fmBlock = content.slice(3, end).trim(); + bodyStart = end + 4; + // Simple YAML key: value parser (no arrays/nested) + for (const line of fmBlock.split("\n")) { + const colon = line.indexOf(":"); + if (colon === -1) continue; + const key = line.slice(0, colon).trim(); + const val = line.slice(colon + 1).trim(); + if (key) frontmatter[key] = val; + } + } } + + // Extract tags from frontmatter + let tags: string[] = []; + if (typeof frontmatter.tags === "string") { + tags = frontmatter.tags + .replace(/[\[\]]/g, "") + .split(",") + .map((t) => t.trim().replace(/^#/, "")) + .filter(Boolean); + } + // Also pick up inline #tags from body + const body = content.slice(bodyStart); + const inlineTags = [...body.matchAll(/#([\w/-]+)/g)].map((m) => m[1]); + tags = [...new Set([...tags, ...inlineTags])]; + + // Derive title: first H1 heading, or frontmatter title, or filename + let title = (frontmatter.title as string) ?? ""; + if (!title) { + const h1 = body.match(/^#\s+(.+)/m); + title = h1 ? h1[1].trim() : relativePath.split("/").pop()!.replace(/\.md$/i, ""); + } + + // Extract wikilinks [[Target]] and [[Target|Alias]] + const wikilinks = [...body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)].map((m) => + m[1].trim(), + ); + + // Logseq: strip "pages/" prefix if present + const normalizedPath = + source === "logseq" && relativePath.startsWith("pages/") + ? relativePath.slice(6) + : relativePath; + + results.push({ + path: normalizedPath, + title, + tags, + frontmatter, + contentHash, + sizeBytes, + wikilinks, + }); } + return results; } -/** Convert an Automerge NotebookDoc to REST API format. */ -function notebookToRest(doc: NotebookDoc) { - const nb = doc.notebook; - return { - id: nb.id, - title: nb.title, - slug: nb.slug, - description: nb.description, - cover_color: nb.coverColor, - is_public: nb.isPublic, - note_count: String(Object.keys(doc.items).length), - created_at: new Date(nb.createdAt).toISOString(), - updated_at: new Date(nb.updatedAt).toISOString(), - }; -} +// ── Routes ── -/** Convert an Automerge NoteItem to REST API format. */ -function noteToRest(item: NoteItem) { - return { - id: item.id, - notebook_id: item.notebookId, - title: item.title, - content: item.content, - content_plain: item.contentPlain, - content_format: item.contentFormat || undefined, - type: item.type, - tags: item.tags.length > 0 ? item.tags : null, - is_pinned: item.isPinned, - sort_order: item.sortOrder, - url: item.url, - language: item.language, - file_url: item.fileUrl, - 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(), - }; -} - -/** Find the notebook doc that contains a given note ID. */ -function findNote(space: string, noteId: string): { docId: string; doc: NotebookDoc; item: NoteItem } | null { - for (const { docId, doc } of listNotebooks(space)) { - const item = doc.items[noteId]; - if (item) return { docId, doc, item }; - } - return null; -} - -// ── Seed demo data into Automerge (runs once if no notebooks exist) ── - -function seedDemoIfEmpty(space: string) { - if (!_syncServer) return; - - // Resolve effective data space (global for rnotes by default) - const dataSpace = resolveDataSpace("rnotes", space); - - // If the space already has notebooks, skip (or was already seeded) - const _connectionsDoc = _syncServer!.getDoc(connectionsDocId(dataSpace)); - if ((_connectionsDoc?.meta as any)?.seeded || listNotebooks(dataSpace).length > 0) return; - - const now = Date.now(); - - // Notebook 1: Project Ideas - const nb1Id = newId(); - const nb1DocId = notebookDocId(dataSpace, nb1Id); - const nb1Doc = Automerge.change(Automerge.init(), "Seed: Project Ideas", (d) => { - d.meta = { module: "notes", collection: "notebooks", version: 1, spaceSlug: dataSpace, createdAt: now }; - d.notebook = { id: nb1Id, title: "Project Ideas", slug: "project-ideas", description: "Brainstorms and design notes for the r* ecosystem", coverColor: "#6366f1", isPublic: true, createdAt: now, updatedAt: now }; - d.items = {}; - }); - _syncServer.setDoc(nb1DocId, nb1Doc); - - // Notebook 2: Meeting Notes - const nb2Id = newId(); - const nb2DocId = notebookDocId(dataSpace, nb2Id); - const nb2Doc = Automerge.change(Automerge.init(), "Seed: Meeting Notes", (d) => { - d.meta = { module: "notes", collection: "notebooks", version: 1, spaceSlug: dataSpace, createdAt: now }; - d.notebook = { id: nb2Id, title: "Meeting Notes", slug: "meeting-notes", description: "Weekly standups, design reviews, and retrospectives", coverColor: "#f59e0b", isPublic: true, createdAt: now, updatedAt: now }; - d.items = {}; - }); - _syncServer.setDoc(nb2DocId, nb2Doc); - - // Notebook 3: How-To Guides - const nb3Id = newId(); - const nb3DocId = notebookDocId(dataSpace, nb3Id); - const nb3Doc = Automerge.change(Automerge.init(), "Seed: How-To Guides", (d) => { - d.meta = { module: "notes", collection: "notebooks", version: 1, spaceSlug: dataSpace, createdAt: now }; - d.notebook = { id: nb3Id, title: "How-To Guides", slug: "how-to-guides", description: "Tutorials and onboarding guides for contributors", coverColor: "#10b981", isPublic: true, createdAt: now, updatedAt: now }; - d.items = {}; - }); - _syncServer.setDoc(nb3DocId, nb3Doc); - - // Seed notes into notebooks - const notes = [ - { - nbId: nb1Id, nbDocId: nb1DocId, title: "Cosmolocal Manufacturing Network", - content: "## Vision\n\nDesign global, manufacture local. Every creative work should be producible by the nearest capable provider.\n\n## Key Components\n\n- **Artifact Spec**: Standardized envelope describing what to produce\n- **Provider Registry**: Directory of local makers with capabilities + pricing\n- **rCart**: Marketplace connecting creators to providers\n- **Revenue Splits**: 50% provider, 35% creator, 15% community\n\n## Open Questions\n\n- How do we handle quality assurance across distributed providers?\n- Should providers be able to set custom margins?\n- What's the minimum viable set of capabilities for launch?", - tags: ["cosmolocal", "architecture"], pinned: true, - }, - { - nbId: nb1Id, nbDocId: nb1DocId, title: "Revenue Sharing Model", - content: "## Current Split\n\n| Recipient | Share | Rationale |\n|-----------|-------|-----------|\n| Provider | 50% | Covers materials, labor, shipping |\n| Creator | 35% | Design and creative work |\n| Community | 15% | Platform maintenance, commons fund |\n\n## Enoughness Thresholds\n\nOnce a funnel reaches its sufficient threshold, surplus flows to the next highest-need funnel. This prevents accumulation and keeps resources flowing.\n\n## Implementation\n\nrFlows Flow Service handles deposits from rCart. Each order total is routed through the configured flow → funnel → overflow splits.", - tags: ["cosmolocal", "governance"], - }, - { - nbId: nb1Id, nbDocId: nb1DocId, title: "FUN Model: Forget, Update, New", - content: "## Replacing CRUD\n\nNothing is permanently destroyed in rSpace.\n\n- **Forget** replaces Delete — soft-delete with `forgotten: true`. Shapes stay in document, hidden from canvas. Memory panel lets you browse + Remember.\n- **Update** stays the same — public `sync.updateShape()` for programmatic updates\n- **New** replaces Create — language shift: toolbar says \"New X\", events are `new-shape`\n\n## Why?\n\nData sovereignty means users should always be able to recover their work. The Memory panel makes forgotten shapes discoverable, like a digital archive.", - tags: ["design", "architecture"], - }, - { - nbId: nb2Id, nbDocId: nb2DocId, title: "Weekly Standup — Feb 15, 2026", - content: "## Attendees\n\nAlice, Bob, Carol\n\n## Updates\n\n**Alice**: Finished EncryptID guardian recovery flow. 2-of-3 guardian approval working. Next: device linking via QR code.\n\n**Bob**: Provider registry now has 6 printers globally. Working on proximity search with earthdistance extension.\n\n**Carol**: rFlows river visualization deployed. Enoughness layer showing golden glow on sufficient funnels.\n\n## Action Items\n\n- [ ] Alice: Document guardian recovery API endpoints\n- [ ] Bob: Add turnaround time estimates to provider matching\n- [ ] Carol: Add demo mode to river view with mock data", - tags: ["standup"], - }, - { - nbId: nb2Id, nbDocId: nb2DocId, title: "Design Review — rBooks Flipbook Reader", - content: "## What We Reviewed\n\nThe react-pageflip integration for PDF reading in rBooks.\n\n## Feedback\n\n1. **Page turn animation** — smooth, feels good on desktop. On mobile, swipe gesture needs larger hit area.\n2. **PDF rendering** — react-pdf handles most PDFs well. Large files (>50MB) cause browser memory issues.\n3. **Read Locally mode** — IndexedDB storage works. Need to show storage usage somewhere.\n\n## Decisions\n\n- Ship current version, iterate on mobile\n- Add a 50MB soft warning on upload\n- Explore PDF.js worker for background rendering", - tags: ["review", "design"], - }, - { - nbId: nb3Id, nbDocId: nb3DocId, title: "Getting Started with rSpace Development", - content: "## Prerequisites\n\n- Bun runtime (v1.3+)\n- Docker + Docker Compose\n- Git access to Gitea\n\n## Local Setup\n\n```bash\ngit clone ssh://git@gitea.jeffemmett.com:223/jeffemmett/rspace-online.git\ncd rspace-online\nbun install\nbun run dev\n```\n\n## Module Structure\n\nEach module lives in `modules/{name}/` and exports an `RSpaceModule` interface:\n\n```typescript\nexport interface RSpaceModule {\n id: string;\n name: string;\n icon: string;\n description: string;\n routes: Hono;\n}\n```\n\n## Adding a New Module\n\n1. Create `modules/{name}/mod.ts`\n2. Create `modules/{name}/components/` for web components\n3. Add build step in `vite.config.ts`\n4. Register in `server/index.ts`", - tags: ["onboarding"], - }, - { - nbId: nb3Id, nbDocId: nb3DocId, title: "How to Add a Cosmolocal Provider", - content: "## Overview\n\nProviders are local print shops, makerspaces, or studios that can fulfill rCart orders.\n\n## Steps\n\n1. Visit `providers.mycofi.earth`\n2. Sign in with your rStack passkey\n3. Click \"Register Provider\"\n4. Fill in:\n - Name, location (address + coordinates)\n - Capabilities (laser-print, risograph, screen-print, etc.)\n - Substrates (paper types, fabric, vinyl)\n - Turnaround time and pricing\n5. Submit for review\n\n## Matching Algorithm\n\nWhen an order comes in, rCart matches based on:\n- Required capabilities vs. provider capabilities\n- Geographic distance (earthdistance extension)\n- Turnaround time\n- Price", - tags: ["cosmolocal", "onboarding"], - }, - ]; - - for (const n of notes) { - const noteId = newId(); - const contentPlain = n.content.replace(/<[^>]*>/g, " ").replace(/[#*|`\-\[\]]/g, " ").replace(/\s+/g, " ").trim(); - const item = createNoteItem(noteId, n.nbId, n.title, { - content: n.content, - contentPlain, - tags: n.tags, - isPinned: n.pinned || false, - }); - - _syncServer!.changeDoc(n.nbDocId, `Seed note: ${n.title}`, (d) => { - d.items[noteId] = item; - }); - } - - // Mark this space as seeded so deletions don't trigger re-seeding - const _connDocId = connectionsDocId(dataSpace); - if (!_syncServer!.getDoc(_connDocId)) { - _syncServer!.setDoc(_connDocId, Automerge.change(Automerge.init(), 'init connections', (d) => { - d.meta = { module: 'notes', collection: 'connections', version: 1, spaceSlug: dataSpace, createdAt: Date.now() }; - })); - } - _syncServer!.changeDoc(_connDocId, 'mark seeded', (d) => { - if (d.meta) (d.meta as any).seeded = true; - }); - - console.log("[Notes] Demo data seeded: 3 notebooks, 7 notes"); -} - -// ── Content extraction helpers ── - -/** Recursively extract plain text from a Tiptap JSON node tree. */ -function walkTiptapNodes(node: any): string { - if (node.text) return node.text; - if (!node.content) return ''; - return node.content.map(walkTiptapNodes).join(node.type === 'paragraph' ? '\n' : ''); -} - -/** Extract plain text from content, handling both HTML and tiptap-json formats. */ -function extractPlainText(content: string, format?: string): string { - if (format === 'tiptap-json') { - try { - const doc = JSON.parse(content); - return walkTiptapNodes(doc).trim(); - } catch { return ''; } - } - // Legacy HTML stripping - return content.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); -} - -// ── Notebooks API ── - -// GET /api/notebooks — list notebooks -routes.get("/api/notebooks", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - - const notebooks = listNotebooks(dataSpace).map(({ doc }) => notebookToRest(doc)); - notebooks.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); - return c.json({ notebooks, source: "automerge" }); -}); - -// POST /api/notebooks — create notebook -routes.post("/api/notebooks", 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); - let claims; - try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const body = await c.req.json(); - const { title, description, cover_color } = body; - - const nbTitle = title || "Untitled Notebook"; - const notebookId = newId(); - const now = Date.now(); - - const doc = ensureDoc(dataSpace, notebookId); - _syncServer!.changeDoc(notebookDocId(dataSpace, notebookId), "Create notebook", (d) => { - d.notebook.id = notebookId; - d.notebook.title = nbTitle; - d.notebook.slug = slugify(nbTitle); - d.notebook.description = description || ""; - d.notebook.coverColor = cover_color || "#3b82f6"; - d.notebook.isPublic = false; - d.notebook.createdAt = now; - d.notebook.updatedAt = now; - }); - - const updatedDoc = _syncServer!.getDoc(notebookDocId(dataSpace, notebookId))!; - return c.json(notebookToRest(updatedDoc), 201); -}); - -// GET /api/notebooks/:id — notebook detail with notes -routes.get("/api/notebooks/:id", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - - const docId = notebookDocId(dataSpace, id); - const doc = _syncServer?.getDoc(docId); - if (!doc || !doc.notebook || !doc.notebook.title) { - return c.json({ error: "Notebook not found" }, 404); - } - - const nb = notebookToRest(doc); - const notes = Object.values(doc.items) - .map(noteToRest) - .sort((a, b) => { - if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1; - return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); - }); - return c.json({ ...nb, notes, source: "automerge" }); -}); - -// PUT /api/notebooks/:id — update notebook -routes.put("/api/notebooks/: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); - let claims; - try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const id = c.req.param("id"); - const body = await c.req.json(); - const { title, description, cover_color, is_public } = body; - - if (title === undefined && description === undefined && cover_color === undefined && is_public === undefined) { - return c.json({ error: "No fields to update" }, 400); - } - - const docId = notebookDocId(dataSpace, id); - const doc = _syncServer?.getDoc(docId); - if (!doc || !doc.notebook || !doc.notebook.title) { - return c.json({ error: "Notebook not found" }, 404); - } - - _syncServer!.changeDoc(docId, "Update notebook", (d) => { - if (title !== undefined) d.notebook.title = title; - if (description !== undefined) d.notebook.description = description; - if (cover_color !== undefined) d.notebook.coverColor = cover_color; - if (is_public !== undefined) d.notebook.isPublic = is_public; - d.notebook.updatedAt = Date.now(); - }); - - const updatedDoc = _syncServer!.getDoc(docId)!; - return c.json(notebookToRest(updatedDoc)); -}); - -// DELETE /api/notebooks/:id -routes.delete("/api/notebooks/:id", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - - const docId = notebookDocId(dataSpace, id); - const doc = _syncServer?.getDoc(docId); - if (!doc || !doc.notebook || !doc.notebook.title) { - return c.json({ error: "Notebook not found" }, 404); - } - - // Clear all items and blank the notebook title to mark as deleted. - // SyncServer has no removeDoc API, so we empty the doc instead. - _syncServer!.changeDoc(docId, "Delete notebook", (d) => { - for (const key of Object.keys(d.items)) { - delete d.items[key]; - } - d.notebook.title = ""; - d.notebook.updatedAt = Date.now(); - }); - - return c.json({ ok: true }); -}); - -// ── Notes API ── - -// GET /api/notes — list all notes -routes.get("/api/notes", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const { notebook_id, type, q, limit = "50", offset = "0" } = c.req.query(); - - let allNotes: ReturnType[] = []; - const notebooks = notebook_id - ? (() => { - const doc = _syncServer?.getDoc(notebookDocId(dataSpace, notebook_id)); - return doc ? [{ doc }] : []; - })() - : listNotebooks(dataSpace); - - for (const { doc } of notebooks) { - for (const item of Object.values(doc.items)) { - if (type && item.type !== type) continue; - if (q) { - const lower = q.toLowerCase(); - if (!item.title.toLowerCase().includes(lower) && !item.contentPlain.toLowerCase().includes(lower)) continue; - } - allNotes.push(noteToRest(item)); - } - } - - // Sort: pinned first, then by updated_at desc - allNotes.sort((a, b) => { - if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1; - return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); - }); - - const lim = Math.min(parseInt(limit), 100); - const off = parseInt(offset) || 0; - return c.json({ notes: allNotes.slice(off, off + lim), source: "automerge" }); -}); - -// POST /api/notes — create note -routes.post("/api/notes", 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); - let claims; - try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const body = await c.req.json(); - const { notebook_id, title, content, content_format, type, url, language, file_url, mime_type, file_size, duration, tags } = body; - - if (!title?.trim()) return c.json({ error: "Title is required" }, 400); - if (!notebook_id) return c.json({ error: "notebook_id is required" }, 400); - - const contentPlain = content ? extractPlainText(content, content_format) : ""; - - // Normalize tags - const tagNames: string[] = []; - if (tags && Array.isArray(tags)) { - for (const tagName of tags) { - const name = (tagName as string).trim().toLowerCase(); - if (name) tagNames.push(name); - } - } - - const noteId = newId(); - const item = createNoteItem(noteId, notebook_id, title.trim(), { - authorId: claims.sub ?? null, - content: content || "", - contentPlain, - type: type || "NOTE", - url: url || null, - language: language || null, - fileUrl: file_url || null, - mimeType: mime_type || null, - fileSize: file_size || null, - duration: duration || null, - tags: tagNames, - }); - - // Ensure the notebook doc exists, then add the note - ensureDoc(dataSpace, notebook_id); - const docId = notebookDocId(dataSpace, notebook_id); - _syncServer!.changeDoc(docId, `Create note: ${title.trim()}`, (d) => { - d.items[noteId] = item; - d.notebook.updatedAt = Date.now(); - }); - - // Notify space members about the new note - import('../rinbox/agent-notify').then(({ sendSpaceNotification }) => { - sendSpaceNotification(dataSpace, `New Note: ${title.trim()}`, - `

      ${title.trim()}

      ${contentPlain ? `

      ${contentPlain.slice(0, 200)}${contentPlain.length > 200 ? '...' : ''}

      ` : ''}

      View in rNotes

      ` - ).catch(() => {}); - }).catch(() => {}); - - return c.json(noteToRest(item), 201); -}); - -// GET /api/notes/:id — note detail -routes.get("/api/notes/:id", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - - const found = findNote(dataSpace, id); - if (!found) return c.json({ error: "Note not found" }, 404); - - return c.json({ ...noteToRest(found.item), source: "automerge" }); -}); - -// PUT /api/notes/:id — update note -routes.put("/api/notes/:id", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - const body = await c.req.json(); - const { title, content, content_format, type, url, language, is_pinned, sort_order } = body; - - if (title === undefined && content === undefined && type === undefined && - url === undefined && language === undefined && is_pinned === undefined && sort_order === undefined) { - return c.json({ error: "No fields to update" }, 400); - } - - const found = findNote(dataSpace, id); - if (!found) return c.json({ error: "Note not found" }, 404); - - const contentPlain = content !== undefined - ? extractPlainText(content, content_format || found.item.contentFormat) - : undefined; - - _syncServer!.changeDoc(found.docId, `Update note ${id}`, (d) => { - const item = d.items[id]; - if (!item) return; - if (title !== undefined) item.title = title; - if (content !== undefined) item.content = content; - if (contentPlain !== undefined) item.contentPlain = contentPlain; - if (content_format !== undefined) (item as any).contentFormat = content_format; - if (type !== undefined) item.type = type; - if (url !== undefined) item.url = url; - if (language !== undefined) item.language = language; - if (is_pinned !== undefined) item.isPinned = is_pinned; - if (sort_order !== undefined) item.sortOrder = sort_order; - item.updatedAt = Date.now(); - }); - - // Return the updated note - const updatedDoc = _syncServer!.getDoc(found.docId)!; - const updatedItem = updatedDoc.items[id]; - return c.json(noteToRest(updatedItem)); -}); - -// DELETE /api/notes/:id -routes.delete("/api/notes/:id", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - - const found = findNote(dataSpace, id); - if (!found) return c.json({ error: "Note not found" }, 404); - - _syncServer!.changeDoc(found.docId, `Delete note ${id}`, (d) => { - delete d.items[id]; - d.notebook.updatedAt = Date.now(); - }); - - return c.json({ ok: true }); -}); - -// ── 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, - convertedNotes: ConvertedNote[], -): { imported: number; updated: number } { - 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); - - _syncServer!.changeDoc(docId, `Import ${convertedNotes.length} notes`, (d) => { - for (const cn of convertedNotes) { - // Check if a note with the same sourceRef already exists (re-import) - let existingId: string | null = null; - if (cn.sourceRef) { - for (const [id, item] of Object.entries(d.items)) { - if (item.sourceRef?.source === cn.sourceRef.source && - item.sourceRef?.externalId === cn.sourceRef.externalId) { - existingId = id; - break; - } - } - } - - if (existingId) { - // Update existing note - const item = d.items[existingId]; - item.title = cn.title; - item.content = cn.content; - item.contentPlain = cn.contentPlain; - item.contentFormat = 'tiptap-json'; - item.tags = cn.tags; - item.sourceRef = cn.sourceRef; - item.updatedAt = Date.now(); - updated++; - } else { - // Create new note - const noteId = newId(); - d.items[noteId] = { - ...createNoteItem(noteId, notebookId, cn.title, { - content: cn.content, - contentPlain: cn.contentPlain, - contentFormat: 'tiptap-json', - tags: cn.tags, - type: cn.type || 'NOTE', - sourceRef: cn.sourceRef, - }), - }; - imported++; - } - } - d.notebook.updatedAt = Date.now(); - }); - - return { imported, updated }; -} - -/** Get connection tokens for a space. */ -function getConnectionDoc(space: string): ConnectionsDoc | null { - const docId = connectionsDocId(space); - return _syncServer?.getDoc(docId) || null; -} - -// POST /api/import/upload — ZIP upload for Logseq/Obsidian -routes.post("/api/import/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 verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const formData = await c.req.formData(); - const file = formData.get("file") as File | null; - const source = formData.get("source") as string; - const notebookId = formData.get("notebookId") as string | null; - - if (!file) return c.json({ error: "No file uploaded" }, 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); - if (!converter) return c.json({ error: `Unknown converter: ${source}` }, 400); - - const fileData = new Uint8Array(await file.arrayBuffer()); - const result = await converter.import({ fileData }); - - // Determine target notebook - let targetNotebookId = notebookId; - if (!targetNotebookId) { - // Create a new notebook with the import title - targetNotebookId = newId(); - const now = Date.now(); - ensureDoc(dataSpace, targetNotebookId); - _syncServer!.changeDoc(notebookDocId(dataSpace, targetNotebookId), "Create import notebook", (d) => { - d.notebook.id = targetNotebookId!; - d.notebook.title = result.notebookTitle; - d.notebook.slug = slugify(result.notebookTitle); - d.notebook.createdAt = now; - d.notebook.updatedAt = now; - }); - } - - const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, result.notes); - - return c.json({ - ok: true, - notebookId: targetNotebookId, - notebookTitle: result.notebookTitle, - imported, - updated, - warnings: result.warnings, - }); -}); - -// 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 verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const formData = await c.req.formData(); - const notebookId = formData.get("notebookId") as string | null; - const files: any[] = []; - for (const [key, value] of formData.entries()) { - if (key === 'files' && typeof value === 'object' && value !== null && 'arrayBuffer' in value) files.push(value); - } - if (files.length === 0) return c.json({ error: "No files uploaded" }, 400); - - const convertedNotes: ConvertedNote[] = []; - const warnings: string[] = []; - - for (const file of files) { - try { - const data = new Uint8Array(await file.arrayBuffer()); - const note = importFile(file.name, data, file.type || undefined); - convertedNotes.push(note); - } catch (err) { - warnings.push(`Failed to import ${file.name}: ${(err as Error).message}`); - } - } - - if (convertedNotes.length === 0) { - return c.json({ error: "No files could be imported", warnings }, 400); - } - - let targetNotebookId = notebookId; - if (!targetNotebookId) { - targetNotebookId = newId(); - const now = Date.now(); - ensureDoc(dataSpace, targetNotebookId); - _syncServer!.changeDoc(notebookDocId(dataSpace, targetNotebookId), "Create file import notebook", (d) => { - d.notebook.id = targetNotebookId!; - d.notebook.title = files.length === 1 ? files[0].name.replace(/\.[^.]+$/, '') : 'File Import'; - d.notebook.slug = slugify(d.notebook.title); - d.notebook.createdAt = now; - d.notebook.updatedAt = now; - }); - } - - const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, convertedNotes); - - return c.json({ ok: true, notebookId: targetNotebookId, imported, updated, warnings }); -}); - -// POST /api/import/notion — Import selected Notion pages -routes.post("/api/import/notion", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const body = await c.req.json(); - const { pageIds, notebookId, recursive } = body; - - if (!pageIds || !Array.isArray(pageIds) || pageIds.length === 0) { - return c.json({ error: "pageIds array is required" }, 400); - } - - const conn = getConnectionDoc(dataSpace); - if (!conn?.notion?.accessToken) { - return c.json({ error: "Notion not connected. Connect your Notion account first." }, 400); - } - - const converter = getConverter('notion')!; - const result = await converter.import({ - pageIds, - recursive: recursive || false, - accessToken: conn.notion.accessToken, - }); - - let targetNotebookId = notebookId; - if (!targetNotebookId) { - targetNotebookId = newId(); - const now = Date.now(); - ensureDoc(dataSpace, targetNotebookId); - _syncServer!.changeDoc(notebookDocId(dataSpace, targetNotebookId), "Create Notion import notebook", (d) => { - d.notebook.id = targetNotebookId!; - d.notebook.title = result.notebookTitle; - d.notebook.slug = slugify(result.notebookTitle); - d.notebook.createdAt = now; - d.notebook.updatedAt = now; - }); - } - - const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, result.notes); - - return c.json({ ok: true, notebookId: targetNotebookId, imported, updated, warnings: result.warnings }); -}); - -// POST /api/import/google-docs — Import selected Google Docs -routes.post("/api/import/google-docs", 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 verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const body = await c.req.json(); - const { docIds, notebookId } = body; - - if (!docIds || !Array.isArray(docIds) || docIds.length === 0) { - return c.json({ error: "docIds array is required" }, 400); - } - - const conn = getConnectionDoc(dataSpace); - if (!conn?.google?.accessToken) { - return c.json({ error: "Google not connected. Connect your Google account first." }, 400); - } - - const converter = getConverter('google-docs')!; - const result = await converter.import({ - pageIds: docIds, - accessToken: conn.google.accessToken, - }); - - let targetNotebookId = notebookId; - if (!targetNotebookId) { - targetNotebookId = newId(); - const now = Date.now(); - ensureDoc(dataSpace, targetNotebookId); - _syncServer!.changeDoc(notebookDocId(dataSpace, targetNotebookId), "Create Google Docs import notebook", (d) => { - d.notebook.id = targetNotebookId!; - d.notebook.title = result.notebookTitle; - d.notebook.slug = slugify(result.notebookTitle); - d.notebook.createdAt = now; - d.notebook.updatedAt = now; - }); - } - - const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, result.notes); - - return c.json({ ok: true, notebookId: targetNotebookId, imported, updated, warnings: result.warnings }); -}); - -// GET /api/import/notion/pages — Browse Notion pages for selection -routes.get("/api/import/notion/pages", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const conn = getConnectionDoc(dataSpace); - if (!conn?.notion?.accessToken) { - return c.json({ error: "Notion not connected" }, 400); - } - - try { - const res = await fetch("https://api.notion.com/v1/search", { - method: "POST", - headers: { - "Authorization": `Bearer ${conn.notion.accessToken}`, - "Notion-Version": "2022-06-28", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - filter: { property: "object", value: "page" }, - sort: { direction: "descending", timestamp: "last_edited_time" }, - page_size: 50, - }), - }); - if (!res.ok) return c.json({ error: "Failed to fetch Notion pages" }, 502); - - const data = await res.json() as any; - const pages = (data.results || []).map((p: any) => { - const titleProp = p.properties?.title || p.properties?.Name; - const title = titleProp?.title?.[0]?.plain_text || "Untitled"; - return { - id: p.id, - title, - lastEdited: p.last_edited_time, - icon: p.icon?.emoji || null, - }; - }); - - return c.json({ pages }); - } catch (err) { - return c.json({ error: (err as Error).message }, 500); - } -}); - -// GET /api/import/google-docs/list — Browse Google Docs for selection -routes.get("/api/import/google-docs/list", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const conn = getConnectionDoc(dataSpace); - if (!conn?.google?.accessToken) { - return c.json({ error: "Google not connected" }, 400); - } - - try { - const res = await fetch( - "https://www.googleapis.com/drive/v3/files?q=mimeType='application/vnd.google-apps.document'&orderBy=modifiedTime desc&pageSize=50&fields=files(id,name,modifiedTime)", - { - headers: { "Authorization": `Bearer ${conn.google.accessToken}` }, - } - ); - if (!res.ok) return c.json({ error: "Failed to fetch Google Docs" }, 502); - - const data = await res.json() as any; - const docs = (data.files || []).map((f: any) => ({ - id: f.id, - title: f.name, - lastModified: f.modifiedTime, - })); - - return c.json({ docs }); - } catch (err) { - return c.json({ error: (err as Error).message }, 500); - } -}); - -// GET /api/export/obsidian — Download Obsidian-format ZIP -routes.get("/api/export/obsidian", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const notebookId = c.req.query("notebookId"); - if (!notebookId) return c.json({ error: "notebookId is required" }, 400); - - const docId = notebookDocId(dataSpace, notebookId); - const doc = _syncServer?.getDoc(docId); - if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404); - - const notes = Object.values(doc.items); - const converter = getConverter('obsidian')!; - const result = await converter.export(notes, { notebookTitle: doc.notebook.title }); - - return new Response(result.data as unknown as BodyInit, { - headers: { - "Content-Type": result.mimeType, - "Content-Disposition": `attachment; filename="${result.filename}"`, - }, - }); -}); - -// GET /api/export/logseq — Download Logseq-format ZIP -routes.get("/api/export/logseq", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const notebookId = c.req.query("notebookId"); - if (!notebookId) return c.json({ error: "notebookId is required" }, 400); - - const docId = notebookDocId(dataSpace, notebookId); - const doc = _syncServer?.getDoc(docId); - if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404); - - const notes = Object.values(doc.items); - const converter = getConverter('logseq')!; - const result = await converter.export(notes, { notebookTitle: doc.notebook.title }); - - return new Response(result.data as unknown as BodyInit, { - headers: { - "Content-Type": result.mimeType, - "Content-Disposition": `attachment; filename="${result.filename}"`, - }, - }); -}); - -// GET /api/export/markdown — Download universal Markdown ZIP -routes.get("/api/export/markdown", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const notebookId = c.req.query("notebookId"); - const noteIds = c.req.query("noteIds"); - - let notes: NoteItem[] = []; - let title = "rNotes Export"; - - if (notebookId) { - const docId = notebookDocId(dataSpace, notebookId); - const doc = _syncServer?.getDoc(docId); - if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404); - notes = Object.values(doc.items); - title = doc.notebook.title; - } else if (noteIds) { - const ids = noteIds.split(",").map(id => id.trim()); - for (const id of ids) { - const found = findNote(dataSpace, id); - if (found) notes.push(found.item); - } - } else { - return c.json({ error: "notebookId or noteIds is required" }, 400); - } - - // Use obsidian converter for generic markdown export (it produces clean markdown + YAML frontmatter) - const converter = getConverter('obsidian')!; - const result = await converter.export(notes, { notebookTitle: title }); - - // Rename the file - const filename = `${title.replace(/\s+/g, '-').toLowerCase()}-markdown.zip`; - return new Response(result.data as unknown as BodyInit, { - headers: { - "Content-Type": "application/zip", - "Content-Disposition": `attachment; filename="${filename}"`, - }, - }); -}); - -// POST /api/export/notion — Push notes to Notion -routes.post("/api/export/notion", 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 verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const body = await c.req.json(); - const { notebookId, noteIds, parentId } = body; - - const conn = getConnectionDoc(dataSpace); - if (!conn?.notion?.accessToken) { - return c.json({ error: "Notion not connected" }, 400); - } - - let notes: NoteItem[] = []; - if (notebookId) { - const docId = notebookDocId(dataSpace, notebookId); - const doc = _syncServer?.getDoc(docId); - if (doc) notes = Object.values(doc.items); - } else if (noteIds && Array.isArray(noteIds)) { - for (const id of noteIds) { - const found = findNote(dataSpace, id); - if (found) notes.push(found.item); - } - } - - if (notes.length === 0) return c.json({ error: "No notes to export" }, 400); - - const converter = getConverter('notion')!; - const result = await converter.export(notes, { - accessToken: conn.notion.accessToken, - parentId, - }); - - const resultData = JSON.parse(new TextDecoder().decode(result.data)); - return c.json(resultData); -}); - -// POST /api/export/google-docs — Push notes to Google Docs -routes.post("/api/export/google-docs", 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 verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const body = await c.req.json(); - const { notebookId, noteIds, parentId } = body; - - const conn = getConnectionDoc(dataSpace); - if (!conn?.google?.accessToken) { - return c.json({ error: "Google not connected" }, 400); - } - - let notes: NoteItem[] = []; - if (notebookId) { - const docId = notebookDocId(dataSpace, notebookId); - const doc = _syncServer?.getDoc(docId); - if (doc) notes = Object.values(doc.items); - } else if (noteIds && Array.isArray(noteIds)) { - for (const id of noteIds) { - const found = findNote(dataSpace, id); - if (found) notes.push(found.item); - } - } - - if (notes.length === 0) return c.json({ error: "No notes to export" }, 400); - - const converter = getConverter('google-docs')!; - const result = await converter.export(notes, { - accessToken: conn.google.accessToken, - parentId, - }); - - const resultData = JSON.parse(new TextDecoder().decode(result.data)); - 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 verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const noteId = c.req.param("noteId"); - const found = findNote(dataSpace, noteId); - if (!found) return c.json({ error: "Note not found" }, 404); - - const note = found.item; - if (!note.sourceRef) return c.json({ error: "Note has no source reference" }, 400); - - const conn = getConnectionDoc(dataSpace); - let result; - - if (note.sourceRef.source === 'notion') { - if (!conn?.notion?.accessToken) return c.json({ error: "Notion not connected" }, 400); - result = await syncNotionNote(note, conn.notion.accessToken); - } else if (note.sourceRef.source === 'google-docs') { - if (!conn?.google?.accessToken) return c.json({ error: "Google not connected" }, 400); - result = await syncGoogleDocsNote(note, conn.google.accessToken); - } else { - return c.json({ error: `Sync not supported for source: ${note.sourceRef.source}` }, 400); - } - - // Apply sync result to Automerge doc - if (result.action === 'updated' && result.updatedContent) { - _syncServer!.changeDoc(found.docId, `Sync update ${noteId}`, (d) => { - const item = d.items[noteId]; - if (!item) return; - item.content = result.updatedContent!; - item.contentPlain = result.updatedPlain || ''; - item.contentFormat = 'tiptap-json'; - item.sourceRef!.contentHash = result.remoteHash || ''; - item.sourceRef!.lastSyncedAt = Date.now(); - item.sourceRef!.syncStatus = 'synced'; - item.updatedAt = Date.now(); - }); - } else if (result.action === 'conflict' && result.updatedContent) { - _syncServer!.changeDoc(found.docId, `Sync conflict ${noteId}`, (d) => { - const item = d.items[noteId]; - if (!item) return; - item.sourceRef!.syncStatus = 'conflict'; - item.conflictContent = result.updatedContent!; - }); - } - - return c.json({ ok: true, noteId, ...result }); -}); - -// POST /api/sync/notebook/:id — Sync all notes with sourceRef in a notebook -routes.post("/api/sync/notebook/:id", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const notebookId = c.req.param("id"); - const docId = notebookDocId(dataSpace, notebookId); - const doc = _syncServer?.getDoc(docId); - if (!doc) return c.json({ error: "Notebook not found" }, 404); - - const conn = getConnectionDoc(dataSpace); - const results: Record = {}; - let synced = 0, conflicts = 0, errors = 0; - - for (const [noteId, note] of Object.entries(doc.items)) { - if (!note.sourceRef) continue; - - let result; - if (note.sourceRef.source === 'notion' && conn?.notion?.accessToken) { - result = await syncNotionNote(note, conn.notion.accessToken); - } else if (note.sourceRef.source === 'google-docs' && conn?.google?.accessToken) { - result = await syncGoogleDocsNote(note, conn.google.accessToken); - } else { - continue; // Skip file-based sources (need ZIP re-upload) - } - - if (result.action === 'updated' && result.updatedContent) { - _syncServer!.changeDoc(docId, `Sync update ${noteId}`, (d) => { - const item = d.items[noteId]; - if (!item) return; - item.content = result.updatedContent!; - item.contentPlain = result.updatedPlain || ''; - item.contentFormat = 'tiptap-json'; - item.sourceRef!.contentHash = result.remoteHash || ''; - item.sourceRef!.lastSyncedAt = Date.now(); - item.sourceRef!.syncStatus = 'synced'; - item.updatedAt = Date.now(); - }); - synced++; - } else if (result.action === 'conflict') { - _syncServer!.changeDoc(docId, `Sync conflict ${noteId}`, (d) => { - const item = d.items[noteId]; - if (!item) return; - item.sourceRef!.syncStatus = 'conflict'; - item.conflictContent = result.updatedContent || ''; - }); - conflicts++; - } else if (result.action === 'error') { - errors++; - } - - results[noteId] = { action: result.action, error: result.error }; - } - - return c.json({ ok: true, notebookId, synced, conflicts, errors, results }); -}); - -// POST /api/sync/upload — Re-upload vault ZIP for file-based sync (Obsidian/Logseq) -routes.post("/api/sync/upload", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const formData = await c.req.formData(); - const file = formData.get("file") as File | null; - const notebookId = formData.get("notebookId") as string; - const source = formData.get("source") as 'obsidian' | 'logseq'; - - if (!file) return c.json({ error: "No file uploaded" }, 400); - if (!notebookId) return c.json({ error: "notebookId required" }, 400); - if (!source || !['obsidian', 'logseq'].includes(source)) { - return c.json({ error: "source must be 'obsidian' or 'logseq'" }, 400); - } - - const docId = notebookDocId(dataSpace, notebookId); - const doc = _syncServer?.getDoc(docId); - if (!doc) return c.json({ error: "Notebook not found" }, 404); - - const notes = Object.values(doc.items).filter(n => n.sourceRef?.source === source); - if (notes.length === 0) { - return c.json({ error: `No ${source} notes in this notebook` }, 400); - } - - const zipData = new Uint8Array(await file.arrayBuffer()); - const results = await syncFileBasedNotes(notes, zipData, source); - - let synced = 0, conflicts = 0; - for (const [noteId, result] of results) { - if (result.action === 'updated' && result.updatedContent) { - _syncServer!.changeDoc(docId, `Sync update ${noteId}`, (d) => { - const item = d.items[noteId]; - if (!item) return; - item.content = result.updatedContent!; - item.contentPlain = result.updatedPlain || ''; - item.contentFormat = 'tiptap-json'; - item.sourceRef!.contentHash = result.remoteHash || ''; - item.sourceRef!.lastSyncedAt = Date.now(); - item.sourceRef!.syncStatus = 'synced'; - item.updatedAt = Date.now(); - }); - synced++; - } else if (result.action === 'conflict' && result.updatedContent) { - _syncServer!.changeDoc(docId, `Sync conflict ${noteId}`, (d) => { - const item = d.items[noteId]; - if (!item) return; - item.sourceRef!.syncStatus = 'conflict'; - item.conflictContent = result.updatedContent || ''; - }); - conflicts++; - } - } - - return c.json({ ok: true, notebookId, synced, conflicts, total: notes.length }); -}); - -// GET /api/sync/status/:id — Get sync status for a notebook's notes -routes.get("/api/sync/status/:id", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - - const notebookId = c.req.param("id"); - const docId = notebookDocId(dataSpace, notebookId); - const doc = _syncServer?.getDoc(docId); - if (!doc) return c.json({ error: "Notebook not found" }, 404); - - const statuses: Record = {}; - let syncable = 0; - for (const [noteId, note] of Object.entries(doc.items)) { - if (!note.sourceRef) continue; - syncable++; - statuses[noteId] = { - source: note.sourceRef.source, - syncStatus: note.sourceRef.syncStatus, - lastSyncedAt: note.sourceRef.lastSyncedAt, - hasConflict: note.sourceRef.syncStatus === 'conflict', - }; - } - - return c.json({ notebookId, syncable, statuses }); -}); - -// GET /api/connections — Status of all integrations -routes.get("/api/connections", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const conn = getConnectionDoc(dataSpace); - - return c.json({ - notion: conn?.notion ? { - connected: true, - workspaceName: conn.notion.workspaceName, - connectedAt: conn.notion.connectedAt, - } : { connected: false }, - google: conn?.google ? { - connected: true, - email: conn.google.email, - connectedAt: conn.google.connectedAt, - } : { 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 { existsSync, mkdirSync } from "fs"; - -const UPLOAD_DIR = "/data/files/generated"; - -// POST /api/uploads — Upload a file (audio, image, etc.) -routes.post("/api/uploads", async (c) => { - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const formData = await c.req.formData(); - const file = formData.get("file") as File | null; - if (!file) return c.json({ error: "No file provided" }, 400); - - // Ensure upload dir exists - if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true }); - - const ext = file.name.split('.').pop() || 'bin'; - const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; - const filepath = join(UPLOAD_DIR, filename); - - const arrayBuffer = await file.arrayBuffer(); - await Bun.write(filepath, arrayBuffer); - - return c.json({ - url: `/data/files/generated/${filename}`, - mimeType: file.type || 'application/octet-stream', - size: file.size, - filename, - }, 201); -}); - -// GET /api/uploads/:filename — Serve uploaded file -routes.get("/api/uploads/:filename", async (c) => { - const filename = c.req.param("filename"); - // Path traversal protection - if (filename.includes('..') || filename.includes('/')) { - return c.json({ error: "Invalid filename" }, 400); - } - const filepath = join(UPLOAD_DIR, filename); - const file = Bun.file(filepath); - if (!(await file.exists())) return c.json({ error: "File not found" }, 404); - return new Response(file.stream(), { - headers: { "Content-Type": file.type || "application/octet-stream" }, - }); -}); - -// ── Voice transcription proxy ── - -// POST /api/voice/transcribe — Proxy to voice-command-api -routes.post("/api/voice/transcribe", async (c) => { - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - try { - const formData = await c.req.formData(); - const upstream = await fetch("http://voice-command-api:8000/api/voice/transcribe", { - method: "POST", - body: formData, - signal: AbortSignal.timeout(60000), - }); - if (!upstream.ok) return c.json({ error: "Transcription service error" }, 502); - const result = await upstream.json(); - return c.json(result); - } catch (err) { - return c.json({ error: "Voice service unavailable" }, 502); - } -}); - -// POST /api/voice/diarize — Proxy to voice-command-api (speaker diarization) -routes.post("/api/voice/diarize", async (c) => { - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - try { - const formData = await c.req.formData(); - const upstream = await fetch("http://voice-command-api:8000/api/voice/diarize", { - method: "POST", - body: formData, - signal: AbortSignal.timeout(120000), - }); - if (!upstream.ok) return c.json({ error: "Diarization service error" }, 502); - const result = await upstream.json(); - return c.json(result); - } catch (err) { - return c.json({ error: "Voice service unavailable" }, 502); - } -}); - -// ── Note summarization ── - -const NOTEBOOK_API_URL = process.env.NOTEBOOK_API_URL || "http://open-notebook:5055"; - -// POST /api/notes/summarize — Quick summarization via Gemini/Ollama -routes.post("/api/notes/summarize", async (c) => { - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const { content, model = "gemini-flash", length = "medium" } = await c.req.json<{ - content: string; model?: string; length?: "short" | "medium" | "long"; - }>(); - if (!content?.trim()) return c.json({ error: "Content is required" }, 400); - - const lengthGuide: Record = { - short: "1-2 sentences", - medium: "3-5 sentences (a short paragraph)", - long: "2-3 paragraphs with key points as bullet points", - }; - - const systemPrompt = `You are a note summarizer. Summarize the following note content in ${lengthGuide[length] || lengthGuide.medium}. Be concise, capture the key ideas, and preserve any action items or decisions. Do not add commentary — return only the summary.`; - - try { - // Use the internal /api/prompt endpoint - const origin = new URL(c.req.url).origin; - const res = await fetch(`${origin}/api/prompt`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model, - messages: [ - { role: "user", content: `${systemPrompt}\n\n---\n\n${content}` }, - ], - }), - }); - - if (!res.ok) { - const err = await res.json().catch(() => ({})); - return c.json({ error: (err as any).error || "Summarization failed" }, 502); - } - - const data = await res.json() as { content: string }; - return c.json({ summary: data.content, model }); - } catch (err) { - console.error("[Notes] Summarize error:", err); - return c.json({ error: "Summarization service unavailable" }, 502); - } -}); - -// POST /api/notes/deep-summarize — RAG-enhanced multi-note analysis via open-notebook -routes.post("/api/notes/deep-summarize", async (c) => { - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const { noteIds, query } = await c.req.json<{ noteIds: string[]; query?: string }>(); - if (!noteIds?.length) return c.json({ error: "noteIds required" }, 400); - - // Gather note contents from Automerge docs - const space = c.req.param("space") || "demo"; - const contents: { id: string; title: string; content: string }[] = []; - - if (_syncServer) { - // Scan all notebook docs for matching note IDs - const prefix = `${space}:notes:notebooks:`; - for (const [docId, doc] of (_syncServer as any)._docs?.entries?.() || []) { - if (typeof docId === 'string' && docId.startsWith(prefix)) { - const nbDoc = doc as import("./schemas").NotebookDoc; - if (!nbDoc?.items) continue; - for (const nid of noteIds) { - const item = nbDoc.items[nid]; - if (item) contents.push({ id: nid, title: item.title, content: item.contentPlain || item.content }); - } - } - } - } - - if (contents.length === 0) return c.json({ error: "No matching notes found" }, 404); - - try { - // Call open-notebook API for RAG-enhanced summary - const res = await fetch(`${NOTEBOOK_API_URL}/api/v1/summarize`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sources: contents.map(n => ({ title: n.title, content: n.content })), - query: query || "Provide a comprehensive summary of these notes, highlighting key themes, decisions, and action items.", - }), - signal: AbortSignal.timeout(120000), - }); - - if (!res.ok) { - console.error("[Notes] Deep summarize upstream error:", res.status); - return c.json({ error: "Deep summarization service error" }, 502); - } - - const result = await res.json() as { summary: string }; - return c.json({ summary: result.summary, sources: contents.map(n => n.id) }); - } catch (err) { - console.error("[Notes] Deep summarize error:", err); - return c.json({ error: "Deep summarization service unavailable" }, 502); - } -}); - -// POST /api/notes/send-to-notebook — Send note content to open-notebook for RAG indexing -routes.post("/api/notes/send-to-notebook", async (c) => { - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const { noteId, title, content } = await c.req.json<{ - noteId: string; title: string; content: string; - }>(); - if (!noteId || !content?.trim()) return c.json({ error: "noteId and content are required" }, 400); - - try { - const formData = new FormData(); - formData.append("type", "text"); - formData.append("content", content); - if (title) formData.append("title", title); - formData.append("async_processing", "true"); - - const res = await fetch(`${NOTEBOOK_API_URL}/api/sources`, { - method: "POST", - body: formData, - signal: AbortSignal.timeout(30000), - }); - - if (!res.ok) { - console.error("[Notes] Send to notebook upstream error:", res.status); - return c.json({ error: "Open notebook service error" }, 502); - } - - const result = await res.json() as { id: string; command_id?: string }; - return c.json({ sourceId: result.id, message: "Sent to open notebook for processing" }); - } catch (err) { - console.error("[Notes] Send to notebook error:", err); - return c.json({ error: "Open notebook service unavailable" }, 502); - } -}); - -// POST /api/articles/unlock — Find archived version of a paywalled article -routes.post("/api/articles/unlock", async (c) => { - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Unauthorized" }, 401); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const { url } = await c.req.json<{ url: string }>(); - if (!url) return c.json({ error: "Missing url" }, 400); - - try { - const result = await unlockArticle(url); - return c.json(result); - } catch (err) { - return c.json({ success: false, error: "Unlock failed" }, 500); - } -}); - -// ── Extension download ── - -// GET /extension/download — Serve the rNotes Chrome extension as a zip -routes.get("/extension/download", async (c) => { - const JSZip = (await import("jszip")).default; - const { readdir, readFile } = await import("fs/promises"); - const { join, resolve } = await import("path"); - - const extDir = resolve(import.meta.dir, "browser-extension"); - const zip = new JSZip(); - - async function addDir(dir: string, prefix: string) { - const entries = await readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = join(dir, entry.name); - const zipPath = prefix ? `${prefix}/${entry.name}` : entry.name; - if (entry.isDirectory()) { - await addDir(fullPath, zipPath); - } else { - const data = await readFile(fullPath); - zip.file(zipPath, data); - } - } - } - - try { - await addDir(extDir, ""); - const buf = await zip.generateAsync({ type: "arraybuffer" }); - return new Response(buf, { - headers: { - "Content-Type": "application/zip", - "Content-Disposition": 'attachment; filename="rnotes-extension.zip"', - }, - }); - } catch (err) { - console.error("[Notes] Extension zip error:", err); - return c.json({ error: "Extension files not found" }, 404); - } -}); - -// ── Page routes ── - -// GET /voice — Standalone voice recorder page -routes.get("/voice", (c) => { - const space = c.req.param("space") || "demo"; - return c.html(renderShell({ - title: `${space} — Voice Recorder | rSpace`, - moduleId: "rnotes", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: ``, - scripts: ``, - styles: ``, - })); -}); - -// GET /transcripts — Transcription app (alias for voice recorder) -routes.get("/transcripts", (c) => { - const space = c.req.param("space") || "demo"; - return c.html(renderShell({ - title: `${space} — Transcripts | rSpace`, - moduleId: "rnotes", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: ``, - scripts: ``, - styles: ``, - })); -}); +const routes = new Hono(); +// GET / — render landing or app shell routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - return c.html(renderShell({ - title: `${space} — Notes | rSpace`, - moduleId: "rnotes", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: ``, - scripts: ``, - styles: ``, - })); + if (!space || space === "rnotes.online") { + return c.html(renderLanding()); + } + return c.html( + renderShell({ + title: `${space} — rNotes | rSpace`, + moduleId: "rnotes", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: ``, + }), + ); }); +// POST /api/vault/upload — accept ZIP + metadata, parse, store +routes.post("/api/vault/upload", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Unauthorized" }, 401); + try { + await verifyToken(token); + } catch { + return c.json({ error: "Invalid token" }, 401); + } + + const space = c.req.param("space") || "demo"; + const dataSpace = resolveDataSpace("rnotes", space); + + let formData: FormData; + try { + formData = await c.req.formData(); + } catch { + return c.json({ error: "Expected multipart/form-data" }, 400); + } + + const file = formData.get("file") as File | null; + const name = (formData.get("name") as string | null) ?? "My Vault"; + const source = ((formData.get("source") as string | null) ?? "obsidian") as + | "obsidian" + | "logseq"; + + if (!file) return c.json({ error: "Missing file" }, 400); + if (!file.name.endsWith(".zip")) return c.json({ error: "File must be a .zip" }, 400); + + const buffer = await file.arrayBuffer(); + const vaultId = crypto.randomUUID(); + + // Parse ZIP metadata + let notes: ParsedNote[]; + try { + notes = await parseVaultZip(buffer, source); + } catch (err) { + return c.json({ error: `Failed to parse ZIP: ${(err as Error).message}` }, 400); + } + + if (notes.length === 0) { + return c.json({ error: "No markdown files found in ZIP" }, 400); + } + + // Store ZIP to disk + await mkdir(VAULT_UPLOAD_DIR, { recursive: true }); + const zipPath = join(VAULT_UPLOAD_DIR, `${vaultId}.zip`); + await writeFile(zipPath, Buffer.from(buffer)); + + // Build Automerge doc + const docId = vaultDocId(dataSpace, vaultId); + const now = Date.now(); + const totalSize = notes.reduce((acc, n) => acc + n.sizeBytes, 0); + + const doc = Automerge.change(Automerge.init(), "init vault", (d) => { + d.meta = { + module: "rnotes", + collection: "vaults", + version: 1, + spaceSlug: dataSpace, + createdAt: now, + }; + d.vault = { + id: vaultId, + name, + source, + totalNotes: notes.length, + totalSizeBytes: totalSize, + lastSyncedAt: now, + createdAt: now, + }; + d.notes = {}; + d.wikilinks = {}; + for (const n of notes) { + d.notes[n.path] = { + path: n.path, + title: n.title, + tags: n.tags, + contentHash: n.contentHash, + sizeBytes: n.sizeBytes, + lastModifiedAt: now, + syncStatus: "synced", + frontmatter: n.frontmatter as Record, + } satisfies VaultNoteMeta; + if (n.wikilinks.length > 0) { + d.wikilinks[n.path] = n.wikilinks; + } + } + }); + + _syncServer!.setDoc(docId, doc); + + return c.json({ + vaultId, + name, + source, + totalNotes: notes.length, + totalSizeBytes: totalSize, + }); +}); + +// GET /api/vault/list — list all vaults for a space +routes.get("/api/vault/list", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = resolveDataSpace("rnotes", space); + + const docIds = findVaultDocIds(dataSpace); + const vaults = docIds + .map((id) => _syncServer!.getDoc(id)) + .filter((d): d is VaultDoc => !!d) + .map((d) => d.vault); + + return c.json({ vaults }); +}); + +// GET /api/vault/:vaultId/status — sync status for a vault +routes.get("/api/vault/:vaultId/status", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = resolveDataSpace("rnotes", space); + const vaultId = c.req.param("vaultId"); + + const docId = vaultDocId(dataSpace, vaultId); + const doc = _syncServer!.getDoc(docId); + if (!doc) return c.json({ error: "Vault not found" }, 404); + + const notes = Object.values(doc.notes); + const synced = notes.filter((n) => n.syncStatus === "synced").length; + const conflicts = notes.filter((n) => n.syncStatus === "conflict").length; + const localModified = notes.filter((n) => n.syncStatus === "local-modified").length; + + return c.json({ + vaultId, + name: doc.vault.name, + source: doc.vault.source, + totalNotes: doc.vault.totalNotes, + lastSyncedAt: doc.vault.lastSyncedAt, + synced, + conflicts, + localModified, + }); +}); + +// GET /api/vault/:vaultId/notes — list notes (with ?folder= and ?search=) +routes.get("/api/vault/:vaultId/notes", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = resolveDataSpace("rnotes", space); + const vaultId = c.req.param("vaultId"); + const { folder, search } = c.req.query(); + + const docId = vaultDocId(dataSpace, vaultId); + const doc = _syncServer!.getDoc(docId); + if (!doc) return c.json({ error: "Vault not found" }, 404); + + let notes = Object.values(doc.notes); + + if (folder) { + const prefix = folder.endsWith("/") ? folder : `${folder}/`; + notes = notes.filter((n) => n.path.startsWith(prefix)); + } + if (search) { + const term = search.toLowerCase(); + notes = notes.filter( + (n) => + n.title.toLowerCase().includes(term) || + n.path.toLowerCase().includes(term) || + n.tags.some((t) => t.toLowerCase().includes(term)), + ); + } + + return c.json({ + vaultId, + total: notes.length, + notes: notes.sort((a, b) => b.lastModifiedAt - a.lastModifiedAt), + }); +}); + +// GET /api/vault/:vaultId/note/:path+ — read note content from ZIP +routes.get("/api/vault/:vaultId/note/*", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = resolveDataSpace("rnotes", space); + const vaultId = c.req.param("vaultId"); + + // Extract wildcard path segment after /note/ + const url = new URL(c.req.url); + const prefix = `/rnotes/api/vault/${vaultId}/note/`; + const altPrefix = `/api/vault/${vaultId}/note/`; + let notePath = url.pathname; + if (notePath.includes(prefix)) { + notePath = decodeURIComponent(notePath.slice(notePath.indexOf(prefix) + prefix.length)); + } else if (notePath.includes(altPrefix)) { + notePath = decodeURIComponent(notePath.slice(notePath.indexOf(altPrefix) + altPrefix.length)); + } + + const docId = vaultDocId(dataSpace, vaultId); + const doc = _syncServer!.getDoc(docId); + if (!doc) return c.json({ error: "Vault not found" }, 404); + if (!doc.notes[notePath]) return c.json({ error: "Note not found" }, 404); + + const zipPath = join(VAULT_UPLOAD_DIR, `${vaultId}.zip`); + let zipBuffer: Buffer; + try { + zipBuffer = await readFile(zipPath); + } catch { + return c.json({ error: "Vault ZIP not found on disk" }, 404); + } + + const zip = await JSZip.loadAsync(zipBuffer); + const zipEntry = zip.file(notePath); + if (!zipEntry) { + // Logseq may store under pages/ prefix + const altEntry = zip.file(`pages/${notePath}`); + if (!altEntry) return c.json({ error: "File not in ZIP" }, 404); + const content = await altEntry.async("string"); + return new Response(content, { + headers: { "Content-Type": "text/markdown; charset=utf-8" }, + }); + } + const content = await zipEntry.async("string"); + return new Response(content, { + headers: { "Content-Type": "text/markdown; charset=utf-8" }, + }); +}); + +// GET /api/vault/:vaultId/graph — wikilinks for graph visualization +routes.get("/api/vault/:vaultId/graph", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = resolveDataSpace("rnotes", space); + const vaultId = c.req.param("vaultId"); + + const docId = vaultDocId(dataSpace, vaultId); + const doc = _syncServer!.getDoc(docId); + if (!doc) return c.json({ error: "Vault not found" }, 404); + + // Build nodes + edges for graph viz + const notes = Object.values(doc.notes); + const nodeSet = new Set(notes.map((n) => n.path)); + const nodes = notes.map((n) => ({ + id: n.path, + label: n.title, + tags: n.tags, + })); + const edges: Array<{ source: string; target: string }> = []; + + for (const [sourcePath, targets] of Object.entries(doc.wikilinks)) { + for (const target of targets) { + // Try to find the matching note path (title match or path match) + const targetPath = + [...nodeSet].find( + (p) => + p === target || + p.replace(/\.md$/i, "") === target || + p.split("/").pop()?.replace(/\.md$/i, "") === target, + ) ?? target; + edges.push({ source: sourcePath, target: targetPath }); + } + } + + return c.json({ vaultId, nodes, edges }); +}); + +// DELETE /api/vault/:vaultId — remove vault doc + ZIP +routes.delete("/api/vault/:vaultId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Unauthorized" }, 401); + try { + await verifyToken(token); + } catch { + return c.json({ error: "Invalid token" }, 401); + } + + const space = c.req.param("space") || "demo"; + const dataSpace = resolveDataSpace("rnotes", space); + const vaultId = c.req.param("vaultId"); + + const docId = vaultDocId(dataSpace, vaultId); + const doc = _syncServer!.getDoc(docId); + if (!doc) return c.json({ error: "Vault not found" }, 404); + + // Remove Automerge doc + (_syncServer as any).deleteDoc?.(docId); + + // Remove ZIP from disk (best-effort) + const zipPath = join(VAULT_UPLOAD_DIR, `${vaultId}.zip`); + try { + await unlink(zipPath); + } catch { + // Ignore if already gone + } + + return c.json({ ok: true, vaultId }); +}); + +// ── Browser extension compat shims ── +// Old extension POSTed to /api/notes and GET /api/notebooks. +// Redirect to rdocs equivalents so existing installs keep working. + +routes.post("/api/notes", (c) => { + const space = c.req.param("space") || "demo"; + return c.redirect(`/${space}/rdocs/api/notes`, 301); +}); + +routes.get("/api/notebooks", (c) => { + const space = c.req.param("space") || "demo"; + return c.redirect(`/${space}/rdocs/api/notebooks`, 301); +}); + +// ── Module definition ── + export const notesModule: RSpaceModule = { id: "rnotes", name: "rNotes", - icon: "📝", - description: "Notebooks with rich-text notes, voice transcription, and collaboration", - scoping: { defaultScope: 'global', userConfigurable: true }, + icon: "🔗", + description: "Vault sync and browse for Obsidian and Logseq", + standaloneDomain: "rnotes.online", routes, - settingsSchema: [ - { - key: 'defaultNotebookId', - label: 'Default notebook for imports', - type: 'notebook-id', - description: 'Pre-selected notebook when importing from Logseq, Obsidian, or the web clipper', - }, - ], + scoping: { + defaultScope: "space", + userConfigurable: false, + }, docSchemas: [ { - pattern: '{space}:notes:notebooks:{notebookId}', - description: 'One Automerge doc per notebook, containing all notes as items', - init: notebookSchema.init, + pattern: "{space}:rnotes:vaults:{vaultId}", + description: "Vault metadata — notes, tags, wikilinks (content in ZIP on disk)", + init: vaultSchema.init, }, ], - seedTemplate: seedDemoIfEmpty, - async onInit({ syncServer }) { - _syncServer = syncServer; - // Seeding happens after loadAllDocs via seedTemplate, not here - console.log("[Notes] onInit complete (Automerge-only)"); - }, - - async onSpaceCreate(ctx: SpaceLifecycleContext) { - if (!_syncServer) return; - - // Create a default "My Notes" notebook doc, using resolved scope - const dataSpace = resolveDataSpace("rnotes", ctx.spaceSlug); - const notebookId = "default"; - const docId = notebookDocId(dataSpace, notebookId); - - if (_syncServer.getDoc(docId)) return; // already exists - - const doc = Automerge.init(); - const initialized = Automerge.change(doc, "Create default notebook", (d) => { - d.meta = { - module: "notes", - collection: "notebooks", - version: 1, - spaceSlug: dataSpace, - createdAt: Date.now(), - }; - d.notebook = { - id: notebookId, - title: "My Notes", - slug: "my-notes", - description: "Default notebook", - coverColor: "#3b82f6", - isPublic: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - d.items = {}; - }); - - _syncServer.setDoc(docId, initialized); - console.log(`[Notes] Created default notebook for space: ${ctx.spaceSlug}`); - }, - landingPage: renderLanding, - standaloneDomain: "rnotes.online", - feeds: [ - { - id: "notes-by-tag", - name: "Notes by Tag", - kind: "data", - description: "Stream of notes filtered by tag (design, architecture, etc.)", - emits: ["folk-markdown"], - filterable: true, - }, - { - id: "recent-notes", - name: "Recent Notes", - kind: "data", - description: "Latest notes across all notebooks", - emits: ["folk-markdown"], - }, - ], - acceptsFeeds: ["data", "resource"], - outputPaths: [ - { path: "notebooks", name: "Notebooks", icon: "📓", description: "Rich-text collaborative notebooks" }, - { path: "transcripts", name: "Transcripts", icon: "🎙️", description: "Voice transcription records" }, - { path: "articles", name: "Articles", icon: "📰", description: "Published articles and posts" }, - ], + onboardingActions: [ - { label: "Import from Obsidian or Logseq", icon: "📂", description: "Import markdown files from your vault", type: 'link', href: '/{space}/rnotes?action=import' }, - { label: "Import from Notion", icon: "📄", description: "Bring in pages from a Notion export", type: 'link', href: '/{space}/rnotes?action=import&source=notion' }, - { label: "Create a Notebook", icon: "✏️", description: "Start writing from scratch", type: 'create', href: '/{space}/rnotes' }, + { + label: "Upload Vault ZIP", + icon: "📦", + description: "Export your Obsidian or Logseq vault as a ZIP and upload it here", + type: "upload", + upload: { accept: ".zip", endpoint: "/api/vault/upload" }, + }, ], + + outputPaths: [ + { + path: "vaults", + name: "Vaults", + icon: "🗂️", + description: "Synced Obsidian and Logseq vaults", + }, + ], + + async onInit({ syncServer }) { + _syncServer = syncServer; + }, + + async onSpaceCreate(_ctx: SpaceLifecycleContext) { + // Vaults are user-uploaded — no auto-create needed + }, }; +export default notesModule; + // ── MI Integration ── -export interface MINoteItem { - id: string; +export interface MIVaultNote { title: string; - contentPlain: string; + path: string; + vaultName: string; tags: string[]; - type: string; - updatedAt: number; + lastModifiedAt: number; } /** - * Read recent notes directly from Automerge for the MI system prompt. + * Return recently modified vault notes for the MI system prompt. + * Queries all vault docs for the space and returns the most recent entries. */ -export function getRecentNotesForMI(space: string, limit = 3): MINoteItem[] { +export function getRecentVaultNotesForMI(space: string, limit = 10): MIVaultNote[] { if (!_syncServer) return []; - const allNotes: MINoteItem[] = []; - const prefix = `${space}:notes:notebooks:`; - for (const docId of _syncServer.listDocs()) { - if (!docId.startsWith(prefix)) continue; - const doc = _syncServer.getDoc(docId); - if (!doc?.items) continue; + const docIds = findVaultDocIds(space); + const allNotes: MIVaultNote[] = []; - for (const item of Object.values(doc.items)) { + for (const docId of docIds) { + const doc = _syncServer.getDoc(docId); + if (!doc) continue; + const vaultName = doc.vault.name; + for (const note of Object.values(doc.notes)) { allNotes.push({ - id: item.id, - title: item.title, - contentPlain: (item.contentPlain || "").slice(0, 300), - tags: item.tags ? Array.from(item.tags) : [], - type: item.type, - updatedAt: item.updatedAt, + title: note.title, + path: note.path, + vaultName, + tags: Array.from(note.tags), + lastModifiedAt: note.lastModifiedAt, }); } } return allNotes - .sort((a, b) => b.updatedAt - a.updatedAt) + .sort((a, b) => b.lastModifiedAt - a.lastModifiedAt) .slice(0, limit); } diff --git a/modules/rnotes/schemas.ts b/modules/rnotes/schemas.ts index 67acc56f..150af2ca 100644 --- a/modules/rnotes/schemas.ts +++ b/modules/rnotes/schemas.ts @@ -1,83 +1,43 @@ /** - * rNotes Automerge document schemas. + * rNotes Automerge document schemas — Vault Browser. * - * Granularity: one Automerge document per notebook. - * DocId format: {space}:notes:notebooks:{notebookId} + * rNotes is now a vault sync + browse module for Obsidian/Logseq vaults. + * Rich editing moved to rDocs. * - * The shape matches the PG→Automerge migration adapter - * (server/local-first/migration/pg-to-automerge.ts:notesMigration) - * and the client-side NotebookDoc type in folk-notes-app.ts. + * DocId format: {space}:rnotes:vaults:{vaultId} + * + * Storage model: + * - Automerge stores metadata (title, tags, hash, sync status) + * - ZIP vault files stored on disk at /data/files/uploads/vaults/ + * - Content served on demand from ZIP (not stored in CRDT) */ import type { DocSchema } from '../../shared/local-first/document'; -// ── Document types ── +// ── Vault note metadata (no content — lightweight) ── -export interface SourceRef { - 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 { - id: string; - authorId: string; - authorName: string; - text: string; - createdAt: number; -} - -export interface CommentThread { - id: string; - anchor: string; // serialized position info for the comment mark - resolved: boolean; - messages: CommentMessage[]; - createdAt: number; -} - -export interface NoteItem { - id: string; - notebookId: string; - authorId: string | null; +export interface VaultNoteMeta { + path: string; // relative path within vault (e.g. "daily/2026-04-10.md") title: string; - content: string; - contentPlain: string; - contentFormat?: 'html' | 'tiptap-json'; - type: 'NOTE' | 'CLIP' | 'BOOKMARK' | 'CODE' | 'IMAGE' | 'FILE' | 'AUDIO'; - url: string | null; - language: string | null; - fileUrl: string | null; - mimeType: string | null; - fileSize: number | null; - duration: number | null; - isPinned: boolean; - sortOrder: number; tags: string[]; - summary?: string; - summaryModel?: string; - openNotebookSourceId?: string; - sourceRef?: SourceRef; - conflictContent?: string; // Stores remote version on conflict - collabEnabled?: boolean; - comments?: Record; - createdAt: number; - updatedAt: number; + contentHash: string; // SHA-256 of file content for change detection + sizeBytes: number; + lastModifiedAt: number; // file mtime from vault + syncStatus: 'synced' | 'local-modified' | 'conflict'; + frontmatter?: Record; // parsed YAML frontmatter } -export interface NotebookMeta { +export interface VaultMeta { id: string; - title: string; - slug: string; - description: string; - coverColor: string; - isPublic: boolean; + name: string; + source: 'obsidian' | 'logseq'; + totalNotes: number; + totalSizeBytes: number; + lastSyncedAt: number; createdAt: number; - updatedAt: number; } -export interface NotebookDoc { +export interface VaultDoc { meta: { module: string; collection: string; @@ -85,112 +45,50 @@ export interface NotebookDoc { spaceSlug: string; createdAt: number; }; - notebook: NotebookMeta; - items: Record; + vault: VaultMeta; + notes: Record; // keyed by path + wikilinks: Record; // outgoing links per path } // ── Schema registration ── -export interface ConnectionsDoc { - meta: { - module: string; - collection: string; - version: number; - spaceSlug: string; - createdAt: number; - }; - notion?: { - accessToken: string; - workspaceId: string; - workspaceName: string; - connectedAt: number; - }; - google?: { - refreshToken: string; - accessToken: string; - expiresAt: number; - email: string; - connectedAt: number; - }; -} - -/** Generate a docId for a space's integration connections. */ -export function connectionsDocId(space: string) { - return `${space}:notes:connections` as const; -} - -export const notebookSchema: DocSchema = { - module: 'notes', - collection: 'notebooks', - version: 5, - init: (): NotebookDoc => ({ +export const vaultSchema: DocSchema = { + module: 'rnotes', + collection: 'vaults', + version: 1, + init: (): VaultDoc => ({ meta: { - module: 'notes', - collection: 'notebooks', - version: 5, + module: 'rnotes', + collection: 'vaults', + version: 1, spaceSlug: '', createdAt: Date.now(), }, - notebook: { + vault: { id: '', - title: 'Untitled Notebook', - slug: '', - description: '', - coverColor: '#3b82f6', - isPublic: false, + name: 'Untitled Vault', + source: 'obsidian', + totalNotes: 0, + totalSizeBytes: 0, + lastSyncedAt: Date.now(), createdAt: Date.now(), - updatedAt: Date.now(), }, - items: {}, + notes: {}, + wikilinks: {}, }), - migrate: (doc: NotebookDoc, fromVersion: number): NotebookDoc => { - if (fromVersion < 2) { - for (const item of Object.values(doc.items)) { - if (!(item as any).contentFormat) (item as any).contentFormat = 'html'; - } - } - // 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; - }, + migrate: (doc: VaultDoc, _fromVersion: number): VaultDoc => doc, }; // ── Helpers ── -/** Generate a docId for a notebook. */ -export function notebookDocId(space: string, notebookId: string) { - return `${space}:notes:notebooks:${notebookId}` as const; +/** Generate a docId for a vault. */ +export function vaultDocId(space: string, vaultId: string) { + return `${space}:rnotes:vaults:${vaultId}` as const; } -/** Create a fresh NoteItem with defaults. */ -export function createNoteItem( - id: string, - notebookId: string, - title: string, - opts: Partial = {}, -): NoteItem { - const now = Date.now(); - return { - id, - notebookId, - authorId: null, - title, - content: '', - contentPlain: '', - contentFormat: 'tiptap-json', - type: 'NOTE', - url: null, - language: null, - fileUrl: null, - mimeType: null, - fileSize: null, - duration: null, - isPinned: false, - sortOrder: 0, - tags: [], - createdAt: now, - updatedAt: now, - ...opts, - }; -} +// ── Legacy re-exports for backward compat ── +// The old rNotes schemas (NotebookDoc, NoteItem, etc.) are now in rdocs/schemas. +// Converters and MCP tools that still import from here should be updated. +// For now, re-export from rdocs to avoid breaking shared/converters/types.ts. + +export type { NoteItem, SourceRef } from '../rdocs/schemas'; diff --git a/modules/rnotes/yjs-ws-provider.ts b/modules/rnotes/yjs-ws-provider.ts deleted file mode 100644 index 12ef72f8..00000000 --- a/modules/rnotes/yjs-ws-provider.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Re-export from shared location. - * The Yjs provider is now shared across modules (rNotes, rInbox, etc.). - */ -export { RSpaceYjsProvider } from '../../shared/yjs-ws-provider'; diff --git a/server/mcp-tools/rnotes.ts b/server/mcp-tools/rnotes.ts index 9cb72e29..8a7a0cd2 100644 --- a/server/mcp-tools/rnotes.ts +++ b/server/mcp-tools/rnotes.ts @@ -1,114 +1,106 @@ /** - * MCP tools for rNotes (notebooks & notes). + * MCP tools for rNotes (vault browser — Obsidian/Logseq sync). * - * Tools: rnotes_list_notebooks, rnotes_list_notes, rnotes_get_note, - * rnotes_create_note, rnotes_update_note + * Tools: rnotes_list_vaults, rnotes_browse_vault, rnotes_search_vault, + * rnotes_get_vault_note, rnotes_sync_status */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; -import { notebookDocId, createNoteItem } from "../../modules/rnotes/schemas"; -import type { NotebookDoc, NoteItem } from "../../modules/rnotes/schemas"; +import { vaultDocId } from "../../modules/rnotes/schemas"; +import type { VaultDoc } from "../../modules/rnotes/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; +import { readFile } from "fs/promises"; +import { join } from "path"; -const NOTEBOOK_PREFIX = ":notes:notebooks:"; +const VAULT_PREFIX = ":rnotes:vaults:"; +const VAULT_DIR = "/data/files/uploads/vaults"; -/** Find all notebook docIds for a space. */ -function findNotebookDocIds(syncServer: SyncServer, space: string): string[] { - const prefix = `${space}${NOTEBOOK_PREFIX}`; +/** Find all vault docIds for a space. */ +function findVaultDocIds(syncServer: SyncServer, space: string): string[] { + const prefix = `${space}${VAULT_PREFIX}`; return syncServer.getDocIds().filter(id => id.startsWith(prefix)); } +/** Read a single file from a vault ZIP on disk. */ +async function readVaultFile(vaultId: string, filePath: string): Promise { + try { + const zipPath = join(VAULT_DIR, `${vaultId}.zip`); + const JSZip = (await import("jszip")).default; + const data = await readFile(zipPath); + const zip = await JSZip.loadAsync(data); + const entry = zip.file(filePath); + if (!entry) return null; + return await entry.async("string"); + } catch { + return null; + } +} + export function registerNotesTools(server: McpServer, syncServer: SyncServer) { server.tool( - "rnotes_list_notebooks", - "List all notebooks in a space", + "rnotes_list_vaults", + "List all synced vaults (Obsidian/Logseq) in a space", { space: z.string().describe("Space slug"), - token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), + token: z.string().optional().describe("JWT auth token"), }, async ({ space, token }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); - const docIds = findNotebookDocIds(syncServer, space); - const notebooks = []; + const docIds = findVaultDocIds(syncServer, space); + const vaults = []; for (const docId of docIds) { - const doc = syncServer.getDoc(docId); - if (!doc?.notebook) continue; - notebooks.push({ - id: doc.notebook.id, - title: doc.notebook.title, - slug: doc.notebook.slug, - description: doc.notebook.description, - noteCount: Object.keys(doc.items || {}).length, - createdAt: doc.notebook.createdAt, - updatedAt: doc.notebook.updatedAt, + const doc = syncServer.getDoc(docId); + if (!doc?.vault) continue; + vaults.push({ + id: doc.vault.id, + name: doc.vault.name, + source: doc.vault.source, + totalNotes: doc.vault.totalNotes, + lastSyncedAt: doc.vault.lastSyncedAt, + createdAt: doc.vault.createdAt, }); } - return { content: [{ type: "text", text: JSON.stringify(notebooks, null, 2) }] }; + return { content: [{ type: "text", text: JSON.stringify(vaults, null, 2) }] }; }, ); server.tool( - "rnotes_list_notes", - "List notes, optionally filtered by notebook, search text, or tags", + "rnotes_browse_vault", + "Browse notes in a vault, optionally filtered by folder path", { space: z.string().describe("Space slug"), - token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), - notebook_id: z.string().optional().describe("Filter by notebook ID"), - search: z.string().optional().describe("Search in title/content"), + token: z.string().optional().describe("JWT auth token"), + vault_id: z.string().describe("Vault ID"), + folder: z.string().optional().describe("Folder path prefix (e.g. 'daily/')"), limit: z.number().optional().describe("Max results (default 50)"), - tags: z.array(z.string()).optional().describe("Filter by tags"), }, - async ({ space, token, notebook_id, search, limit, tags }) => { + async ({ space, token, vault_id, folder, limit }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); - const docIds = notebook_id - ? [notebookDocId(space, notebook_id)] - : findNotebookDocIds(syncServer, space); + const doc = syncServer.getDoc(vaultDocId(space, vault_id)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] }; - let notes: Array = []; - for (const docId of docIds) { - const doc = syncServer.getDoc(docId); - if (!doc?.items) continue; - const nbTitle = doc.notebook?.title || "Untitled"; - for (const note of Object.values(doc.items)) { - notes.push({ ...JSON.parse(JSON.stringify(note)), notebookTitle: nbTitle }); - } + let notes = Object.values(doc.notes || {}); + if (folder) { + notes = notes.filter(n => n.path.startsWith(folder)); } - if (search) { - const q = search.toLowerCase(); - notes = notes.filter(n => - n.title.toLowerCase().includes(q) || - (n.contentPlain && n.contentPlain.toLowerCase().includes(q)), - ); - } - - if (tags && tags.length > 0) { - notes = notes.filter(n => - n.tags && tags.some(t => n.tags.includes(t)), - ); - } - - notes.sort((a, b) => b.updatedAt - a.updatedAt); + notes.sort((a, b) => b.lastModifiedAt - a.lastModifiedAt); const maxResults = limit || 50; notes = notes.slice(0, maxResults); const summary = notes.map(n => ({ - id: n.id, - notebookId: n.notebookId, - notebookTitle: n.notebookTitle, + path: n.path, title: n.title, - type: n.type, tags: n.tags, - isPinned: n.isPinned, - contentPreview: (n.contentPlain || "").slice(0, 200), - createdAt: n.createdAt, - updatedAt: n.updatedAt, + sizeBytes: n.sizeBytes, + lastModifiedAt: n.lastModifiedAt, + syncStatus: n.syncStatus, })); return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; @@ -116,117 +108,114 @@ export function registerNotesTools(server: McpServer, syncServer: SyncServer) { ); server.tool( - "rnotes_get_note", - "Get the full content of a specific note", + "rnotes_search_vault", + "Search notes across all vaults by title or tags", { space: z.string().describe("Space slug"), - token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), - note_id: z.string().describe("Note ID"), - notebook_id: z.string().optional().describe("Notebook ID (speeds up lookup)"), + token: z.string().optional().describe("JWT auth token"), + search: z.string().describe("Search term (matches title and tags)"), + vault_id: z.string().optional().describe("Limit to specific vault"), + limit: z.number().optional().describe("Max results (default 20)"), }, - async ({ space, token, note_id, notebook_id }) => { + async ({ space, token, search, vault_id, limit }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); - if (notebook_id) { - const doc = syncServer.getDoc(notebookDocId(space, notebook_id)); - const note = doc?.items?.[note_id]; - if (note) { - return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] }; - } - } + const docIds = vault_id + ? [vaultDocId(space, vault_id)] + : findVaultDocIds(syncServer, space); - for (const docId of findNotebookDocIds(syncServer, space)) { - const doc = syncServer.getDoc(docId); - const note = doc?.items?.[note_id]; - if (note) { - return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] }; - } - } - - return { content: [{ type: "text", text: JSON.stringify({ error: "Note not found" }) }] }; - }, - ); - - server.tool( - "rnotes_create_note", - "Create a new note in a notebook (requires auth token + space membership)", - { - space: z.string().describe("Space slug"), - token: z.string().describe("JWT auth token"), - notebook_id: z.string().describe("Target notebook ID"), - title: z.string().describe("Note title"), - content: z.string().optional().describe("Note content (plain text or HTML)"), - tags: z.array(z.string()).optional().describe("Note tags"), - }, - async ({ space, token, notebook_id, title, content, tags }) => { - const access = await resolveAccess(token, space, true); - if (!access.allowed) return accessDeniedResponse(access.reason!); - - const docId = notebookDocId(space, notebook_id); - const doc = syncServer.getDoc(docId); - if (!doc) { - return { content: [{ type: "text", text: JSON.stringify({ error: "Notebook not found" }) }], isError: true }; - } - - const noteId = `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const noteItem = createNoteItem(noteId, notebook_id, title, { - content: content || "", - contentPlain: content || "", - contentFormat: "html", - tags: tags || [], - }); - - syncServer.changeDoc(docId, `Create note ${title}`, (d) => { - if (!d.items) (d as any).items = {}; - d.items[noteId] = noteItem; - }); - - return { content: [{ type: "text", text: JSON.stringify({ id: noteId, created: true }) }] }; - }, - ); - - server.tool( - "rnotes_update_note", - "Update an existing note (requires auth token + space membership)", - { - space: z.string().describe("Space slug"), - token: z.string().describe("JWT auth token"), - note_id: z.string().describe("Note ID"), - notebook_id: z.string().optional().describe("Notebook ID (speeds up lookup)"), - title: z.string().optional().describe("New title"), - content: z.string().optional().describe("New content"), - tags: z.array(z.string()).optional().describe("New tags"), - is_pinned: z.boolean().optional().describe("Pin/unpin note"), - }, - async ({ space, token, note_id, notebook_id, ...updates }) => { - const access = await resolveAccess(token, space, true); - if (!access.allowed) return accessDeniedResponse(access.reason!); - - const docIds = notebook_id - ? [notebookDocId(space, notebook_id)] - : findNotebookDocIds(syncServer, space); + const q = search.toLowerCase(); + const results: Array<{ vaultName: string; path: string; title: string; tags: string[] }> = []; for (const docId of docIds) { - const doc = syncServer.getDoc(docId); - if (!doc?.items?.[note_id]) continue; - - syncServer.changeDoc(docId, `Update note ${note_id}`, (d) => { - const n = d.items[note_id]; - if (updates.title !== undefined) n.title = updates.title; - if (updates.content !== undefined) { - n.content = updates.content; - n.contentPlain = updates.content; + const doc = syncServer.getDoc(docId); + if (!doc?.notes) continue; + const vaultName = doc.vault?.name || "Unknown"; + for (const note of Object.values(doc.notes)) { + if ( + note.title.toLowerCase().includes(q) || + note.path.toLowerCase().includes(q) || + note.tags.some(t => t.toLowerCase().includes(q)) + ) { + results.push({ vaultName, path: note.path, title: note.title, tags: note.tags }); } - if (updates.tags !== undefined) n.tags = updates.tags; - if (updates.is_pinned !== undefined) n.isPinned = updates.is_pinned; - n.updatedAt = Date.now(); - }); - - return { content: [{ type: "text", text: JSON.stringify({ id: note_id, updated: true }) }] }; + } } - return { content: [{ type: "text", text: JSON.stringify({ error: "Note not found" }) }], isError: true }; + const maxResults = limit || 20; + return { content: [{ type: "text", text: JSON.stringify(results.slice(0, maxResults), null, 2) }] }; + }, + ); + + server.tool( + "rnotes_get_vault_note", + "Get the full content of a vault note (reads from disk ZIP)", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + vault_id: z.string().describe("Vault ID"), + path: z.string().describe("Note file path within vault"), + }, + async ({ space, token, vault_id, path: notePath }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(vaultDocId(space, vault_id)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] }; + + const meta = doc.notes?.[notePath]; + if (!meta) return { content: [{ type: "text", text: JSON.stringify({ error: "Note not found in vault" }) }] }; + + const content = await readVaultFile(vault_id, notePath); + if (content === null) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Could not read note from vault ZIP" }) }] }; + } + + return { + content: [{ + type: "text", + text: JSON.stringify({ ...meta, content }, null, 2), + }], + }; + }, + ); + + server.tool( + "rnotes_sync_status", + "Get sync status summary for a vault", + { + space: z.string().describe("Space slug"), + token: z.string().optional().describe("JWT auth token"), + vault_id: z.string().describe("Vault ID"), + }, + async ({ space, token, vault_id }) => { + const access = await resolveAccess(token, space, false); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(vaultDocId(space, vault_id)); + if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] }; + + const notes = Object.values(doc.notes || {}); + const synced = notes.filter(n => n.syncStatus === 'synced').length; + const modified = notes.filter(n => n.syncStatus === 'local-modified').length; + const conflicts = notes.filter(n => n.syncStatus === 'conflict').length; + + return { + content: [{ + type: "text", + text: JSON.stringify({ + vaultId: vault_id, + vaultName: doc.vault?.name, + source: doc.vault?.source, + totalNotes: notes.length, + synced, + modified, + conflicts, + lastSyncedAt: doc.vault?.lastSyncedAt, + }, null, 2), + }], + }; }, ); } diff --git a/server/mi-data-queries.ts b/server/mi-data-queries.ts index 042f2bca..4807c0a6 100644 --- a/server/mi-data-queries.ts +++ b/server/mi-data-queries.ts @@ -6,7 +6,7 @@ */ import { getUpcomingEventsForMI } from "../modules/rcal/mod"; -import { getRecentNotesForMI } from "../modules/rnotes/mod"; +import { getRecentVaultNotesForMI } from "../modules/rnotes/mod"; import { getRecentTasksForMI } from "../modules/rtasks/mod"; import { getRecentCampaignsForMI } from "../modules/rsocials/mod"; import { getRecentContactsForMI } from "../modules/rnetwork/mod"; @@ -61,12 +61,12 @@ export function queryModuleContent( ): MiQueryResult { switch (module) { case "rnotes": { - const notes = getRecentNotesForMI(space, limit); + const notes = getRecentVaultNotesForMI(space, limit); if (queryType === "count") { - return { ok: true, module, queryType, data: { count: notes.length }, summary: `${notes.length} recent notes found.` }; + return { ok: true, module, queryType, data: { count: notes.length }, summary: `${notes.length} vault notes found.` }; } - const lines = notes.map((n) => `- "${n.title}" (${n.type}, updated ${new Date(n.updatedAt).toLocaleDateString()})${n.tags.length ? ` [${n.tags.join(", ")}]` : ""}: ${n.contentPlain.slice(0, 100)}...`); - return { ok: true, module, queryType, data: notes, summary: lines.length ? `Recent notes:\n${lines.join("\n")}` : "No notes found." }; + const lines = notes.map((n) => `- "${n.title}" in ${n.vaultName} (${n.path})${n.tags.length ? ` [${n.tags.join(", ")}]` : ""}`); + return { ok: true, module, queryType, data: notes, summary: lines.length ? `Vault notes:\n${lines.join("\n")}` : "No vault notes found." }; } case "rtasks": { diff --git a/server/mi-routes.ts b/server/mi-routes.ts index 193e2db2..b4013c86 100644 --- a/server/mi-routes.ts +++ b/server/mi-routes.ts @@ -19,7 +19,7 @@ import type { EncryptIDClaims } from "./auth"; import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes"; import type { MiAction } from "../lib/mi-actions"; import { getUpcomingEventsForMI } from "../modules/rcal/mod"; -import { getRecentNotesForMI } from "../modules/rnotes/mod"; +import { getRecentVaultNotesForMI } from "../modules/rnotes/mod"; import { getRecentTasksForMI } from "../modules/rtasks/mod"; import { getRecentCampaignsForMI } from "../modules/rsocials/mod"; import { getRecentContactsForMI } from "../modules/rnetwork/mod"; @@ -234,12 +234,12 @@ mi.post("/ask", async (c) => { calendarContext = `\n- Upcoming events (next 14 days):\n${lines.join("\n")}`; } - const recentNotes = getRecentNotesForMI(space, 3); - if (recentNotes.length > 0) { - const lines = recentNotes.map((n) => - `- "${n.title}" (${n.type}${n.tags.length ? `, tags: ${n.tags.join(", ")}` : ""}): ${n.contentPlain.slice(0, 100)}…` + const vaultNotes = getRecentVaultNotesForMI(space, 3); + if (vaultNotes.length > 0) { + const lines = vaultNotes.map((n) => + `- "${n.title}" in ${n.vaultName} (${n.path})${n.tags.length ? ` [${n.tags.join(", ")}]` : ""}` ); - notesContext = `\n- Recent notes:\n${lines.join("\n")}`; + notesContext = `\n- Recent vault notes:\n${lines.join("\n")}`; } const openTasks = getRecentTasksForMI(space, 5); @@ -885,12 +885,12 @@ mi.post("/suggestions", async (c) => { // Check if current module has zero content — "get started" suggestion if (currentModule === "rnotes") { - const notes = getRecentNotesForMI(space, 1); - if (notes.length === 0) { + const vaults = getRecentVaultNotesForMI(space, 1); + if (vaults.length === 0) { suggestions.push({ - label: "Create your first note", - icon: "📝", - prompt: "Help me create my first notebook", + label: "Upload your first vault", + icon: "🔗", + prompt: "Help me upload my Obsidian or Logseq vault", autoSend: true, }); } @@ -928,12 +928,12 @@ mi.post("/suggestions", async (c) => { // Recent note/doc to continue editing if (currentModule === "rnotes") { - const recent = getRecentNotesForMI(space, 1); + const recent = getRecentVaultNotesForMI(space, 1); if (recent.length > 0) { suggestions.push({ - label: `Continue "${recent[0].title}"`, - icon: "📝", - prompt: `Help me continue working on "${recent[0].title}"`, + label: `Browse "${recent[0].title}"`, + icon: "🔗", + prompt: `Show me the note "${recent[0].title}" from ${recent[0].vaultName}`, autoSend: true, }); } diff --git a/shared/components/rstack-mi.ts b/shared/components/rstack-mi.ts index d5f8b6ba..1615be0e 100644 --- a/shared/components/rstack-mi.ts +++ b/shared/components/rstack-mi.ts @@ -11,6 +11,7 @@ import type { MiAction } from "../../lib/mi-actions"; import { MiActionExecutor } from "../../lib/mi-action-executor"; import { suggestTools, type ToolHint } from "../../lib/mi-tool-schema"; import { SpeechDictation } from "../../lib/speech-dictation"; +import { MiVoiceBridge, type VoiceState } from "../../lib/mi-voice-bridge"; import { getContextSuggestions } from "../../lib/mi-suggestions"; import type { MiSuggestion } from "../../lib/mi-suggestions"; @@ -45,6 +46,12 @@ export class RStackMi extends HTMLElement { #dynamicSuggestions: MiSuggestion[] = []; #placeholderIdx = 0; #placeholderTimer: ReturnType | null = null; + #voiceMode = false; + #voiceState: VoiceState = "idle"; + #voiceBridge: MiVoiceBridge | null = null; + #voiceDictation: SpeechDictation | null = null; + #voiceAccumulated = ""; + #voiceSilenceTimer: ReturnType | null = null; constructor() { super(); @@ -63,6 +70,7 @@ export class RStackMi extends HTMLElement { disconnectedCallback() { document.removeEventListener("keydown", this.#keyHandler); if (this.#placeholderTimer) clearInterval(this.#placeholderTimer); + if (this.#voiceMode) this.#deactivateVoiceMode(); } #keyHandler = (e: KeyboardEvent) => { @@ -147,6 +155,7 @@ export class RStackMi extends HTMLElement { ${SpeechDictation.isSupported() ? '' : ''} + ${SpeechDictation.isSupported() ? '' : ''}
      @@ -154,6 +163,7 @@ export class RStackMi extends HTMLElement { mi
      + ${SpeechDictation.isSupported() ? '' : ''}
      @@ -177,6 +187,12 @@ export class RStackMi extends HTMLElement {
      +
      @@ -250,6 +266,7 @@ export class RStackMi extends HTMLElement { // Close panel on outside click — use composedPath to pierce Shadow DOM document.addEventListener("pointerdown", (e) => { + if (this.#voiceMode) return; // Keep panel open during voice conversation const path = e.composedPath(); if (!path.includes(this)) { panel.classList.remove("open"); @@ -324,6 +341,7 @@ export class RStackMi extends HTMLElement { micBtn.addEventListener("click", (e) => { e.stopPropagation(); + if (this.#voiceMode) return; // Bar mic disabled during voice mode if (!this.#dictation!.isRecording) { baseText = barInput.value; } @@ -331,6 +349,24 @@ export class RStackMi extends HTMLElement { barInput.focus(); }); } + + // Voice mode buttons + const voiceBarBtn = this.#shadow.getElementById("mi-voice-btn"); + const voicePanelBtn = this.#shadow.getElementById("mi-voice-panel-btn"); + const voiceStopBtn = this.#shadow.getElementById("mi-voice-stop"); + + voiceBarBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#toggleVoiceMode(); + }); + voicePanelBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#toggleVoiceMode(); + }); + voiceStopBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#deactivateVoiceMode(); + }); } #loadSuggestions() { @@ -440,6 +476,184 @@ export class RStackMi extends HTMLElement { }, 8000); } + // ── Voice conversation mode ── + + #toggleVoiceMode() { + if (this.#voiceMode) { + this.#deactivateVoiceMode(); + } else { + this.#activateVoiceMode(); + } + } + + #activateVoiceMode() { + // Stop existing bar dictation if recording + if (this.#dictation?.isRecording) this.#dictation.stop(); + + this.#voiceMode = true; + this.#voiceAccumulated = ""; + + // Create TTS bridge + this.#voiceBridge = new MiVoiceBridge({ + onStateChange: (s) => this.#voiceSetState(s), + }); + + // Create dedicated voice dictation instance + this.#voiceDictation = new SpeechDictation({ + onInterim: (text) => { + const interimEl = this.#shadow.getElementById("mi-voice-interim"); + if (interimEl) interimEl.textContent = text; + // Interrupt TTS if user starts speaking + if (this.#voiceState === "speaking") { + this.#voiceBridge?.stop(); + this.#voiceSetState("listening"); + } + }, + onFinal: (text) => { + this.#voiceAccumulated += (this.#voiceAccumulated ? " " : "") + text; + const interimEl = this.#shadow.getElementById("mi-voice-interim"); + if (interimEl) interimEl.textContent = this.#voiceAccumulated; + // Reset silence timer + this.#resetSilenceTimer(); + }, + onStateChange: () => {}, + onError: (err) => console.warn("Voice dictation:", err), + }); + + // Open panel, show strip, start listening + const panel = this.#shadow.getElementById("mi-panel")!; + const bar = this.#shadow.getElementById("mi-bar")!; + panel.classList.add("open"); + bar.classList.add("focused"); + this.#shadow.getElementById("mi-voice-strip")!.style.display = "flex"; + + this.#voiceSetState("listening"); + this.#voiceDictation.start(); + } + + #deactivateVoiceMode() { + this.#voiceMode = false; + + // Clear silence timer + if (this.#voiceSilenceTimer) { + clearTimeout(this.#voiceSilenceTimer); + this.#voiceSilenceTimer = null; + } + + // Stop dictation and TTS + this.#voiceDictation?.destroy(); + this.#voiceDictation = null; + this.#voiceBridge?.stop(); + this.#voiceBridge?.destroy(); + this.#voiceBridge = null; + this.#voiceAccumulated = ""; + + // Hide strip, reset UI + this.#shadow.getElementById("mi-voice-strip")!.style.display = "none"; + this.#voiceSetState("idle"); + } + + #voiceSetState(state: VoiceState) { + this.#voiceState = state; + + const strip = this.#shadow.getElementById("mi-voice-strip"); + const label = this.#shadow.getElementById("mi-voice-label"); + const barBtn = this.#shadow.getElementById("mi-voice-btn"); + const panelBtn = this.#shadow.getElementById("mi-voice-panel-btn"); + + // Update strip + if (strip) { + strip.classList.remove("vs-listening", "vs-thinking", "vs-speaking"); + if (state !== "idle") strip.classList.add(`vs-${state}`); + } + if (label) { + const labels: Record = { + idle: "", + listening: "Listening...", + thinking: "Thinking...", + speaking: "Speaking...", + }; + label.textContent = labels[state]; + } + + // Update buttons + for (const btn of [barBtn, panelBtn]) { + if (!btn) continue; + btn.classList.remove("v-listening", "v-thinking", "v-speaking", "v-active"); + if (this.#voiceMode) { + btn.classList.add("v-active"); + if (state !== "idle") btn.classList.add(`v-${state}`); + } + } + } + + #resetSilenceTimer() { + if (this.#voiceSilenceTimer) clearTimeout(this.#voiceSilenceTimer); + this.#voiceSilenceTimer = setTimeout(() => { + if (this.#voiceMode && this.#voiceAccumulated.trim()) { + this.#voiceSubmit(); + } + }, 1500); + } + + async #voiceSubmit() { + const query = this.#voiceAccumulated.trim(); + if (!query || !this.#voiceMode) return; + + this.#voiceAccumulated = ""; + const interimEl = this.#shadow.getElementById("mi-voice-interim"); + if (interimEl) interimEl.textContent = ""; + + // Stop listening while processing (echo prevention) + this.#voiceDictation?.stop(); + this.#voiceSetState("thinking"); + + // Submit query through existing #ask flow + await this.#ask(query); + + if (!this.#voiceMode) return; // Deactivated during ask + + // Get the last assistant message for TTS + const lastMsg = [...this.#messages].reverse().find((m) => m.role === "assistant"); + if (lastMsg?.content && this.#voiceBridge) { + this.#voiceSetState("speaking"); + const ttsText = this.#stripForTTS(lastMsg.content); + await this.#voiceBridge.speak(ttsText); + } + + // Resume listening + if (this.#voiceMode) { + this.#voiceSetState("listening"); + this.#voiceDictation?.start(); + } + } + + #stripForTTS(text: string): string { + let stripped = text + // Remove code blocks + .replace(/```[\s\S]*?```/g, "... code block omitted ...") + // Remove inline code + .replace(/`[^`]+`/g, "") + // Remove markdown formatting + .replace(/[*_#]+/g, "") + // Remove links, keep label + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + // Remove action indicators + .replace(/\n\*[⏳✓✗].*?\*\n?/g, " ") + // Remove turn indicators + .replace(/\n\*— thinking.*?—\*\n?/g, " ") + // Collapse whitespace + .replace(/\s+/g, " ") + .trim(); + + // Truncate to ~4 sentences for snappy TTS + const sentences = stripped.match(/[^.!?]+[.!?]+/g) || [stripped]; + if (sentences.length > 4) { + stripped = sentences.slice(0, 4).join(" ").trim(); + } + return stripped; + } + #minimize() { this.#minimized = true; const panel = this.#shadow.getElementById("mi-panel")!; @@ -1099,6 +1313,90 @@ const STYLES = ` -webkit-background-clip: text; -webkit-text-fill-color: transparent; } +/* ── Voice mode buttons ── */ +.mi-voice-btn, .mi-voice-panel-btn { + background: none; border: none; cursor: pointer; padding: 2px 4px; + font-size: 0.85rem; border-radius: 6px; transition: all 0.2s; + flex-shrink: 0; line-height: 1; opacity: 0.7; +} +.mi-voice-btn:hover, .mi-voice-panel-btn:hover { background: var(--rs-bg-hover); opacity: 1; } +.mi-voice-btn.v-active, .mi-voice-panel-btn.v-active { opacity: 1; } +.mi-voice-btn.v-listening, .mi-voice-panel-btn.v-listening { + animation: voicePulseRed 1.5s infinite; +} +.mi-voice-btn.v-thinking, .mi-voice-panel-btn.v-thinking { + animation: voiceSpinAmber 2s linear infinite; +} +.mi-voice-btn.v-speaking, .mi-voice-panel-btn.v-speaking { + animation: voicePulseCyan 2s infinite; +} + +@keyframes voicePulseRed { + 0%, 100% { filter: drop-shadow(0 0 2px transparent); } + 50% { filter: drop-shadow(0 0 6px rgba(239,68,68,0.7)); } +} +@keyframes voiceSpinAmber { + 0% { filter: hue-rotate(0deg) drop-shadow(0 0 4px rgba(234,179,8,0.5)); } + 100% { filter: hue-rotate(360deg) drop-shadow(0 0 4px rgba(234,179,8,0.5)); } +} +@keyframes voicePulseCyan { + 0%, 100% { filter: drop-shadow(0 0 2px transparent); } + 50% { filter: drop-shadow(0 0 6px rgba(6,182,212,0.7)); } +} + +/* ── Voice strip ── */ +.mi-voice-strip { + display: flex; align-items: center; gap: 8px; + padding: 8px 14px; border-top: 1px solid var(--rs-border); + flex-shrink: 0; min-height: 36px; + background: rgba(239,68,68,0.06); + transition: background 0.3s; +} +.mi-voice-strip.vs-listening { background: rgba(239,68,68,0.06); } +.mi-voice-strip.vs-thinking { background: rgba(234,179,8,0.06); } +.mi-voice-strip.vs-speaking { background: rgba(6,182,212,0.06); } + +.mi-voice-waveform { + display: flex; align-items: center; gap: 2px; height: 18px; +} +.mi-voice-waveform span { + width: 3px; border-radius: 2px; + background: #ef4444; animation: voiceBar 1s ease-in-out infinite; +} +.vs-thinking .mi-voice-waveform span { background: #eab308; } +.vs-speaking .mi-voice-waveform span { background: #06b6d4; } +.mi-voice-waveform span:nth-child(1) { height: 6px; animation-delay: 0s; } +.mi-voice-waveform span:nth-child(2) { height: 12px; animation-delay: 0.1s; } +.mi-voice-waveform span:nth-child(3) { height: 18px; animation-delay: 0.2s; } +.mi-voice-waveform span:nth-child(4) { height: 12px; animation-delay: 0.3s; } +.mi-voice-waveform span:nth-child(5) { height: 6px; animation-delay: 0.4s; } + +@keyframes voiceBar { + 0%, 100% { transform: scaleY(0.4); } + 50% { transform: scaleY(1); } +} + +.mi-voice-label { + font-size: 0.75rem; font-weight: 600; white-space: nowrap; + color: var(--rs-text-muted); +} +.vs-listening .mi-voice-label { color: #ef4444; } +.vs-thinking .mi-voice-label { color: #eab308; } +.vs-speaking .mi-voice-label { color: #06b6d4; } + +.mi-voice-interim { + flex: 1; font-size: 0.78rem; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--rs-text-secondary); font-style: italic; +} + +.mi-voice-stop { + background: none; border: none; cursor: pointer; + font-size: 1rem; line-height: 1; padding: 2px 6px; border-radius: 4px; + color: var(--rs-text-muted); transition: all 0.15s; flex-shrink: 0; +} +.mi-voice-stop:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); } + @media (max-width: 640px) { .mi { max-width: none; width: 100%; } .mi-panel {