diff --git a/backlog/tasks/task-140 - IPFS-integration-for-backups-and-generated-files.md b/backlog/tasks/task-140 - IPFS-integration-for-backups-and-generated-files.md new file mode 100644 index 00000000..0ab3dc41 --- /dev/null +++ b/backlog/tasks/task-140 - IPFS-integration-for-backups-and-generated-files.md @@ -0,0 +1,43 @@ +--- +id: TASK-140 +title: IPFS integration for backups and generated files +status: Done +assignee: [] +created_date: '2026-04-02 22:11' +updated_date: '2026-04-02 22:11' +labels: + - infra + - ipfs + - storage +dependencies: [] +references: + - server/ipfs.ts + - server/ipfs-routes.ts + - server/local-first/backup-store.ts + - server/local-first/backup-routes.ts + - server/index.ts +priority: medium +--- + +## Description + + +Add IPFS as a redundant storage layer via Kubo (ipfs.jeffemmett.com). Pin encrypted backups and AI-generated files (images, 3D models, zines) to IPFS fire-and-forget. Filesystem remains primary — IPFS failures are non-fatal. API routes at /api/ipfs for status, pin/unpin, and gateway proxy. + + +## Acceptance Criteria + +- [x] #1 server/ipfs.ts client library with pin/unpin/status functions +- [x] #2 server/ipfs-routes.ts Hono router at /api/ipfs (status, pin, unpin, gateway proxy) +- [x] #3 Backup pinning in backup-store.ts (fire-and-forget, CID in manifest) +- [x] #4 IPFS URL route in backup-routes.ts (GET /:space/:docId/ipfs) +- [x] #5 Generated file pinning with .cid sidecar files for 8 producer endpoints +- [x] #6 IPFS_API_URL and IPFS_GATEWAY_URL env vars in docker-compose.yml +- [x] #7 Kubo reachable from rspace container via traefik-public network + + +## Final Summary + + +Implemented IPFS integration for rspace-online. Created server/ipfs.ts (client library) and server/ipfs-routes.ts (API routes at /api/ipfs). Modified backup-store.ts to pin encrypted backups fire-and-forget with CID stored in manifest. Added pinGeneratedFile() helper in server/index.ts called from 8 producer endpoints (3D models, fal.ai images, Gemini/Imagen images, zine pages). Each pinned file gets a .cid sidecar loaded into memory cache on startup. Kubo container is collab-server-ipfs-1 on traefik-public network. Deployed and verified on Netcup. Key deployment discovery: server uses local Gitea registry (localhost:3000/jeffemmett/rspace-online), not compose build — documented in MEMORY.md. + diff --git a/backlog/tasks/task-141 - SMS-text-based-poll-input-for-rSpace-magic-links-optional-Twilio-2-way.md b/backlog/tasks/task-141 - SMS-text-based-poll-input-for-rSpace-magic-links-optional-Twilio-2-way.md new file mode 100644 index 00000000..1314cd2a --- /dev/null +++ b/backlog/tasks/task-141 - SMS-text-based-poll-input-for-rSpace-magic-links-optional-Twilio-2-way.md @@ -0,0 +1,51 @@ +--- +id: TASK-141 +title: SMS/text-based poll input for rSpace (magic links + optional Twilio 2-way) +status: To Do +assignee: [] +created_date: '2026-04-09 16:42' +labels: + - rChoices + - rCal + - integration + - SMS +dependencies: [] +references: + - modules/rchoices/mod.ts + - modules/rcal/mod.ts + - modules/rschedule/mod.ts + - server/index.ts (webhook pattern examples around line 2808) +priority: high +--- + +## Description + + +Enable lightweight poll/RSVP responses via text message. Users send a text or click a magic link in SMS/email to respond to polls (e.g. "1" for Yes, "0" for No). + +## Phased approach: +1. **Phase 1 — Magic links (minimal infra):** Generate per-participant short token URLs for polls/RSVPs. Send via Mailcow email (free) or any SMS API. Recipient clicks link → minimal no-auth 1-tap response page → updates Automerge doc (rChoices poll or rCal RSVP). +2. **Phase 2 — Twilio 2-way SMS:** Twilio number (~$1/mo), outbound SMS with poll question + response codes, inbound webhook at `POST /api/sms/inbound` parses reply digit, phone→DID mapping table to attribute responses. + +## Key integration points: +- **rChoices** (`modules/rchoices/`) — simple polls (vote/rank/spider) +- **rCal** (`modules/rcal/`) — event RSVPs (attendee fields exist but stub) +- **rSchedule** (`modules/rschedule/`) — could add SMS as action type for scheduled sends +- **Existing webhook pattern** — follow payment webhook style (unauthenticated POST endpoints) + +## Design notes from initial discussion: +- Magic link approach gets 90% of value with minimal new infra +- Twilio costs ~$0.02/round-trip, magic links ~$0.01 outbound only +- Email-based variant is free via existing Mailcow setup +- Need phone→DID mapping if doing 2-way SMS + + +## Acceptance Criteria + +- [ ] #1 Magic link generation for polls/RSVPs with unique per-participant tokens +- [ ] #2 Minimal no-auth response page (1-tap Yes/No) that updates Automerge doc +- [ ] #3 Email delivery of magic links via Mailcow +- [ ] #4 Optional: Twilio outbound SMS delivery +- [ ] #5 Optional: Twilio inbound webhook parsing for 2-way SMS replies +- [ ] #6 Optional: Phone-to-DID mapping table for SMS identity attribution + diff --git a/backlog/tasks/task-high.7 - Intent-routed-resource-backed-commitments-for-rTime.md b/backlog/tasks/task-high.7 - Intent-routed-resource-backed-commitments-for-rTime.md new file mode 100644 index 00000000..4e00b2fb --- /dev/null +++ b/backlog/tasks/task-high.7 - Intent-routed-resource-backed-commitments-for-rTime.md @@ -0,0 +1,35 @@ +--- +id: TASK-HIGH.7 +title: Intent-routed resource-backed commitments for rTime +status: Done +assignee: [] +created_date: '2026-04-01 05:36' +updated_date: '2026-04-01 05:38' +labels: [] +dependencies: [] +parent_task_id: TASK-HIGH +--- + +## Description + + +Anoma-style intent routing integrated into rTime. Members declare needs/capacities as intents, solver finds collaboration clusters via Mycelium Clustering algorithm, settlement locks tokens via CRDT escrow. New files: schemas-intent.ts, solver.ts, settlement.ts, skill-curve.ts, reputation.ts, intent-routes.ts. Frontend: Collaborate tab with intent cards, solver results, accept/reject, skill prices, status rings on pool orbs. + + +## Acceptance Criteria + +- [x] #1 Intent CRUD routes working +- [x] #2 Solver produces valid cluster matches +- [x] #3 Settlement creates connections and tasks atomically +- [x] #4 Skill curve pricing responds to supply/demand +- [x] #5 Collaborate tab renders in frontend +- [x] #6 Status rings visible on pool orbs + + +## Implementation Notes + + +Committed 08cae26, pushed to Gitea/GitHub, merged dev→main, rebuild triggered on Netcup. + +Deployed to production. Commit 08cae26, built and running on Netcup. Live at rspace.online/{space}/rtime (Collaborate tab). + diff --git a/modules/rbnb/components/folk-bnb-view.ts b/modules/rbnb/components/folk-bnb-view.ts index 87d27094..e661cd81 100644 --- a/modules/rbnb/components/folk-bnb-view.ts +++ b/modules/rbnb/components/folk-bnb-view.ts @@ -11,6 +11,8 @@ import './folk-listing'; import './folk-stay-request'; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; import { getModuleApiBase } from "../../../shared/url-helpers"; +import type { DocumentId } from "../../../shared/local-first/document"; +import { bnbSchema, bnbDocId } from "../schemas"; const BNB_TOUR_STEPS: TourStep[] = [ { target: '.bnb-search', title: 'Search', message: 'Filter listings by location, type, or economy model.' }, @@ -68,16 +70,28 @@ class FolkBnbView extends HTMLElement { #mapContainer: HTMLElement | null = null; #tour: LightTourEngine | null = null; private _stopPresence: (() => void) | null = null; + private _offlineUnsub: (() => void) | null = null; connectedCallback() { this.#space = this.getAttribute('space') || 'demo'; this.#render(); this.#loadData(); this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rbnb', context: 'Listings' })); + if (this.#space !== 'demo') this.subscribeOffline(); } disconnectedCallback() { this._stopPresence?.(); + this._offlineUnsub?.(); this._offlineUnsub = null; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + try { + const docId = bnbDocId(this.#space) as DocumentId; + await runtime.subscribe(docId, bnbSchema); + } catch { /* runtime unavailable */ } } attributeChangedCallback(name: string, _old: string, val: string) { @@ -277,7 +291,7 @@ class FolkBnbView extends HTMLElement { const typeLabel = (l.type || 'room').replace(/_/g, ' '); return ` -
+
${typeIcon}
${this.#esc(l.title)}
diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index 0f844872..c25ade89 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -38,7 +38,7 @@ class FolkCartShop extends HTMLElement { private creatingPayment = false; private creatingGroupBuy = false; private _offlineUnsubs: (() => void)[] = []; - private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts"); + private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts", "rcart"); private _stopPresence: (() => void) | null = null; // Guided tour @@ -91,9 +91,12 @@ class FolkCartShop extends HTMLElement { setTimeout(() => this._tour.start(), 1200); } this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rcart', context: this.selectedCatalogItem?.title || this.view })); + window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener); } disconnectedCallback() { + this._history.destroy(); + window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); for (const unsub of this._offlineUnsubs) unsub(); this._offlineUnsubs = []; this._stopPresence?.(); @@ -539,6 +542,14 @@ class FolkCartShop extends HTMLElement { startTour() { this._tour.start(); } + private _onViewRestored = (e: CustomEvent) => { + if (e.detail?.moduleId !== 'rcart') return; + this.view = e.detail.view; + if (e.detail.view !== "cart-detail") this.selectedCartId = null; + if (e.detail.view !== "order-detail") this.selectedOrder = null; + this.render(); + }; + private goBack() { const prev = this._history.back(); if (!prev) return; diff --git a/modules/rchoices/components/folk-choices-dashboard.ts b/modules/rchoices/components/folk-choices-dashboard.ts index b28663ea..2e224c71 100644 --- a/modules/rchoices/components/folk-choices-dashboard.ts +++ b/modules/rchoices/components/folk-choices-dashboard.ts @@ -8,8 +8,10 @@ import { TourEngine } from "../../../shared/tour-engine"; import { ChoicesLocalFirstClient } from "../local-first-client"; import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas"; +import { choicesSchema, choicesDocId } from "../schemas"; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; import { getModuleApiBase, rspaceNavUrl, getCurrentSpace } from "../../../shared/url-helpers"; +import type { DocumentId } from "../../../shared/local-first/document"; // ── CrowdSurf types ── interface CrowdSurfOption { @@ -139,6 +141,7 @@ class FolkChoicesDashboard extends HTMLElement { } catch (err) { console.warn('[rChoices] Local-first init failed, falling back to API:', err); } + this.subscribeCollabOverlay(); // Also load canvas-based choices await this.loadChoices(); @@ -148,6 +151,15 @@ class FolkChoicesDashboard extends HTMLElement { this.bindLiveEvents(); } + private async subscribeCollabOverlay() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + try { + const docId = choicesDocId(this.space) as DocumentId; + await runtime.subscribe(docId, choicesSchema); + } catch { /* runtime unavailable */ } + } + private extractSessions(doc: ChoicesDoc) { this.sessions = doc.sessions ? Object.values(doc.sessions).sort((a, b) => b.createdAt - a.createdAt) : []; // Pre-compute votes per session diff --git a/modules/rdocs/browser-extension/background.js b/modules/rdocs/browser-extension/background.js new file mode 100644 index 00000000..a07f8b3d --- /dev/null +++ b/modules/rdocs/browser-extension/background.js @@ -0,0 +1,315 @@ +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/rdocs/browser-extension/icons/icon-128.png b/modules/rdocs/browser-extension/icons/icon-128.png new file mode 100644 index 00000000..1e296f93 Binary files /dev/null and b/modules/rdocs/browser-extension/icons/icon-128.png differ diff --git a/modules/rdocs/browser-extension/icons/icon-16.png b/modules/rdocs/browser-extension/icons/icon-16.png new file mode 100644 index 00000000..62b0620d Binary files /dev/null and b/modules/rdocs/browser-extension/icons/icon-16.png differ diff --git a/modules/rdocs/browser-extension/icons/icon-48.png b/modules/rdocs/browser-extension/icons/icon-48.png new file mode 100644 index 00000000..3851e827 Binary files /dev/null and b/modules/rdocs/browser-extension/icons/icon-48.png differ diff --git a/modules/rdocs/browser-extension/manifest.json b/modules/rdocs/browser-extension/manifest.json new file mode 100644 index 00000000..95f6da23 --- /dev/null +++ b/modules/rdocs/browser-extension/manifest.json @@ -0,0 +1,50 @@ +{ + "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/rdocs/browser-extension/options.html b/modules/rdocs/browser-extension/options.html new file mode 100644 index 00000000..99468401 --- /dev/null +++ b/modules/rdocs/browser-extension/options.html @@ -0,0 +1,231 @@ + + + + + + + +

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/rdocs/browser-extension/options.js b/modules/rdocs/browser-extension/options.js new file mode 100644 index 00000000..55858c52 --- /dev/null +++ b/modules/rdocs/browser-extension/options.js @@ -0,0 +1,179 @@ +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/rdocs/browser-extension/parakeet-offline.js b/modules/rdocs/browser-extension/parakeet-offline.js new file mode 100644 index 00000000..2aa4443f --- /dev/null +++ b/modules/rdocs/browser-extension/parakeet-offline.js @@ -0,0 +1,147 @@ +/** + * 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/rdocs/browser-extension/popup.html b/modules/rdocs/browser-extension/popup.html new file mode 100644 index 00000000..dcb72a9c --- /dev/null +++ b/modules/rdocs/browser-extension/popup.html @@ -0,0 +1,262 @@ + + + + + + + +
+ rNotes Clipper + ... +
+ + + +
+
Loading...
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ + + + + + diff --git a/modules/rdocs/browser-extension/popup.js b/modules/rdocs/browser-extension/popup.js new file mode 100644 index 00000000..4a9f1f7d --- /dev/null +++ b/modules/rdocs/browser-extension/popup.js @@ -0,0 +1,328 @@ +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/rdocs/browser-extension/voice.html b/modules/rdocs/browser-extension/voice.html new file mode 100644 index 00000000..0da0f251 --- /dev/null +++ b/modules/rdocs/browser-extension/voice.html @@ -0,0 +1,414 @@ + + + + + + + +
+ + rVoice + voice notes + + +
+ + + +
+
Ready
+ +
00:00
+
+ + Live transcribe +
+
+ +
+
Loading model...
+
+
+ +
+ +
+ +
+
Transcript
+
+ Transcribing... +
+
+ +
+ + +
+ + + +
+ +
+ Space to record · Esc to close · Offline ready +
+ + + + + diff --git a/modules/rdocs/browser-extension/voice.js b/modules/rdocs/browser-extension/voice.js new file mode 100644 index 00000000..9c94767f --- /dev/null +++ b/modules/rdocs/browser-extension/voice.js @@ -0,0 +1,610 @@ +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/rdocs/components/comment-mark.ts b/modules/rdocs/components/comment-mark.ts new file mode 100644 index 00000000..c27eec89 --- /dev/null +++ b/modules/rdocs/components/comment-mark.ts @@ -0,0 +1,49 @@ +/** + * 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/rdocs/components/comment-panel.ts b/modules/rdocs/components/comment-panel.ts new file mode 100644 index 00000000..d7f711f6 --- /dev/null +++ b/modules/rdocs/components/comment-panel.ts @@ -0,0 +1,918 @@ +/** + * — 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/rdocs/components/folk-docs-app.ts b/modules/rdocs/components/folk-docs-app.ts new file mode 100644 index 00000000..b4607dde --- /dev/null +++ b/modules/rdocs/components/folk-docs-app.ts @@ -0,0 +1,4373 @@ +/** + * — notebook and note management. + * + * Browse notebooks, create/edit notes with rich text (Tiptap), + * search, tag management. + * + * Notebook list: REST (GET /api/notebooks) + * Notebook detail + notes: Automerge sync via WebSocket + * Search: REST (GET /api/notes?q=...) + */ + +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'; + +const lowlight = createLowlight(common); + +/** 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: '', +}; + +interface Notebook { + id: string; + title: string; + description: string; + cover_color: string; + note_count: string; + updated_at: string; +} + +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; +} + +type NoteType = 'NOTE' | 'CODE' | 'BOOKMARK' | 'CLIP' | 'IMAGE' | 'AUDIO' | 'FILE'; + +interface CreateNoteOpts { + type?: NoteType; + title?: string; + url?: string; + fileUrl?: string; + mimeType?: string; + duration?: number; + language?: string; + content?: string; + tags?: string[]; +} + +/** 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; +} + +class FolkDocsApp 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 = ""; + + // 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; + + // Zone-based rendering + private navZone!: HTMLDivElement; + private contentZone!: HTMLDivElement; + private metaZone!: HTMLDivElement; + + // 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 }, + ]; + + // 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>(); + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open", delegatesFocus: true }); + this._tour = new TourEngine( + this.shadow, + FolkDocsApp.TOUR_STEPS, + "rdocs_tour_done", + () => this.shadow.host as HTMLElement, + ); + } + + 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("rdocs_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); + } + + private async subscribeOfflineRuntime() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + 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 || FolkDocsApp.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: rdocs is globally-scoped, so use 'global' prefix + const dataSpace = runtime?.isInitialized + ? (runtime.resolveDocSpace?.('rdocs') || 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 || FolkDocsApp.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?.('rdocs') || 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?.('rdocs') || 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 { + const path = window.location.pathname; + const match = path.match(/^(\/[^/]+)?\/rdocs/); + 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() { + 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); + } + }, 5000); + } + + private async loadNotebookREST(id: string) { + 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); + } + } catch { + this.error = "Failed to load notebook"; + } + this.loading = false; + this.render(); + } + + /** Fetch notes for a notebook (sidebar display). */ + private async fetchNotebookNotes(id: string) { + 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 = `rdocs:${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) } + ); + 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); + } + + /** 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; } + } else { + content = note.content; + } + } + + 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(); }, + }); + } + + /** 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; + + const label = document.createElement('span'); + label.className = 'collab-cursor-label'; + label.style.backgroundColor = user.color; + label.textContent = user.name; + cursor.appendChild(label); + + return cursor; + } + + /** 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 !== 'rdocs' || !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: 'rdocs', + 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: 'rdocs', + 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) => + ``; + return ` +
+
+ ${btn('bold', 'Bold (Ctrl+B)')} + ${btn('italic', 'Italic (Ctrl+I)')} + ${btn('underline', 'Underline (Ctrl+U)')} + ${btn('strike', 'Strikethrough')} + ${btn('code', 'Inline Code')} +
+
+
+ +
+
+
+ ${btn('bulletList', 'Bullet List')} + ${btn('orderedList', 'Numbered List')} + ${btn('taskList', 'Task List')} +
+
+
+ ${btn('blockquote', 'Blockquote')} + ${btn('codeBlock', 'Code Block')} + ${btn('horizontalRule', 'Divider')} +
+
+
+ ${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(); + + const popover = document.createElement('div'); + popover.className = 'url-popover'; + + 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); + } + }); + }); + } + + private attachToolbarListeners() { + const toolbar = this.shadow.getElementById('editor-toolbar'); + if (!toolbar || !this.editor) return; + + // 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; + 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; + } + }); + + // 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(); + } + }); + } + } + + /** 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); + }); + }); + + // 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); + }); + }); + + // 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: "rdocs", 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-rdocs-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-rdocs-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) => { + e.preventDefault(); + (el as HTMLElement).classList.remove("drop-target"); + const raw = e.dataTransfer?.getData("application/x-rdocs-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 {} + }); + }); + + // 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-rdocs-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-rdocs-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-docs-app", FolkDocsApp); diff --git a/modules/rdocs/components/folk-voice-recorder.ts b/modules/rdocs/components/folk-voice-recorder.ts new file mode 100644 index 00000000..df39d1ab --- /dev/null +++ b/modules/rdocs/components/folk-voice-recorder.ts @@ -0,0 +1,578 @@ +/** + * — 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/rdocs/components/import-export-dialog.ts b/modules/rdocs/components/import-export-dialog.ts new file mode 100644 index 00000000..a9eda96c --- /dev/null +++ b/modules/rdocs/components/import-export-dialog.ts @@ -0,0 +1,1003 @@ +/** + * — 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/rdocs/components/notes.css b/modules/rdocs/components/notes.css new file mode 100644 index 00000000..3b415915 --- /dev/null +++ b/modules/rdocs/components/notes.css @@ -0,0 +1,7 @@ +/* Notes module — dark theme (host-level styles) */ +folk-notes-app { + display: block; + min-height: 400px; + padding: 0; + position: relative; +} diff --git a/modules/rdocs/components/slash-command.ts b/modules/rdocs/components/slash-command.ts new file mode 100644 index 00000000..d751e571 --- /dev/null +++ b/modules/rdocs/components/slash-command.ts @@ -0,0 +1,308 @@ +/** + * 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/rdocs/components/suggestion-marks.ts b/modules/rdocs/components/suggestion-marks.ts new file mode 100644 index 00000000..b4a3678e --- /dev/null +++ b/modules/rdocs/components/suggestion-marks.ts @@ -0,0 +1,101 @@ +/** + * 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/rdocs/components/suggestion-plugin.ts b/modules/rdocs/components/suggestion-plugin.ts new file mode 100644 index 00000000..8fa42ae7 --- /dev/null +++ b/modules/rdocs/components/suggestion-plugin.ts @@ -0,0 +1,366 @@ +/** + * 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/rdocs/converters/evernote.ts b/modules/rdocs/converters/evernote.ts new file mode 100644 index 00000000..0aefb68e --- /dev/null +++ b/modules/rdocs/converters/evernote.ts @@ -0,0 +1,236 @@ +/** + * Evernote ENEX → rNotes converter. + * + * Import: Parse .enex XML (ENML — strict HTML subset inside ) + * Convert ENML → markdown via Turndown. + * Extract base64 attachments, save to /data/files/uploads/. + * File-based import (.enex), no auth needed. + */ + +import TurndownService from 'turndown'; +import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap'; +import { registerConverter, hashContent } from './index'; +import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; +import type { NoteItem } from '../schemas'; + +const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); + +// Custom Turndown rules for ENML-specific elements +turndown.addRule('enMedia', { + filter: (node) => node.nodeName === 'EN-MEDIA', + replacement: (_content, node) => { + const el = node as Element; + const hash = el.getAttribute('hash') || ''; + const type = el.getAttribute('type') || ''; + if (type.startsWith('image/')) { + return `![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/rdocs/converters/file-import.ts b/modules/rdocs/converters/file-import.ts new file mode 100644 index 00000000..0b9baf52 --- /dev/null +++ b/modules/rdocs/converters/file-import.ts @@ -0,0 +1,171 @@ +/** + * Generic file import for rNotes. + * + * Handles direct import of individual files: + * - .md / .txt → parse as markdown/text + * - .html → convert via Turndown + * - .jpg / .png / .webp / .gif → create IMAGE note with stored file + * + * All produce ConvertedNote with sourceRef.source = 'manual'. + */ + +import TurndownService from 'turndown'; +import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap'; +import { hashContent } from './index'; +import type { ConvertedNote } from './index'; + +const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); + +/** Dispatch file import by extension / MIME type. */ +export function importFile( + filename: string, + data: Uint8Array, + mimeType?: string, +): ConvertedNote { + const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); + const textContent = () => new TextDecoder().decode(data); + + if (ext === '.md' || ext === '.markdown') { + return importMarkdownFile(filename, textContent()); + } + if (ext === '.txt') { + return importTextFile(filename, textContent()); + } + if (ext === '.html' || ext === '.htm') { + return importHtmlFile(filename, textContent()); + } + if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'].includes(ext)) { + return importImageFile(filename, data, mimeType || guessMime(ext)); + } + + // Default: treat as text + try { + return importTextFile(filename, textContent()); + } catch { + // Binary file — store as FILE note + return importBinaryFile(filename, data, mimeType || 'application/octet-stream'); + } +} + +/** Import a markdown file. */ +export function importMarkdownFile(filename: string, content: string): ConvertedNote { + const title = titleFromFilename(filename); + const tiptapJson = markdownToTiptap(content); + const contentPlain = extractPlainTextFromTiptap(tiptapJson); + + return { + title, + content: tiptapJson, + contentPlain, + markdown: content, + tags: [], + sourceRef: { + source: 'manual', + externalId: `file:${filename}`, + lastSyncedAt: Date.now(), + contentHash: hashContent(content), + }, + }; +} + +/** Import a plain text file — wrap as simple note. */ +export function importTextFile(filename: string, content: string): ConvertedNote { + const title = titleFromFilename(filename); + const tiptapJson = markdownToTiptap(content); + const contentPlain = content; + + return { + title, + content: tiptapJson, + contentPlain, + markdown: content, + tags: [], + sourceRef: { + source: 'manual', + externalId: `file:${filename}`, + lastSyncedAt: Date.now(), + contentHash: hashContent(content), + }, + }; +} + +/** Import an HTML file — convert via Turndown. */ +export function importHtmlFile(filename: string, html: string): ConvertedNote { + const title = titleFromFilename(filename); + const markdown = turndown.turndown(html); + const tiptapJson = markdownToTiptap(markdown); + const contentPlain = extractPlainTextFromTiptap(tiptapJson); + + return { + title, + content: tiptapJson, + contentPlain, + markdown, + tags: [], + sourceRef: { + source: 'manual', + externalId: `file:${filename}`, + lastSyncedAt: Date.now(), + contentHash: hashContent(markdown), + }, + }; +} + +/** Import an image file — create IMAGE note with stored file reference. */ +export function importImageFile(filename: string, data: Uint8Array, mimeType: string): ConvertedNote { + const title = titleFromFilename(filename); + const md = `![${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/rdocs/converters/google-docs.ts b/modules/rdocs/converters/google-docs.ts new file mode 100644 index 00000000..231991c8 --- /dev/null +++ b/modules/rdocs/converters/google-docs.ts @@ -0,0 +1,329 @@ +/** + * 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/rdocs/converters/index.ts b/modules/rdocs/converters/index.ts new file mode 100644 index 00000000..abdf2c99 --- /dev/null +++ b/modules/rdocs/converters/index.ts @@ -0,0 +1,57 @@ +/** + * Converter registry and shared types for rDocs import/export. + * + * All source-specific converters implement NoteConverter. + * ConvertedNote is the intermediate format between external sources and NoteItem. + */ + +import type { NoteItem, SourceRef } from '../schemas'; + +// Re-export types from shared converters +export type { ConvertedNote, ImportResult, ExportResult, NoteConverter, ImportInput, ExportOptions } from '../../../shared/converters/types'; + +// ── 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); +} + +// ── Converter registry ── + +import type { NoteConverter } from '../../../shared/converters/types'; + +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 ── +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/rdocs/converters/logseq.ts b/modules/rdocs/converters/logseq.ts new file mode 100644 index 00000000..02b7af48 --- /dev/null +++ b/modules/rdocs/converters/logseq.ts @@ -0,0 +1,9 @@ +/** + * Logseq converter — re-exports from shared and registers with rDocs converter system. + */ +import { logseqConverter } from '../../../shared/converters/logseq'; +import { registerConverter } from './index'; + +export { logseqConverter }; + +registerConverter(logseqConverter); diff --git a/modules/rdocs/converters/markdown-tiptap.ts b/modules/rdocs/converters/markdown-tiptap.ts new file mode 100644 index 00000000..4cf11150 --- /dev/null +++ b/modules/rdocs/converters/markdown-tiptap.ts @@ -0,0 +1,9 @@ +/** + * 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/rdocs/converters/notion.ts b/modules/rdocs/converters/notion.ts new file mode 100644 index 00000000..567485d6 --- /dev/null +++ b/modules/rdocs/converters/notion.ts @@ -0,0 +1,462 @@ +/** + * 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'; +// Note: imports from './index' and './markdown-tiptap' resolve to rdocs-local copies + +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/rdocs/converters/obsidian.ts b/modules/rdocs/converters/obsidian.ts new file mode 100644 index 00000000..eb8bb618 --- /dev/null +++ b/modules/rdocs/converters/obsidian.ts @@ -0,0 +1,9 @@ +/** + * Obsidian converter — re-exports from shared and registers with rDocs converter system. + */ +import { obsidianConverter } from '../../../shared/converters/obsidian'; +import { registerConverter } from './index'; + +export { obsidianConverter }; + +registerConverter(obsidianConverter); diff --git a/modules/rdocs/converters/roam.ts b/modules/rdocs/converters/roam.ts new file mode 100644 index 00000000..5a828b8a --- /dev/null +++ b/modules/rdocs/converters/roam.ts @@ -0,0 +1,171 @@ +/** + * Roam Research JSON → rNotes converter. + * + * Import: Roam JSON export ([{ title, children: [{ string, children }] }]) + * Converts recursive tree → indented markdown bullets. + * Handles Roam syntax: ((block-refs)), {{embed}}, ^^highlight^^, [[page refs]] + */ + +import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap'; +import { registerConverter, hashContent } from './index'; +import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; +import type { NoteItem } from '../schemas'; + +interface RoamBlock { + string?: string; + uid?: string; + children?: RoamBlock[]; + 'create-time'?: number; + 'edit-time'?: number; +} + +interface RoamPage { + title: string; + uid?: string; + children?: RoamBlock[]; + 'create-time'?: number; + 'edit-time'?: number; +} + +/** Convert Roam block tree to indented markdown. */ +function blocksToMarkdown(blocks: RoamBlock[], depth = 0): string { + const lines: string[] = []; + + for (const block of blocks) { + if (!block.string && (!block.children || block.children.length === 0)) continue; + + if (block.string) { + const indent = ' '.repeat(depth); + const text = convertRoamSyntax(block.string); + lines.push(`${indent}- ${text}`); + } + + if (block.children && block.children.length > 0) { + lines.push(blocksToMarkdown(block.children, depth + 1)); + } + } + + return lines.join('\n'); +} + +/** Convert Roam-specific syntax to standard markdown. */ +function convertRoamSyntax(text: string): string { + // [[page references]] → [page references](page references) + text = text.replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)'); + + // ((block refs)) → (ref) + text = text.replace(/\(\(([a-zA-Z0-9_-]+)\)\)/g, '(ref:$1)'); + + // {{embed: ((ref))}} → (embedded ref) + text = text.replace(/\{\{embed:\s*\(\(([^)]+)\)\)\}\}/g, '> (embedded: $1)'); + + // {{[[TODO]]}} and {{[[DONE]]}} + text = text.replace(/\{\{\[\[TODO\]\]\}\}/g, '- [ ]'); + text = text.replace(/\{\{\[\[DONE\]\]\}\}/g, '- [x]'); + + // ^^highlight^^ → ==highlight== (or just **highlight**) + text = text.replace(/\^\^([^^]+)\^\^/g, '**$1**'); + + // **bold** already valid markdown + // __italic__ → *italic* + text = text.replace(/__([^_]+)__/g, '*$1*'); + + return text; +} + +/** Extract tags from Roam page content (inline [[refs]] and #tags). */ +function extractRoamTags(blocks: RoamBlock[]): string[] { + const tags = new Set(); + + function walk(items: RoamBlock[]) { + for (const block of items) { + if (block.string) { + // [[page refs]] + const pageRefs = block.string.match(/\[\[([^\]]+)\]\]/g); + if (pageRefs) { + for (const ref of pageRefs) { + const tag = ref.slice(2, -2).toLowerCase().replace(/\s+/g, '-'); + if (tag.length <= 30) tags.add(tag); // Skip very long refs + } + } + // #tags + const hashTags = block.string.match(/#([a-zA-Z0-9_-]+)/g); + if (hashTags) { + for (const t of hashTags) tags.add(t.slice(1).toLowerCase()); + } + } + if (block.children) walk(block.children); + } + } + + walk(blocks); + return Array.from(tags).slice(0, 20); // Cap tags +} + +const roamConverter: NoteConverter = { + id: 'roam', + name: 'Roam Research', + requiresAuth: false, + + async import(input: ImportInput): Promise { + if (!input.fileData) { + throw new Error('Roam import requires a JSON file'); + } + + const jsonStr = new TextDecoder().decode(input.fileData); + let pages: RoamPage[]; + try { + pages = JSON.parse(jsonStr); + } catch { + throw new Error('Invalid Roam Research JSON format'); + } + + if (!Array.isArray(pages)) { + throw new Error('Expected a JSON array of Roam pages'); + } + + const notes: ConvertedNote[] = []; + const warnings: string[] = []; + + for (const page of pages) { + try { + if (!page.title) continue; + + const children = page.children || []; + const markdown = children.length > 0 + ? blocksToMarkdown(children) + : ''; + + if (!markdown.trim() && children.length === 0) continue; // Skip empty pages + + const tiptapJson = markdownToTiptap(markdown); + const contentPlain = extractPlainTextFromTiptap(tiptapJson); + const tags = extractRoamTags(children); + + notes.push({ + title: page.title, + content: tiptapJson, + contentPlain, + markdown, + tags, + sourceRef: { + source: 'roam', + externalId: page.uid || page.title, + lastSyncedAt: Date.now(), + contentHash: hashContent(markdown), + }, + }); + } catch (err) { + warnings.push(`Failed to parse page "${page.title}": ${(err as Error).message}`); + } + } + + return { notes, notebookTitle: 'Roam Research Import', warnings }; + }, + + async export(): Promise { + throw new Error('Roam Research export is not supported — use Roam\'s native import'); + }, +}; + +registerConverter(roamConverter); diff --git a/modules/rdocs/converters/sync.ts b/modules/rdocs/converters/sync.ts new file mode 100644 index 00000000..c5eaf52a --- /dev/null +++ b/modules/rdocs/converters/sync.ts @@ -0,0 +1,207 @@ +/** + * Sync service for rNotes — handles re-fetching, conflict detection, + * and merging for imported notes. + * + * Conflict policy: + * - Remote-only-changed → auto-update + * - Local-only-changed → keep local + * - Both changed → mark conflict (stores remote version in conflictContent) + */ + +import type { NoteItem, SourceRef } from '../schemas'; +import { getConverter, hashContent } from './index'; +import { tiptapToMarkdown } 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/rdocs/landing.ts b/modules/rdocs/landing.ts new file mode 100644 index 00000000..4a18af4f --- /dev/null +++ b/modules/rdocs/landing.ts @@ -0,0 +1,86 @@ +/** + * rDocs module landing page — static HTML, no React. + */ +export function renderLanding(): string { + return ` + +
+ rDocs +

Documents, notebooks, and knowledge.

+

Rich Text, Voice, Import & Export — All in One Place

+

+ Full TipTap editor with notebooks, voice transcription, AI summarization, + and import from Obsidian, Logseq, Notion, Google Docs, Evernote, and Roam. +

+ +
+ + +
+
+

What rDocs Handles

+
+
+
📝
+

Rich Text Editor

+

Write with a full TipTap editor — formatting, code blocks, checklists, embeds, slash commands, and comments with track changes.

+
+
+
🎤
+

Voice & Transcription

+

Record voice notes with live transcription via Web Speech API. Drop audio or video files for offline transcripts with Parakeet.js.

+
+
+
🔄
+

Import & Export

+

Bring your notes from Obsidian, Logseq, Notion, Google Docs, Evernote, and Roam. Export back to any format.

+
+
+
+
+ + +
+
+

Built on Open Source

+
+
+

Automerge

+

Local-first CRDT for conflict-free real-time collaboration.

+
+
+

TipTap

+

Headless rich text editor built on ProseMirror.

+
+
+

Parakeet.js

+

In-browser speech recognition for offline transcription.

+
+
+

Hono

+

Ultra-fast API framework powering the backend.

+
+
+
+
+ + +
+
+

Start Writing

+

Create a space or try the demo.

+ +
+
+ + +`; +} diff --git a/modules/rdocs/local-first-client.ts b/modules/rdocs/local-first-client.ts index c1f38182..fab3d317 100644 --- a/modules/rdocs/local-first-client.ts +++ b/modules/rdocs/local-first-client.ts @@ -1,55 +1,159 @@ /** - * rDocs Local-First Client — syncs linked Docmost documents. + * rDocs Local-First Client + * + * Wraps the shared local-first stack (DocSyncManager + EncryptedDocStore) + * into a docs-specific API with proper offline support and encryption. */ +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 { docsSchema, docsDocId } from './schemas'; -import type { DocsDoc, LinkedDocument } from './schemas'; +import { notebookSchema, notebookDocId } from './schemas'; +import type { NotebookDoc, NoteItem, NotebookMeta } from './schemas'; export class DocsLocalFirstClient { - #space: string; #documents: DocumentManager; #store: EncryptedDocStore; #sync: DocSyncManager; #initialized = false; + #space: string; + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; constructor(space: string, docCrypto?: DocCrypto) { - this.#space = space; this.#documents = new DocumentManager(); + this.#space = space; + this.#documents = new DocumentManager(); this.#store = new EncryptedDocStore(space, docCrypto); - this.#sync = new DocSyncManager({ documents: this.#documents, store: this.#store }); - this.#documents.registerSchema(docsSchema); + this.#sync = new DocSyncManager({ + documents: this.#documents, + store: this.#store, + }); + + this.#documents.registerSchema(notebookSchema); } get isConnected(): boolean { return this.#sync.isConnected; } + get isInitialized(): boolean { return this.#initialized; } async init(): Promise { if (this.#initialized) return; + await this.#store.open(); - const cachedIds = await this.#store.listByModule('docs', 'links'); + + 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, docsSchema, binary); + for (const [docId, binary] of cached) { + this.#documents.open(docId, notebookSchema, binary); + } + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - try { await this.#sync.connect(`${proto}//${location.host}/ws/${this.#space}`, this.#space); } catch {} + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + try { + await this.#sync.connect(wsUrl, this.#space); + } catch { + console.warn('[DocsClient] WebSocket connection failed, working offline'); + } + this.#initialized = true; } - async subscribe(): Promise { - const docId = docsDocId(this.#space) as DocumentId; - let doc = this.#documents.get(docId); - if (!doc) { const b = await this.#store.load(docId); doc = b ? this.#documents.open(docId, docsSchema, b) : this.#documents.open(docId, docsSchema); } - await this.#sync.subscribe([docId]); return doc ?? null; + async subscribeNotebook(notebookId: string): Promise { + const docId = notebookDocId(this.#space, notebookId) as DocumentId; + + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + if (binary) { + doc = this.#documents.open(docId, notebookSchema, binary); + } else { + doc = this.#documents.open(docId, notebookSchema); + } + } + + await this.#sync.subscribe([docId]); + return doc ?? null; } - getDoc(): DocsDoc | undefined { return this.#documents.get(docsDocId(this.#space) as DocumentId); } - onChange(cb: (doc: DocsDoc) => void): () => void { return this.#sync.onChange(docsDocId(this.#space) as DocumentId, cb as (doc: any) => void); } - - linkDocument(doc: LinkedDocument): void { - this.#sync.change(docsDocId(this.#space) as DocumentId, `Link ${doc.title}`, (d) => { d.linkedDocuments[doc.id] = doc; }); - } - unlinkDocument(id: string): void { - this.#sync.change(docsDocId(this.#space) as DocumentId, `Unlink document`, (d) => { delete d.linkedDocuments[id]; }); + unsubscribeNotebook(notebookId: string): void { + const docId = notebookDocId(this.#space, notebookId) as DocumentId; + this.#sync.unsubscribe([docId]); } - async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect(); } + getNotebook(notebookId: string): NotebookDoc | undefined { + const docId = notebookDocId(this.#space, notebookId) as DocumentId; + return this.#documents.get(docId); + } + + listNotebookIds(): string[] { + return this.#documents.list(this.#space, 'notes'); + } + + 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(); + } + }); + } + + 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]; + }); + } + + 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(); + }); + } + + 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); + } + + onConnect(cb: () => void): () => void { + return this.#sync.onConnect(cb); + } + + onDisconnect(cb: () => void): () => void { + return this.#sync.onDisconnect(cb); + } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } } diff --git a/modules/rdocs/mod.ts b/modules/rdocs/mod.ts index f1211a43..43179e24 100644 --- a/modules/rdocs/mod.ts +++ b/modules/rdocs/mod.ts @@ -1,158 +1,1784 @@ /** - * Docs module — collaborative documentation via Docmost. + * Docs module — notebooks, rich-text notes, voice transcription, import/export. * - * Wraps the Docmost instance as an external app embedded in the rSpace shell. + * 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. */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; -import { renderShell, renderExternalAppShell } from "../../server/shell"; +import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; -import type { RSpaceModule } from "../../shared/module"; +import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; +import { resolveDataSpace } from "../../shared/scope-resolver"; import { verifyToken, extractToken } from "../../server/auth"; -import type { SyncServer } from '../../server/local-first/sync-server'; -import { docsSchema, docsDocId } from './schemas'; -import type { DocsDoc, LinkedDocument } from './schemas'; - -let _syncServer: SyncServer | null = null; +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 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(); -const DOCMOST_URL = "https://docs.cosmolocal.world"; +// ── SyncServer ref (set during onInit) ── +let _syncServer: SyncServer | null = null; -// ── Local-first helpers ── +// ── Automerge helpers ── -function ensureDocsDoc(space: string): DocsDoc { - const docId = docsDocId(space); - let doc = _syncServer!.getDoc(docId); +/** 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 docs registry', (d) => { - const init = docsSchema.init(); + 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; } -// ── CRUD: Document Registry ── +/** 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"; +} -routes.get("/api/registry", (c) => { - if (!_syncServer) return c.json({ documents: [] }); +/** 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 }[] { + 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 }); + } + } + 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(), + }; +} + +/** 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 rdocs by default) + const dataSpace = resolveDataSpace("rdocs", 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("[Docs] 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 doc = ensureDocsDoc(space); - return c.json({ documents: Object.values(doc.linkedDocuments || {}) }); + 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" }); }); -routes.post("/api/registry", async (c) => { +// 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); } - if (!_syncServer) return c.json({ error: "Not initialized" }, 503); - const space = c.req.param("space") || "demo"; - const { url, title } = await c.req.json(); - if (!url || !title) return c.json({ error: "url and title required" }, 400); - const id = crypto.randomUUID(); - const docId = docsDocId(space); - ensureDocsDoc(space); - _syncServer.changeDoc(docId, `register document ${id}`, (d) => { - d.linkedDocuments[id] = { id, url, title, addedBy: claims.sub || null, addedAt: Date.now() }; + + 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 updated = _syncServer.getDoc(docId)!; - return c.json(updated.linkedDocuments[id], 201); + + const updatedDoc = _syncServer!.getDoc(notebookDocId(dataSpace, notebookId))!; + return c.json(notebookToRest(updatedDoc), 201); }); -routes.delete("/api/registry/:docRefId", async (c) => { +// 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); - try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + 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 docRefId = c.req.param("docRefId"); - const docId = docsDocId(space); - const doc = ensureDocsDoc(space); - if (!doc.linkedDocuments[docRefId]) return c.json({ error: "Not found" }, 404); - _syncServer.changeDoc(docId, `unregister document ${docRefId}`, (d) => { delete d.linkedDocuments[docRefId]; }); + 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 }); }); -routes.get("/api/health", (c) => { - return c.json({ ok: true, module: "rdocs" }); +// ── 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 rDocs

` + ).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 = "rDocs 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 rDocs 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="rdocs-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: "rdocs", + 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: "rdocs", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: ``, + styles: ``, + })); }); routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; - const view = c.req.query("view"); - - if (view === "demo") { - return c.html(renderShell({ - title: `${space} — Docs | rSpace`, - moduleId: "rdocs", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: `
-
📝
-

rDocs

-

Collaborative documentation powered by Docmost. Create wikis, knowledge bases, and shared documents for your community.

- Open Docmost -
`, - })); - } - - // Default: show the external app directly - return c.html(renderExternalAppShell({ - title: `${space} — Docmost | rSpace`, + return c.html(renderShell({ + title: `${space} — Docs | rSpace`, moduleId: "rdocs", spaceSlug: space, modules: getModuleInfoList(), - appUrl: DOCMOST_URL, - appName: "Docmost", theme: "dark", + body: ``, + scripts: ``, + styles: ``, })); }); -function renderDocsLanding(): string { - return `
-
📝
-

rDocs

-

Collaborative documentation powered by Docmost. Create wikis, knowledge bases, and shared documents for your community.

-
`; -} - -// ── MI Integration ── - -export function getLinkedDocsForMI(space: string, limit = 5): { id: string; title: string; url: string; addedAt: number }[] { - if (!_syncServer) return []; - const docId = docsDocId(space); - const doc = _syncServer.getDoc(docId); - if (!doc) return []; - return Object.values(doc.linkedDocuments) - .sort((a, b) => b.addedAt - a.addedAt) - .slice(0, limit) - .map((d) => ({ id: d.id, title: d.title, url: d.url, addedAt: d.addedAt })); -} - export const docsModule: RSpaceModule = { id: "rdocs", name: "rDocs", - icon: "📝", - description: "Collaborative documentation and knowledge base", + icon: "📄", + description: "Rich-text notebooks, voice transcription, and import/export", scoping: { defaultScope: 'global', userConfigurable: true }, - docSchemas: [{ pattern: '{space}:docs:links', description: 'Linked Docmost documents per space', init: docsSchema.init }], routes, - landingPage: renderDocsLanding, - externalApp: { url: DOCMOST_URL, name: "Docmost" }, - async onInit(ctx) { _syncServer = ctx.syncServer; }, - feeds: [ - { id: "documents", name: "Documents", kind: "data", description: "Collaborative documents and wiki pages" }, + + settingsSchema: [ + { + key: 'defaultNotebookId', + label: 'Default notebook for imports', + type: 'notebook-id', + description: 'Pre-selected notebook when importing from Logseq, Obsidian, or the web clipper', + }, ], - acceptsFeeds: ["data"], + + docSchemas: [ + { + pattern: '{space}:notes:notebooks:{notebookId}', + description: 'One Automerge doc per notebook, containing all notes as items', + init: notebookSchema.init, + }, + ], + + seedTemplate: seedDemoIfEmpty, + async onInit({ syncServer }) { + _syncServer = syncServer; + // Seeding happens after loadAllDocs via seedTemplate, not here + console.log("[Docs] onInit complete (Automerge-only)"); + }, + + async onSpaceCreate(ctx: SpaceLifecycleContext) { + if (!_syncServer) return; + + // Create a default "My Notes" notebook doc, using resolved scope + const dataSpace = resolveDataSpace("rdocs", 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(`[Docs] Created default notebook for space: ${ctx.spaceSlug}`); + }, + + landingPage: renderLanding, + // standaloneDomain: not yet assigned for rDocs + 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: "documents", name: "Documents", icon: "📝", description: "Collaborative documents" }, - { path: "wikis", name: "Wikis", icon: "📖", description: "Knowledge base wikis" }, + { 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}/rdocs?action=import' }, + { label: "Import from Notion", icon: "📄", description: "Bring in pages from a Notion export", type: 'link', href: '/{space}/rdocs?action=import&source=notion' }, + { label: "Create a Notebook", icon: "✏️", description: "Start writing from scratch", type: 'create', href: '/{space}/rdocs' }, ], }; + +// ── MI Integration ── + +export interface MINoteItem { + id: string; + title: string; + contentPlain: string; + tags: string[]; + type: string; + updatedAt: number; +} + +/** + * Read recent docs directly from Automerge for the MI system prompt. + */ +export function getRecentDocsForMI(space: string, limit = 3): MINoteItem[] { + 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; + + for (const item of Object.values(doc.items)) { + 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, + }); + } + } + + return allNotes + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, limit); +} diff --git a/modules/rdocs/schemas.ts b/modules/rdocs/schemas.ts index 258a4ecc..1aaeba90 100644 --- a/modules/rdocs/schemas.ts +++ b/modules/rdocs/schemas.ts @@ -1,36 +1,193 @@ /** * rDocs Automerge document schemas. * - * Syncs linked Docmost documents per space. - * Actual document content lives in the Docmost instance. + * Granularity: one Automerge document per notebook. + * DocId format: {space}:notes:notebooks:{notebookId} * - * DocId format: {space}:docs:links + * IMPORTANT: These are identical to the original rNotes schemas. + * The docId prefix stays `notes:notebooks` for backward compatibility — + * existing notebook data is accessible without migration. */ import type { DocSchema } from '../../shared/local-first/document'; -export interface LinkedDocument { +// ── Document types ── + +export interface SourceRef { + source: 'logseq' | 'obsidian' | 'notion' | 'google-docs' | 'evernote' | 'roam' | 'manual'; + externalId: string; + lastSyncedAt: number; + contentHash?: string; + syncStatus?: 'synced' | 'local-modified' | 'remote-modified' | 'conflict'; +} + +export interface CommentMessage { id: string; - url: string; + authorId: string; + authorName: string; + text: string; + createdAt: number; +} + +export interface CommentThread { + id: string; + anchor: string; + resolved: boolean; + messages: CommentMessage[]; + createdAt: number; +} + +export interface NoteItem { + id: string; + notebookId: string; + authorId: string | null; title: string; - addedBy: string | null; - addedAt: number; + 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; + collabEnabled?: boolean; + comments?: Record; + createdAt: number; + updatedAt: number; } -export interface DocsDoc { - meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number }; - linkedDocuments: Record; +export interface NotebookMeta { + id: string; + title: string; + slug: string; + description: string; + coverColor: string; + isPublic: boolean; + createdAt: number; + updatedAt: number; } -export const docsSchema: DocSchema = { - module: 'docs', - collection: 'links', - version: 1, - init: (): DocsDoc => ({ - meta: { module: 'docs', collection: 'links', version: 1, spaceSlug: '', createdAt: Date.now() }, - linkedDocuments: {}, +export interface NotebookDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + notebook: NotebookMeta; + items: Record; +} + +// ── 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 => ({ + meta: { + module: 'notes', + collection: 'notebooks', + version: 5, + spaceSlug: '', + createdAt: Date.now(), + }, + notebook: { + id: '', + title: 'Untitled Notebook', + slug: '', + description: '', + coverColor: '#3b82f6', + isPublic: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + items: {}, }), - migrate: (doc: any) => { if (!doc.linkedDocuments) doc.linkedDocuments = {}; doc.meta.version = 1; return doc; }, + 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'; + } + } + return doc; + }, }; -export function docsDocId(space: string) { return `${space}:docs:links` as const; } +// ── Helpers ── + +/** Generate a docId for a notebook. */ +export function notebookDocId(space: string, notebookId: string) { + return `${space}:notes:notebooks:${notebookId}` 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, + }; +} diff --git a/modules/rdocs/yjs-ws-provider.ts b/modules/rdocs/yjs-ws-provider.ts new file mode 100644 index 00000000..12ef72f8 --- /dev/null +++ b/modules/rdocs/yjs-ws-provider.ts @@ -0,0 +1,5 @@ +/** + * 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/modules/rforum/components/folk-forum-dashboard.ts b/modules/rforum/components/folk-forum-dashboard.ts index 715a78d8..8ca1c946 100644 --- a/modules/rforum/components/folk-forum-dashboard.ts +++ b/modules/rforum/components/folk-forum-dashboard.ts @@ -22,7 +22,7 @@ class FolkForumDashboard extends HTMLElement { private space = ""; private _offlineUnsub: (() => void) | null = null; private _stopPresence: (() => void) | null = null; - private _history = new ViewHistory<"list" | "detail" | "create">("list"); + private _history = new ViewHistory<"list" | "detail" | "create">("list", "rforum"); private _tour!: TourEngine; private static readonly TOUR_STEPS = [ { target: '.instance-card', title: "Forum Instances", message: "View your deployed Discourse forums — click one for status and logs.", advanceOnClick: false }, @@ -53,6 +53,7 @@ class FolkForumDashboard extends HTMLElement { setTimeout(() => this._tour.start(), 1200); } this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rforum', context: this.selectedInstance?.name || 'Forum' })); + window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener); } private loadDemoData() { @@ -66,6 +67,8 @@ class FolkForumDashboard extends HTMLElement { } disconnectedCallback() { + this._history.destroy(); + window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); this._stopPresence?.(); if (this.pollTimer) clearInterval(this.pollTimer); this._offlineUnsub?.(); @@ -537,6 +540,14 @@ class FolkForumDashboard extends HTMLElement { if (form) form.addEventListener("submit", (e) => this.handleCreate(e)); } + private _onViewRestored = (e: CustomEvent) => { + if (e.detail?.moduleId !== 'rforum') return; + if (this.pollTimer) clearInterval(this.pollTimer); + this.view = e.detail.view; + if (e.detail.view === "list") this.loadInstances(); + else this.render(); + }; + private goBack() { if (this.pollTimer) clearInterval(this.pollTimer); const prev = this._history.back(); diff --git a/modules/rinbox/components/folk-inbox-client.ts b/modules/rinbox/components/folk-inbox-client.ts index ab121196..c7cab82f 100644 --- a/modules/rinbox/components/folk-inbox-client.ts +++ b/modules/rinbox/components/folk-inbox-client.ts @@ -52,7 +52,7 @@ class FolkInboxClient extends HTMLElement { private agentFormOpen = false; private _usernameCache = new Map(); private _currentUsername: string | null = null; - private _history = new ViewHistory<"mailboxes" | "threads" | "thread" | "approvals" | "personal" | "agents">("mailboxes"); + private _history = new ViewHistory<"mailboxes" | "threads" | "thread" | "approvals" | "personal" | "agents">("mailboxes", "rinbox"); private _fwdStatus: 'loading' | 'unavailable' | 'no-email' | 'ready' | 'enabled' | 'error' = 'loading'; private _fwdAddress = ''; private _fwdTarget = ''; @@ -144,6 +144,7 @@ class FolkInboxClient extends HTMLElement { if (this.space === "demo") { this.loadDemoData(); } else { this.subscribeOffline(); this.loadMailboxes(); } this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rinbox', context: this.currentThread?.subject || 'Inbox' })); + window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener); } disconnectedCallback() { @@ -151,8 +152,18 @@ class FolkInboxClient extends HTMLElement { for (const unsub of this._offlineUnsubs) unsub(); this._offlineUnsubs = []; this._stopPresence?.(); + this._history.destroy(); + window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); } + private _onViewRestored = (e: CustomEvent) => { + if (e.detail?.moduleId !== 'rinbox') return; + if (this.view === "thread") this.composeOpen = false; + if (e.detail.view === "mailboxes") this.currentMailbox = null; + this.view = e.detail.view; + this.render(); + }; + /** Extract username from EncryptID session */ private _loadUsername() { this._currentUsername = getUsername(); diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index ed95f18c..0c04b443 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -21,6 +21,8 @@ import { requireAuth } from "../../../shared/auth-fetch"; import { getUsername } from "../../../shared/components/rstack-identity"; import { MapsLocalFirstClient } from "../local-first-client"; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; +import type { DocumentId } from "../../../shared/local-first/document"; +import { mapsSchema, mapsDocId } from "../schemas"; // MapLibre loaded via CDN — use window access with type assertion @@ -144,8 +146,9 @@ class FolkMapViewer extends HTMLElement { private activeRoute: { segments: any[]; totalDistance: number; estimatedTime: number; destination: string } | null = null; private thumbnailTimer: ReturnType | null = null; private _themeObserver: MutationObserver | null = null; - private _history = new ViewHistory<"lobby" | "map">("lobby"); + private _history = new ViewHistory<"lobby" | "map">("lobby", "rmaps"); private _stopPresence: (() => void) | null = null; + private _offlineUnsub: (() => void) | null = null; // Chat + Local-first state private lfClient: MapsLocalFirstClient | null = null; @@ -197,6 +200,7 @@ class FolkMapViewer extends HTMLElement { this.checkSyncHealth(); this.render(); } + this.subscribeOffline(); } if (!localStorage.getItem("rmaps_tour_done")) { setTimeout(() => this._tour.start(), 1200); @@ -214,10 +218,14 @@ class FolkMapViewer extends HTMLElement { this.setMapInteractive(false); } this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rmaps', context: this.room || 'Maps' })); + window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener); } disconnectedCallback() { + this._history.destroy(); + window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); this._stopPresence?.(); + this._offlineUnsub?.(); this._offlineUnsub = null; if (this._demoInterval) { clearInterval(this._demoInterval); this._demoInterval = null; } this.leaveRoom(); if (this._themeObserver) { @@ -237,6 +245,15 @@ class FolkMapViewer extends HTMLElement { private _onEditExit: (() => void) | null = null; private _mapInteractive = true; + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + try { + const docId = mapsDocId(this.space) as DocumentId; + await runtime.subscribe(docId, mapsSchema); + } catch { /* runtime unavailable */ } + } + private setMapInteractive(interactive: boolean) { this._mapInteractive = interactive; if (this.map) { @@ -2825,6 +2842,13 @@ class FolkMapViewer extends HTMLElement { // No-op — legacy FAB removed; share state updated via updateShareButton() } + private _onViewRestored = (e: CustomEvent) => { + if (e.detail?.moduleId !== 'rmaps') return; + if (e.detail.view === "lobby" && this.view === "map") this.leaveRoom(); + this.view = e.detail.view; + if (e.detail.view === "lobby") this.loadStats(); + }; + private goBack() { const prev = this._history.back(); if (!prev) return; diff --git a/modules/rmeets/components/folk-jitsi-room.ts b/modules/rmeets/components/folk-jitsi-room.ts index c4096d6f..5d1b5d34 100644 --- a/modules/rmeets/components/folk-jitsi-room.ts +++ b/modules/rmeets/components/folk-jitsi-room.ts @@ -131,13 +131,17 @@ class FolkJitsiRoom extends HTMLElement { height: "100%", configOverwrite: { prejoinConfig: { enabled: true }, + requireDisplayName: true, disableDeepLinking: true, hideConferenceSubject: false, + disableVirtualBackground: false, + disableProfile: false, toolbarButtons: [ "camera", "microphone", "desktop", "hangup", "raisehand", "tileview", "toggle-camera", - "fullscreen", "select-background", - "sharedvideo", "sharedmusic", + "fullscreen", "chat", "settings", + "participants-pane", "select-background", + "sharedvideo", ], // Hide panels that add stray close (×) buttons disableChat: false, @@ -149,6 +153,7 @@ class FolkJitsiRoom extends HTMLElement { SHOW_BRAND_WATERMARK: false, CLOSE_PAGE_GUEST_HINT: false, SHOW_PROMOTIONAL_CLOSE_PAGE: false, + SETTINGS_SECTIONS: ['devices', 'language', 'moderator', 'profile', 'sounds', 'more'], }, }); diff --git a/modules/rmeets/mod.ts b/modules/rmeets/mod.ts index 61dabac7..965409d3 100644 --- a/modules/rmeets/mod.ts +++ b/modules/rmeets/mod.ts @@ -544,6 +544,7 @@ routes.get("/:room", (c) => { // Default: clean full-screen Jitsi — no rSpace shell, mobile-friendly const jitsiRoom = encodeURIComponent(room); + const meetsBase = `/${escapeHtml(space)}/rmeets`; return c.html(` @@ -564,14 +565,49 @@ routes.get("/:room", (c) => { .ended a{color:#6366f1;text-decoration:none;padding:10px 24px;border:1px solid #6366f1;border-radius:8px;font-size:0.95rem;transition:background 0.15s} .ended a:hover,.ended a:active{background:#6366f122} @keyframes spin{to{transform:rotate(360deg)}} + /* MI overlay */ + .mi-fab{position:fixed;top:12px;right:12px;z-index:10000;display:flex;align-items:center;gap:6px} + .mi-fab-btn{width:40px;height:40px;border-radius:50%;border:none;background:rgba(99,102,241,0.85);color:#fff;font-size:1.1rem;cursor:pointer;backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;transition:background 0.15s,transform 0.15s;box-shadow:0 2px 8px rgba(0,0,0,0.3)} + .mi-fab-btn:hover{background:rgba(99,102,241,1);transform:scale(1.08)} + .mi-fab-btn.active{background:rgba(99,102,241,1);transform:scale(1.08)} + .mi-dropdown{display:none;position:absolute;top:48px;right:0;background:rgba(30,30,46,0.95);backdrop-filter:blur(12px);border:1px solid rgba(99,102,241,0.3);border-radius:12px;padding:8px 0;min-width:200px;box-shadow:0 8px 24px rgba(0,0,0,0.4)} + .mi-dropdown.open{display:block} + .mi-dropdown a,.mi-dropdown button{display:flex;align-items:center;gap:10px;width:100%;padding:10px 16px;border:none;background:none;color:#e2e8f0;font-size:0.9rem;text-decoration:none;cursor:pointer;text-align:left;font-family:inherit;transition:background 0.12s} + .mi-dropdown a:hover,.mi-dropdown button:hover{background:rgba(99,102,241,0.15)} + .mi-dropdown .mi-icon{font-size:1.1rem;width:22px;text-align:center;flex-shrink:0} + .mi-dropdown .mi-sep{height:1px;background:rgba(99,102,241,0.15);margin:4px 0}
Connecting to meeting...
+