From c19142791e8a6601070b588f0485685bf8e891a9 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 9 Mar 2026 22:39:10 -0700 Subject: [PATCH] feat(rnotes): type-specific notes, voice recording, web clipper, module settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: All 7 note types (NOTE, CODE, BOOKMARK, CLIP, IMAGE, AUDIO, FILE) with type-specific editors, filter bar, new-note dropdown, and demo notes. Phase 1.5: Code Snippet and Voice Note slash commands. Phase 2: Voice recording with 3-tier transcription cascade (server Whisper, Web Speech API, offline Parakeet TDT), mic button in toolbar, standalone voice recorder component, upload/transcribe/diarize server routes. Phase 3: Manifest V3 browser extension (web clipper) retargeted to rspace.online with slug-based routing, article unlock via Wayback/Google Cache/archive.ph strategies. Phase 4: Per-module settings framework — settingsSchema on modules, moduleSettings in CommunityMeta, gear icon in space settings Modules tab, rNotes declares defaultNotebookId setting. Co-Authored-By: Claude Opus 4.6 --- browser-extension/background.js | 175 +++++ browser-extension/icons/icon-128.png | Bin 0 -> 837 bytes browser-extension/icons/icon-16.png | Bin 0 -> 185 bytes browser-extension/icons/icon-48.png | Bin 0 -> 349 bytes browser-extension/manifest.json | 50 ++ browser-extension/options.html | 91 +++ browser-extension/options.js | 206 ++++++ browser-extension/parakeet-offline.js | 120 +++ browser-extension/popup.html | 101 +++ browser-extension/popup.js | 299 ++++++++ browser-extension/voice.html | 414 +++++++++++ browser-extension/voice.js | 579 +++++++++++++++ lib/article-unlock.ts | 113 +++ lib/parakeet-offline.ts | 139 ++++ modules/rnotes/components/folk-notes-app.ts | 689 ++++++++++++++++-- .../rnotes/components/folk-voice-recorder.ts | 481 ++++++++++++ modules/rnotes/components/slash-command.ts | 19 +- modules/rnotes/mod.ts | 139 +++- server/community-store.ts | 3 + server/spaces.ts | 22 + shared/components/rstack-space-switcher.ts | 103 ++- shared/module.ts | 24 + 22 files changed, 3691 insertions(+), 76 deletions(-) create mode 100644 browser-extension/background.js create mode 100644 browser-extension/icons/icon-128.png create mode 100644 browser-extension/icons/icon-16.png create mode 100644 browser-extension/icons/icon-48.png create mode 100644 browser-extension/manifest.json create mode 100644 browser-extension/options.html create mode 100644 browser-extension/options.js create mode 100644 browser-extension/parakeet-offline.js create mode 100644 browser-extension/popup.html create mode 100644 browser-extension/popup.js create mode 100644 browser-extension/voice.html create mode 100644 browser-extension/voice.js create mode 100644 lib/article-unlock.ts create mode 100644 lib/parakeet-offline.ts create mode 100644 modules/rnotes/components/folk-voice-recorder.ts diff --git a/browser-extension/background.js b/browser-extension/background.js new file mode 100644 index 0000000..f2b4d43 --- /dev/null +++ b/browser-extension/background.js @@ -0,0 +1,175 @@ +const DEFAULT_HOST = 'https://rspace.online'; + +// --- Context Menu Setup --- + +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.create({ id: 'clip-page', title: 'Clip page to rSpace', contexts: ['page'] }); + chrome.contextMenus.create({ id: 'save-link', title: 'Save link to rSpace', contexts: ['link'] }); + chrome.contextMenus.create({ id: 'save-image', title: 'Save image to rSpace', contexts: ['image'] }); + chrome.contextMenus.create({ id: 'clip-selection', title: 'Clip selection to rSpace', contexts: ['selection'] }); + chrome.contextMenus.create({ id: 'unlock-article', title: 'Unlock & Clip article to rSpace', contexts: ['page', 'link'] }); +}); + +// --- Helpers --- + +async function getSettings() { + const result = await chrome.storage.sync.get(['rspaceHost', 'rspaceSlug']); + return { + host: result.rspaceHost || DEFAULT_HOST, + slug: result.rspaceSlug || '', + }; +} + +function apiBase(settings) { + return settings.slug ? `${settings.host}/${settings.slug}/rnotes` : `${settings.host}/rnotes`; +} + +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, message, + }); +} + +async function createNote(data) { + const token = await getToken(); + if (!token) { showNotification('rSpace Error', 'Not signed in. Open extension settings.'); return; } + + const settings = await getSettings(); + if (!settings.slug) { showNotification('rSpace Error', 'Configure space slug in extension settings.'); return; } + + const notebookId = await getDefaultNotebook(); + const body = { title: data.title, content: data.content, type: data.type || 'CLIP', url: data.url }; + if (notebookId) body.notebook_id = notebookId; + if (data.fileUrl) body.file_url = data.fileUrl; + if (data.mimeType) body.mime_type = data.mimeType; + if (data.fileSize) body.file_size = data.fileSize; + + const response = await fetch(`${apiBase(settings)}/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(); + + const imgResponse = await fetch(imageUrl); + const blob = await imgResponse.blob(); + let filename; + try { filename = new URL(imageUrl).pathname.split('/').pop() || `image-${Date.now()}.jpg`; } + catch { filename = `image-${Date.now()}.jpg`; } + + const formData = new FormData(); + formData.append('file', blob, filename); + + const response = await fetch(`${apiBase(settings)}/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('rSpace Error', 'Not signed in.'); return null; } + + const settings = await getSettings(); + const response = await fetch(`${apiBase(settings)}/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': { + 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, type: 'CLIP', url: tab.url }); + showNotification('Page Clipped', `"${tab.title}" saved to rSpace`); + 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 rSpace'); + break; + } + case 'save-image': { + const upload = await uploadImage(info.srcUrl); + 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 rSpace'); + 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?.success && result.archiveUrl) { + 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}`); + chrome.tabs.create({ url: result.archiveUrl }); + } else { + showNotification('Unlock Failed', result?.error || 'No archived version found'); + } + break; + } + case 'clip-selection': { + let content = ''; + try { + const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => { const s = window.getSelection(); if (!s || s.rangeCount === 0) return ''; const r = s.getRangeAt(0); const d = document.createElement('div'); d.appendChild(r.cloneContents()); return d.innerHTML; } }); + content = result?.result || ''; + } catch { content = `

${info.selectionText || ''}

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

${info.selectionText}

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

rSpace Web Clipper Settings

+ +
+

Connection

+
+ + +
The URL of your rSpace instance
+
+
+ + +
Your space name in the URL (e.g. "my-space" from rspace.online/my-space)
+
+
+ +
+

Authentication

+
Not signed in
+
+
+ + +
Opens rSpace in a new tab. Sign in with your passkey.
+
+
+ + +
+
+ +
+
+ +
+ +
+

Default Notebook

+
+ + +
Pre-selected notebook when clipping
+
+
+ +
+ + +
+ +
+ + + + diff --git a/browser-extension/options.js b/browser-extension/options.js new file mode 100644 index 0000000..7eccb93 --- /dev/null +++ b/browser-extension/options.js @@ -0,0 +1,206 @@ +const DEFAULT_HOST = 'https://rspace.online'; + +// --- Helpers --- + +async function getSettings() { + const result = await chrome.storage.sync.get(['rspaceHost', 'rspaceSlug']); + return { + host: result.rspaceHost || DEFAULT_HOST, + slug: result.rspaceSlug || '', + }; +} + +function apiBase(settings) { + return settings.slug ? `${settings.host}/${settings.slug}/rnotes` : `${settings.host}/rnotes`; +} + +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 settings = await getSettings(); + if (!settings.slug) return; + + try { + const response = await fetch(`${apiBase(settings)}/api/notebooks`, { + headers: { 'Authorization': `Bearer ${encryptid_token}` }, + }); + + if (!response.ok) return; + + const data = await response.json(); + const notebooks = data.notebooks || (Array.isArray(data) ? data : []); + 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(['rspaceHost', 'rspaceSlug']); + document.getElementById('host').value = result.rspaceHost || DEFAULT_HOST; + document.getElementById('slug').value = result.rspaceSlug || ''; + + await updateAuthUI(); + await populateNotebooks(); +} + +// --- Event handlers --- + +// Open rSpace sign-in +document.getElementById('openSigninBtn').addEventListener('click', () => { + const host = document.getElementById('host').value.replace(/\/+$/, '') || DEFAULT_HOST; + const slug = document.getElementById('slug').value.trim(); + const signinUrl = slug + ? `${host}/${slug}/auth/signin?extension=true` + : `${host}/auth/signin?extension=true`; + chrome.tabs.create({ url: signinUrl }); +}); + +// 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 slug = document.getElementById('slug').value.trim(); + const notebookId = document.getElementById('defaultNotebook').value; + + await chrome.storage.sync.set({ + rspaceHost: host || DEFAULT_HOST, + rspaceSlug: slug, + }); + await chrome.storage.local.set({ lastNotebookId: notebookId }); + + showStatus('Settings saved', 'success'); +}); + +// Test connection +document.getElementById('testBtn').addEventListener('click', async () => { + const settings = { + host: document.getElementById('host').value.trim().replace(/\/+$/, '') || DEFAULT_HOST, + slug: document.getElementById('slug').value.trim(), + }; + + if (!settings.slug) { + showStatus('Configure a space slug first', 'error'); + return; + } + + 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(`${apiBase(settings)}/api/notebooks`, { headers }); + + if (response.ok) { + const data = await response.json(); + const notebooks = data.notebooks || (Array.isArray(data) ? data : []); + showStatus(`Connected! Found ${notebooks.length} 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/browser-extension/parakeet-offline.js b/browser-extension/parakeet-offline.js new file mode 100644 index 0000000..520c6a3 --- /dev/null +++ b/browser-extension/parakeet-offline.js @@ -0,0 +1,120 @@ +/** + * 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. + */ + +const CACHE_KEY = 'parakeet-offline-cached'; + +let cachedModel = null; +let loadingPromise = null; + +function isModelCached() { + try { + return localStorage.getItem(CACHE_KEY) === 'true'; + } catch { + return false; + } +} + +async function detectWebGPU() { + if (!navigator.gpu) return false; + try { + const adapter = await navigator.gpu.requestAdapter(); + return !!adapter; + } catch { + return false; + } +} + +async function getModel(onProgress) { + if (cachedModel) return cachedModel; + if (loadingPromise) return loadingPromise; + + loadingPromise = (async () => { + onProgress?.({ status: 'loading', message: 'Loading Parakeet model...' }); + + 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; +} + +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); + } + + 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(); + } +} + +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; +} + +window.ParakeetOffline = { + isModelCached, + transcribeOffline, +}; diff --git a/browser-extension/popup.html b/browser-extension/popup.html new file mode 100644 index 0000000..7b9aa00 --- /dev/null +++ b/browser-extension/popup.html @@ -0,0 +1,101 @@ + + + + + + + +
+ rSpace Clipper + ... +
+ + + +
+
Loading...
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ + + + + + diff --git a/browser-extension/popup.js b/browser-extension/popup.js new file mode 100644 index 0000000..9b565cc --- /dev/null +++ b/browser-extension/popup.js @@ -0,0 +1,299 @@ +const DEFAULT_HOST = 'https://rspace.online'; + +let currentTab = null; +let selectedText = ''; +let selectedHtml = ''; + +// --- Helpers --- + +async function getSettings() { + const result = await chrome.storage.sync.get(['rspaceHost', 'rspaceSlug']); + return { + host: result.rspaceHost || DEFAULT_HOST, + slug: result.rspaceSlug || '', + }; +} + +function apiBase(settings) { + return settings.slug ? `${settings.host}/${settings.slug}/rnotes` : `${settings.host}/rnotes`; +} + +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 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(); + + if (!settings.slug) { + showStatus('Configure space slug in Settings first', 'error'); + return; + } + + const body = { + title: data.title, + content: data.content, + type: data.type || 'CLIP', + url: data.url, + }; + + const notebookId = document.getElementById('notebook').value; + if (notebookId) body.notebook_id = notebookId; + + const tags = parseTags(document.getElementById('tags').value); + if (tags.length > 0) body.tags = tags; + + const response = await fetch(`${apiBase(settings)}/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(); + if (!settings.slug) return []; + + const response = await fetch(`${apiBase(settings)}/api/notebooks`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + + if (!response.ok) return []; + const data = await response.json(); + return data.notebooks || (Array.isArray(data) ? data : []); +} + +// --- UI --- + +async function populateNotebooks() { + const select = document.getElementById('notebook'); + try { + const notebooks = await fetchNotebooks(); + for (const nb of notebooks) { + const option = document.createElement('option'); + option.value = nb.id; + option.textContent = nb.title; + select.appendChild(option); + } + + const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']); + if (lastNotebookId) select.value = lastNotebookId; + } catch (err) { + console.error('Failed to load notebooks:', err); + } +} + +function setupNotebookMemory() { + document.getElementById('notebook').addEventListener('change', (e) => { + chrome.storage.local.set({ lastNotebookId: e.target.value }); + }); +} + +async function init() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + currentTab = tab; + + document.getElementById('pageTitle').textContent = tab.title || 'Untitled'; + document.getElementById('pageUrl').textContent = tab.url || ''; + + const token = await getToken(); + const claims = token ? decodeToken(token) : null; + const settings = await getSettings(); + + if (!claims) { + document.getElementById('userStatus').textContent = 'Not signed in'; + document.getElementById('userStatus').classList.add('not-authed'); + document.getElementById('authWarning').style.display = 'block'; + return; + } + + if (!settings.slug) { + document.getElementById('userStatus').textContent = 'No space configured'; + document.getElementById('userStatus').classList.add('not-authed'); + document.getElementById('authWarning').style.display = 'block'; + document.getElementById('authWarning').innerHTML = 'Configure your space slug. Open Settings'; + document.getElementById('openSettings')?.addEventListener('click', (e) => { e.preventDefault(); chrome.runtime.openOptionsPage(); }); + return; + } + + document.getElementById('userStatus').textContent = claims.username || claims.sub?.slice(0, 16) || 'Authenticated'; + document.getElementById('authWarning').style.display = 'none'; + + document.getElementById('clipPageBtn').disabled = false; + document.getElementById('unlockBtn').disabled = false; + document.getElementById('voiceBtn').disabled = false; + + await populateNotebooks(); + setupNotebookMemory(); + + 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) { + 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 { + let pageContent = ''; + try { + const [result] = await chrome.scripting.executeScript({ + target: { tabId: currentTab.id }, + func: () => document.body.innerHTML, + }); + pageContent = result?.result || ''; + } catch { + pageContent = `

Clipped from ${currentTab.url}

`; + } + + await createNote({ + title: currentTab.title || 'Untitled Clip', + content: pageContent, + type: 'CLIP', + url: currentTab.url, + }); + + showStatus('Clipped! Note saved.', 'success'); + chrome.runtime.sendMessage({ type: 'notify', title: 'Page Clipped', message: `"${currentTab.title}" saved to rSpace` }); + } 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}

`; + await createNote({ + title: `Selection from ${currentTab.title || 'page'}`, + content, + type: 'CLIP', + url: currentTab.url, + }); + + showStatus('Selection clipped!', 'success'); + chrome.runtime.sendMessage({ type: 'notify', title: 'Selection Clipped', message: 'Saved to rSpace' }); + } 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(`${apiBase(settings)}/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) { + 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'); + 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 () => { + const settings = await getSettings(); + const voiceUrl = settings.slug + ? `${settings.host}/${settings.slug}/rnotes/voice` + : `${settings.host}/rnotes/voice`; + chrome.windows.create({ url: voiceUrl, type: 'popup', width: 400, height: 600, focused: true }); + window.close(); +}); + +document.getElementById('optionsLink').addEventListener('click', (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); +}); + +document.getElementById('openSettings')?.addEventListener('click', (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); +}); + +document.addEventListener('DOMContentLoaded', init); diff --git a/browser-extension/voice.html b/browser-extension/voice.html new file mode 100644 index 0000000..338da7a --- /dev/null +++ b/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/browser-extension/voice.js b/browser-extension/voice.js new file mode 100644 index 0000000..102cbd8 --- /dev/null +++ b/browser-extension/voice.js @@ -0,0 +1,579 @@ +const DEFAULT_HOST = 'https://rspace.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 = ''; +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(['rspaceHost', 'rspaceSlug']); + return { + host: result.rspaceHost || DEFAULT_HOST, + slug: result.rspaceSlug || '', + }; +} + +function apiBase(settings) { + return settings.slug ? `${settings.host}/${settings.slug}/rnotes` : `${settings.host}/rnotes`; +} + +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(); + if (!settings.slug) return; + + try { + const res = await fetch(`${apiBase(settings)}/api/notebooks`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + if (!res.ok) return; + const data = await res.json(); + const notebooks = data.notebooks || (Array.isArray(data) ? data : []); + + for (const nb of notebooks) { + const opt = document.createElement('option'); + opt.value = nb.id; + opt.textContent = nb.title; + notebookSelect.appendChild(opt); + } + + 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 = ''; + 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(); + updateLiveDisplay(finalizedText.trim(), interimText.trim()); + }; + + recognition.onerror = (event) => { + if (event.error !== 'aborted' && event.error !== 'no-speech') { + console.warn('Speech recognition error:', event.error); + } + }; + + 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; + + transcriptArea.classList.add('visible'); + + let html = ''; + if (finalText) html += `${escapeHtml(finalText)}`; + if (interimText) html += `${escapeHtml(interimText)}`; + if (!finalText && !interimText) html = 'Listening...'; + transcriptText.innerHTML = html; + 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'; + + recBtn.classList.add('recording'); + timerEl.classList.add('recording'); + setStatusLabel('Recording', 'recording'); + postActions.style.display = 'none'; + audioPreview.classList.remove('visible'); + statusBar.className = 'status-bar'; + + 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); + + 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); + + const capturedLiveTranscript = liveTranscript; + stopLiveTranscription(); + + state = 'processing'; + recBtn.classList.remove('recording'); + timerEl.classList.remove('recording'); + setStatusLabel('Processing...', 'processing'); + + audioBlob = await new Promise((resolve) => { + mediaRecorder.onstop = () => { + mediaRecorder.stream.getTracks().forEach(t => t.stop()); + resolve(new Blob(audioChunks, { type: mediaRecorder.mimeType })); + }; + mediaRecorder.stop(); + }); + + if (audioUrl) URL.revokeObjectURL(audioUrl); + audioUrl = URL.createObjectURL(audioBlob); + audioPlayer.src = audioUrl; + audioPreview.classList.add('visible'); + + transcriptArea.classList.add('visible'); + if (capturedLiveTranscript) { + transcriptText.textContent = capturedLiveTranscript; + showStatusBar('Improving transcript...', 'loading'); + } else { + transcriptText.innerHTML = 'Transcribing...'; + showStatusBar('Uploading & transcribing...', 'loading'); + } + + const token = await getToken(); + const settings = await getSettings(); + + try { + const uploadForm = new FormData(); + uploadForm.append('file', audioBlob, 'voice-note.webm'); + + const uploadRes = await fetch(`${apiBase(settings)}/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: Server (Whisper) + let bestTranscript = ''; + try { + showStatusBar('Transcribing via server...', 'loading'); + const transcribeForm = new FormData(); + transcribeForm.append('audio', audioBlob, 'voice-note.webm'); + + const transcribeRes = await fetch(`${apiBase(settings)}/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: Web Speech API (already captured) + if (!bestTranscript && capturedLiveTranscript) { + bestTranscript = capturedLiveTranscript; + } + + // Tier 3: Offline Parakeet.js + 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; + + 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) { + 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(); + } +} + +// --- Save to rSpace --- + +async function saveToRSpace() { + saveBtn.disabled = true; + showStatusBar('Saving to rSpace...', 'loading'); + + const token = await getToken(); + const settings = await getSettings(); + + 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.notebook_id = notebookId; + + try { + const res = await fetch(`${apiBase(settings)}/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 rSpace!', 'success'); + + chrome.runtime.sendMessage({ + type: 'notify', + title: 'Voice Note Saved', + message: `${formatTime(duration)} recording saved to rSpace`, + }); + + 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) => { + if (e.code === 'Space' && document.activeElement !== transcriptText) { + e.preventDefault(); + toggleRecording(); + } + if (e.code === 'Escape') { + window.close(); + } + if ((e.ctrlKey || e.metaKey) && e.code === 'Enter' && state === 'done') { + e.preventDefault(); + saveToRSpace(); + } +}); + +transcriptText.addEventListener('focus', () => { + const ph = transcriptText.querySelector('.placeholder'); + if (ph) transcriptText.textContent = ''; +}); + +// --- Event listeners --- + +recBtn.addEventListener('click', toggleRecording); +saveBtn.addEventListener('click', saveToRSpace); +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/lib/article-unlock.ts b/lib/article-unlock.ts new file mode 100644 index 0000000..f4d648a --- /dev/null +++ b/lib/article-unlock.ts @@ -0,0 +1,113 @@ +/** + * Article unlock strategies — find readable/archived versions of paywalled articles. + * Three strategies tried in sequence: Wayback Machine, Google Cache, archive.ph. + */ + +interface UnlockResult { + success: boolean; + strategy?: string; + archiveUrl?: string; + error?: string; +} + +/** Try the Wayback Machine (web.archive.org). */ +async function tryWaybackMachine(url: string): Promise { + try { + const apiUrl = `https://archive.org/wayback/available?url=${encodeURIComponent(url)}`; + const res = await fetch(apiUrl, { signal: AbortSignal.timeout(10000) }); + if (!res.ok) return { success: false }; + + const data = await res.json(); + const snapshot = data?.archived_snapshots?.closest; + + if (snapshot?.available && snapshot.url) { + return { + success: true, + strategy: "Wayback Machine", + archiveUrl: snapshot.url.replace(/^http:/, "https:"), + }; + } + return { success: false }; + } catch { + return { success: false }; + } +} + +/** Try Google Cache. */ +async function tryGoogleCache(url: string): Promise { + try { + const cacheUrl = `https://webcache.googleusercontent.com/search?q=cache:${encodeURIComponent(url)}`; + const res = await fetch(cacheUrl, { + signal: AbortSignal.timeout(10000), + redirect: "manual", + }); + + // Google cache returns 200 if cached, redirects/errors otherwise + if (res.status === 200) { + return { + success: true, + strategy: "Google Cache", + archiveUrl: cacheUrl, + }; + } + return { success: false }; + } catch { + return { success: false }; + } +} + +/** Try archive.ph (archive.today). */ +async function tryArchivePh(url: string): Promise { + try { + const checkUrl = `https://archive.ph/newest/${url}`; + const res = await fetch(checkUrl, { + signal: AbortSignal.timeout(10000), + redirect: "manual", + }); + + // archive.ph returns 302 redirect to the archived page if it exists + if (res.status === 301 || res.status === 302) { + const location = res.headers.get("location"); + if (location) { + return { + success: true, + strategy: "archive.ph", + archiveUrl: location, + }; + } + } + // Sometimes it returns 200 directly with the archived content + if (res.status === 200) { + return { + success: true, + strategy: "archive.ph", + archiveUrl: checkUrl, + }; + } + return { success: false }; + } catch { + return { success: false }; + } +} + +/** + * Try all unlock strategies in sequence. Returns the first successful result. + */ +export async function unlockArticle(url: string): Promise { + // Validate URL + try { + new URL(url); + } catch { + return { success: false, error: "Invalid URL" }; + } + + // Try strategies in order + const strategies = [tryWaybackMachine, tryGoogleCache, tryArchivePh]; + + for (const strategy of strategies) { + const result = await strategy(url); + if (result.success) return result; + } + + return { success: false, error: "No archived version found" }; +} diff --git a/lib/parakeet-offline.ts b/lib/parakeet-offline.ts new file mode 100644 index 0000000..3e93238 --- /dev/null +++ b/lib/parakeet-offline.ts @@ -0,0 +1,139 @@ +/** + * Offline transcription using parakeet.js (NVIDIA Parakeet TDT 0.6B v2). + * Loaded at runtime from CDN to avoid bundling issues with onnxruntime-web. + * Model is ~634 MB (int8) on first download, cached in IndexedDB after. + * + * Ported from rnotes-online/src/lib/parakeetOffline.ts + */ + +const CACHE_KEY = 'parakeet-offline-cached'; + +export interface TranscriptionProgress { + status: 'checking' | 'downloading' | 'loading' | 'transcribing' | 'done' | 'error'; + progress?: number; + file?: string; + message?: string; +} + +type ProgressCallback = (progress: TranscriptionProgress) => void; + +// Singleton model — don't reload on subsequent calls +let cachedModel: any = null; +let loadingPromise: Promise | null = null; + +/** + * Check if the Parakeet model has been downloaded before. + * Best-effort check via localStorage flag; actual cache is in IndexedDB. + */ +export function isModelCached(): boolean { + if (typeof window === 'undefined') return false; + return localStorage.getItem(CACHE_KEY) === 'true'; +} + +/** Detect WebGPU availability in the current browser. */ +async function detectWebGPU(): Promise { + if (typeof navigator === 'undefined' || !(navigator as any).gpu) return false; + try { + const adapter = await (navigator as any).gpu.requestAdapter(); + return !!adapter; + } catch { + return false; + } +} + +/** Get or create the Parakeet model singleton. */ +async function getModel(onProgress?: ProgressCallback): Promise { + if (cachedModel) return cachedModel; + if (loadingPromise) return loadingPromise; + + loadingPromise = (async () => { + onProgress?.({ status: 'loading', message: 'Loading Parakeet model...' }); + + // Load from CDN at runtime — avoids webpack/Terser issues with onnxruntime-web. + const importModule = new Function('url', 'return import(url)'); + const { fromHub } = await importModule('https://esm.sh/parakeet.js@1.1.2'); + + const backend = (await detectWebGPU()) ? 'webgpu' : 'wasm'; + const fileProgress: Record = {}; + + const model = await fromHub('parakeet-tdt-0.6b-v2', { + backend, + progress: ({ file, loaded, total }: { file: string; loaded: number; total: number }) => { + 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 Parakeet 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: Blob): Promise { + 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 model. + */ +export async function transcribeOffline( + audioBlob: Blob, + onProgress?: ProgressCallback +): Promise { + try { + 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; + } catch (err) { + const message = err instanceof Error ? err.message : 'Transcription failed'; + onProgress?.({ status: 'error', message }); + throw err; + } +} diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 718d346..5706bca 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -26,6 +26,7 @@ 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'; const lowlight = createLowlight(common); @@ -46,6 +47,7 @@ const ICONS: Record = { image: '', undo: '', redo: '', + mic: '', }; interface Notebook { @@ -66,10 +68,29 @@ interface Note { type: string; tags: string[] | null; is_pinned: boolean; + url?: string | null; + language?: string | null; + fileUrl?: string | null; + mimeType?: string | null; + duration?: number | null; 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 }; @@ -81,6 +102,8 @@ interface NotebookDoc { id: string; notebookId: string; title: string; content: string; contentPlain: string; contentFormat?: string; type: string; tags: string[]; isPinned: boolean; sortOrder: number; createdAt: number; updatedAt: number; + url?: string | null; language?: string | null; fileUrl?: string | null; + mimeType?: string | null; duration?: number | null; }>; } @@ -93,6 +116,7 @@ class FolkNotesApp extends HTMLElement { private selectedNote: Note | null = null; private searchQuery = ""; private searchResults: Note[] = []; + private typeFilter: NoteType | '' = ''; private loading = false; private error = ""; @@ -106,6 +130,7 @@ class FolkNotesApp extends HTMLElement { private editorNoteId: string | null = null; private isRemoteUpdate = false; private editorUpdateTimer: ReturnType | null = null; + private dictation: SpeechDictation | null = null; // Automerge sync state (via shared runtime) private doc: Automerge.Doc | null = null; @@ -245,6 +270,28 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF }, ]; + // 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", @@ -302,7 +349,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF 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: "6", updated_at: new Date(now - hour).toISOString(), + cover_color: "#f59e0b", note_count: "8", updated_at: new Date(now - hour).toISOString(), notes: tripPlanningNotes, } as any, { @@ -388,14 +435,19 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.mountEditor(newNote); } - private demoCreateNote() { + private demoCreateNote(opts: CreateNoteOpts = {}) { if (!this.selectedNotebook) return; const now = Date.now(); const noteId = `demo-note-${now}`; + const type = opts.type || 'NOTE'; + const title = opts.title || FolkNotesApp.typeDefaultTitle(type); const newNote: Note = { - id: noteId, title: "Untitled Note", content: "", content_plain: "", - content_format: 'tiptap-json', - type: "NOTE", tags: null, is_pinned: false, + id: noteId, title, content: opts.content || "", content_plain: "", + content_format: type === 'CODE' ? 'html' : 'tiptap-json', + type, tags: opts.tags || null, is_pinned: false, + 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 demoNb = this.demoNotebooks.find(n => n.id === this.selectedNotebook!.id); @@ -487,6 +539,11 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF 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, created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), }); @@ -521,6 +578,11 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF 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, created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(), updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(), }; @@ -575,6 +637,11 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF 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, created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(), updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(), }; @@ -587,34 +654,49 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF // ── Automerge mutations ── - private createNoteViaSync() { + private static typeDefaultTitle(type: NoteType): string { + switch (type) { + case 'CODE': return 'Untitled Code Snippet'; + case 'BOOKMARK': return 'Untitled Bookmark'; + case 'CLIP': return 'Untitled Clip'; + case 'IMAGE': return 'Untitled Image'; + case 'AUDIO': return 'Voice Note'; + case 'FILE': return 'Untitled File'; + default: return 'Untitled Note'; + } + } + + private createNoteViaSync(opts: CreateNoteOpts = {}) { if (!this.doc || !this.selectedNotebook || !this.subscribedDocId) return; const noteId = crypto.randomUUID(); const now = Date.now(); const notebookId = this.selectedNotebook.id; + const type = opts.type || 'NOTE'; + const title = opts.title || FolkNotesApp.typeDefaultTitle(type); + const contentFormat = type === 'CODE' ? 'html' : 'tiptap-json'; + + const itemData: any = { + id: noteId, notebookId, title, + content: opts.content || "", contentPlain: "", contentFormat, + type, tags: opts.tags || [], isPinned: false, sortOrder: 0, + createdAt: now, updatedAt: now, + url: opts.url || null, language: opts.language || null, + fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null, + duration: opts.duration ?? null, + }; const runtime = (window as any).__rspaceOfflineRuntime; if (runtime?.isInitialized) { runtime.change(this.subscribedDocId as DocumentId, "Create note", (d: NotebookDoc) => { if (!d.items) (d as any).items = {}; - d.items[noteId] = { - id: noteId, notebookId, title: "Untitled Note", - content: "", contentPlain: "", contentFormat: "tiptap-json", - type: "NOTE", tags: [], isPinned: false, sortOrder: 0, - createdAt: now, updatedAt: now, - }; + 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] = { - id: noteId, notebookId, title: "Untitled Note", - content: "", contentPlain: "", contentFormat: "tiptap-json", - type: "NOTE", tags: [], isPinned: false, sortOrder: 0, - createdAt: now, updatedAt: now, - }; + d.items[noteId] = itemData; }); } @@ -622,9 +704,12 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF // Open the new note this.selectedNote = { - id: noteId, title: "Untitled Note", content: "", content_plain: "", - content_format: 'tiptap-json', - type: "NOTE", tags: null, is_pinned: false, + id: noteId, title, content: opts.content || "", content_plain: "", + content_format: contentFormat, + type, tags: opts.tags || null, is_pinned: false, + 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.view = "note"; @@ -720,6 +805,11 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF 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, created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), }; @@ -776,12 +866,24 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF private mountEditor(note: Note) { this.destroyEditor(); + this.editorNoteId = note.id; - // Build content zone 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; + } + } + + private mountTiptapEditor(note: Note, isEditable: boolean, isDemo: boolean) { this.contentZone.innerHTML = `

@@ -793,42 +895,24 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const container = this.shadow.getElementById('tiptap-container'); if (!container) return; - // Determine content to load let content: any = ''; if (note.content) { if (note.content_format === 'tiptap-json') { - try { - content = JSON.parse(note.content); - } catch { - content = note.content; - } + try { content = JSON.parse(note.content); } catch { content = note.content; } } else { - // HTML content (legacy or explicit) content = note.content; } } - const slashPlugin = createSlashCommandPlugin( - null as any, // Will be set after editor creation - this.shadow - ); - this.editor = new Editor({ element: container, editable: isEditable, extensions: [ - StarterKit.configure({ - codeBlock: false, - heading: { levels: [1, 2, 3, 4] }, - }), + StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] } }), Link.configure({ openOnClick: false }), - Image, - TaskList, - TaskItem.configure({ nested: true }), + Image, TaskList, TaskItem.configure({ nested: true }), Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }), - CodeBlockLowlight.configure({ lowlight }), - Typography, - Underline, + CodeBlockLowlight.configure({ lowlight }), Typography, Underline, ], content, onUpdate: ({ editor }) => { @@ -839,7 +923,6 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const plain = editor.getText(); const noteId = this.editorNoteId; if (!noteId) return; - if (isDemo) { this.demoUpdateNoteField(noteId, "content", json); this.demoUpdateNoteField(noteId, "content_plain", plain); @@ -851,19 +934,11 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } }, 800); }, - onSelectionUpdate: () => { - this.updateToolbarState(); - }, + onSelectionUpdate: () => { this.updateToolbarState(); }, }); - // Now register the slash command plugin with the actual editor - this.editor.registerPlugin( - createSlashCommandPlugin(this.editor, this.shadow) - ); + this.editor.registerPlugin(createSlashCommandPlugin(this.editor, this.shadow)); - this.editorNoteId = note.id; - - // Listen for slash command image insert (custom event from slash-command.ts) container.addEventListener('slash-insert-image', () => { if (!this.editor) return; const { from } = this.editor.view.state.selection; @@ -874,24 +949,284 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF }); }); - // Wire up title input + // Listen for slash-create-typed-note events + 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(); + } + + 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.configure({ openOnClick: false }), Image, + Placeholder.configure({ placeholder: note.type === 'CLIP' ? 'Clipped content...' : 'Add notes about this bookmark...' }), + Typography, Underline, + ], + 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.configure({ openOnClick: false }), + Placeholder.configure({ placeholder: 'Add a caption or notes...' }), Typography, Underline, + ], + 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.configure({ openOnClick: false }), + Placeholder.configure({ placeholder: 'Transcript will appear here...' }), Typography, Underline, + ], + 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); + } + + /** 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); - } + if (isDemo) { this.demoUpdateNoteField(note.id, "title", titleInput.value); } + else { this.updateNoteField(note.id, "title", titleInput.value); } }, 500); }); } - - // Wire up toolbar - this.attachToolbarListeners(); } private destroyEditor() { @@ -899,6 +1234,10 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF clearTimeout(this.editorUpdateTimer); this.editorUpdateTimer = null; } + if (this.dictation) { + this.dictation.destroy(); + this.dictation = null; + } if (this.editor) { this.editor.destroy(); this.editor = null; @@ -950,6 +1289,11 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF ${btn('undo', 'Undo (Ctrl+Z)')} ${btn('redo', 'Redo (Ctrl+Y)')}

+ ${SpeechDictation.isSupported() ? ` +
+
+ ${btn('mic', 'Voice Dictation')} +
` : ''} `; } @@ -1053,6 +1397,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } case 'undo': this.editor.chain().focus().undo().run(); break; case 'redo': this.editor.chain().focus().redo().run(); break; + case 'mic': this.toggleDictation(btn); break; } }); @@ -1071,6 +1416,31 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } } + private toggleDictation(btn: HTMLElement) { + if (this.dictation?.isRecording) { + this.dictation.stop(); + btn.classList.remove('recording'); + return; + } + if (!this.dictation) { + this.dictation = new SpeechDictation({ + onFinal: (text) => { + if (this.editor) { + this.editor.chain().focus().insertContent(text + ' ').run(); + } + }, + onStateChange: (recording) => { + btn.classList.toggle('recording', recording); + }, + onError: (err) => { + console.warn('[Dictation]', err); + btn.classList.remove('recording'); + }, + }); + } + this.dictation.start(); + } + private updateToolbarState() { if (!this.editor) return; const toolbar = this.shadow.getElementById('editor-toolbar'); @@ -1184,6 +1554,14 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const syncBadge = this.subscribedDocId ? `` : ""; + const filterTypes: { label: string; value: NoteType | '' }[] = [ + { label: 'All', value: '' }, + { label: 'Notes', value: 'NOTE' }, + { label: 'Code', value: 'CODE' }, + { label: 'Bookmarks', value: 'BOOKMARK' }, + { label: 'Clips', value: 'CLIP' }, + { label: 'Audio', value: 'AUDIO' }, + ]; this.navZone.innerHTML = `

@@ -1192,8 +1570,55 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF Export - +

+ + + +
+
+
+ ${filterTypes.map(f => ``).join('')}
`; + + // Wire type filter pills + this.navZone.querySelectorAll('[data-type-filter]').forEach(el => { + el.addEventListener('click', () => { + this.typeFilter = ((el as HTMLElement).dataset.typeFilter || '') as NoteType | ''; + this.renderNav(); + this.renderContent(); + this.attachListeners(); + }); + }); + + // Wire new note dropdown + const toggleBtn = this.navZone.querySelector('#new-note-dropdown-toggle'); + const dropdown = this.navZone.querySelector('#new-note-dropdown') as HTMLElement; + if (toggleBtn && dropdown) { + toggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'; + }); + dropdown.querySelectorAll('[data-create-type]').forEach(el => { + el.addEventListener('click', () => { + const type = (el as HTMLElement).dataset.createType as NoteType; + dropdown.style.display = 'none'; + if (this.space === 'demo') { + this.demoCreateNote({ type }); + } else { + this.createNoteViaSync({ type }); + } + }); + }); + // Close dropdown on outside click + document.addEventListener('click', () => { dropdown.style.display = 'none'; }, { once: true }); + } + return; } @@ -1222,9 +1647,12 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF if (this.view === "notebook" && this.selectedNotebook) { const nb = this.selectedNotebook; - this.contentZone.innerHTML = nb.notes && nb.notes.length > 0 - ? nb.notes.map((n) => this.renderNoteItem(n)).join("") - : '

No notes in this notebook.
'; + const filtered = this.typeFilter + ? nb.notes.filter(n => n.type === this.typeFilter) + : nb.notes; + this.contentZone.innerHTML = filtered.length > 0 + ? filtered.map((n) => this.renderNoteItem(n)).join("") + : `
${this.typeFilter ? `No ${this.typeFilter.toLowerCase()} notes in this notebook.` : 'No notes in this notebook.'}
`; return; } @@ -1275,14 +1703,38 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } private renderNoteItem(n: Note): string { + let typeDetail = ''; + switch (n.type) { + case 'BOOKMARK': + case 'CLIP': { + if (n.url) { + try { typeDetail = `${new URL(n.url).hostname}`; } catch { typeDetail = ''; } + } + break; + } + case 'CODE': + if (n.language) typeDetail = `${this.esc(n.language)}`; + break; + case 'AUDIO': + if (n.duration) { + const m = Math.floor(n.duration / 60); + const s = String(n.duration % 60).padStart(2, '0'); + typeDetail = `${m}:${s}`; + } + break; + } + + const typeBorder = this.getTypeBorderColor(n.type); + return ` -

+
${this.getNoteIcon(n.type)}
${n.is_pinned ? '\u{1F4CC} ' : ""}${this.esc(n.title)}
${this.esc(n.content_plain || "")}
${this.formatDate(n.updated_at)} + ${typeDetail} ${n.type} ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""}
@@ -1291,6 +1743,19 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF `; } + private getTypeBorderColor(type: string): string { + switch (type) { + case 'NOTE': return 'var(--rs-primary, #6366f1)'; + case 'CODE': return '#10b981'; + case 'BOOKMARK': return '#f59e0b'; + case 'CLIP': return '#8b5cf6'; + case 'IMAGE': return '#ec4899'; + case 'AUDIO': return '#ef4444'; + case 'FILE': return '#6b7280'; + default: return 'var(--rs-border, #e5e7eb)'; + } + } + private attachListeners() { const isDemo = this.space === "demo"; @@ -1498,6 +1963,90 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .note-item__meta { font-size: 11px; color: var(--rs-text-muted); margin-top: 6px; display: flex; gap: 8px; align-items: center; } .tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: var(--rs-bg-surface-raised); color: var(--rs-text-secondary); font-size: 10px; } + /* ── Type Filter Bar ── */ + .type-filter-bar { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; } + .type-filter-pill { + padding: 4px 12px; border-radius: 16px; border: 1px solid var(--rs-border); + background: transparent; color: var(--rs-text-secondary); font-size: 12px; + cursor: pointer; transition: all 0.15s; font-family: inherit; + } + .type-filter-pill:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); } + .type-filter-pill.active { background: var(--rs-primary); color: #fff; border-color: var(--rs-primary); } + + /* ── New Note Split Button ── */ + .new-note-split { display: flex; position: relative; } + .new-note-split .rapp-nav__btn:first-child { border-radius: 6px 0 0 6px; } + .new-note-dropdown-btn { + border-radius: 0 6px 6px 0 !important; padding: 6px 8px !important; + border-left: 1px solid rgba(255,255,255,0.2) !important; min-width: 28px; + } + .new-note-dropdown { + position: absolute; top: 100%; right: 0; margin-top: 4px; z-index: 50; + background: var(--rs-bg-surface); border: 1px solid var(--rs-border); + border-radius: 8px; box-shadow: var(--rs-shadow-md); min-width: 180px; + overflow: hidden; + } + .new-note-dropdown-item { + padding: 8px 14px; cursor: pointer; font-size: 13px; + color: var(--rs-text-primary); transition: background 0.1s; + } + .new-note-dropdown-item:hover { background: var(--rs-bg-hover); } + + /* ── Note Item Type Badges ── */ + .note-item__badge { + display: inline-block; padding: 1px 6px; border-radius: 3px; + font-size: 10px; font-weight: 500; + } + .badge-url { background: rgba(245, 158, 11, 0.15); color: #d97706; } + .badge-lang { background: rgba(16, 185, 129, 0.15); color: #059669; } + .badge-duration { background: rgba(239, 68, 68, 0.15); color: #dc2626; } + + /* ── 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-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; @@ -1546,6 +2095,8 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .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; } } .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; diff --git a/modules/rnotes/components/folk-voice-recorder.ts b/modules/rnotes/components/folk-voice-recorder.ts new file mode 100644 index 0000000..f719e64 --- /dev/null +++ b/modules/rnotes/components/folk-voice-recorder.ts @@ -0,0 +1,481 @@ +/** + * — 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. + */ + +import { SpeechDictation } from '../../../lib/speech-dictation'; +import { transcribeOffline, isModelCached } from '../../../lib/parakeet-offline'; +import type { TranscriptionProgress } from '../../../lib/parakeet-offline'; +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 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.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 + this.liveTranscript = ''; + if (SpeechDictation.isSupported()) { + this.dictation = new SpeechDictation({ + onFinal: (text) => { this.liveTranscript += text + ' '; this.render(); }, + onInterim: () => { this.render(); }, + }); + this.dictation.start(); + } + + // Start timer + this.recordingStartTime = Date.now(); + this.elapsedSeconds = 0; + this.durationTimer = setInterval(() => { + this.elapsedSeconds = Math.floor((Date.now() - this.recordingStartTime) / 1000); + this.render(); + }, 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; + } + } + + 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 Web Speech API + 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 */ } + + // 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: this.finalTranscript || '', + 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.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.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...

+ ${this.liveTranscript ? `
${esc(this.liveTranscript)}
` : ''} + +
`; + 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(); + } + + 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 { + max-width: 500px; padding: 12px 16px; border-radius: 8px; + background: var(--rs-bg-surface-raised); font-size: 14px; line-height: 1.6; + text-align: left; max-height: 200px; overflow-y: auto; color: var(--rs-text-secondary); + } + .stop-btn { + padding: 12px 32px; border-radius: 50px; border: none; + background: var(--rs-text-primary); color: var(--rs-bg-surface); font-size: 15px; font-weight: 600; + cursor: pointer; + } + + /* Processing */ + .recorder-processing { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 40px; } + .processing-spinner { + width: 48px; height: 48px; border: 3px solid var(--rs-border); + border-top-color: var(--rs-primary); border-radius: 50%; animation: spin 0.8s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + + /* Done */ + .recorder-done { display: flex; flex-direction: column; align-items: center; gap: 12px; width: 100%; } + .result-audio { width: 100%; max-width: 500px; height: 40px; margin-bottom: 8px; } + .result-duration { font-size: 13px; color: var(--rs-text-muted); } + .transcript-section { width: 100%; max-width: 500px; text-align: left; } + .transcript-section label { font-size: 12px; font-weight: 600; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.05em; } + .transcript-textarea { + width: 100%; min-height: 120px; padding: 12px; margin-top: 4px; + border-radius: 8px; border: 1px solid var(--rs-input-border); + background: var(--rs-input-bg); color: var(--rs-input-text); + font-size: 14px; font-family: inherit; line-height: 1.6; resize: vertical; + } + .result-actions { display: flex; gap: 8px; margin-top: 8px; } + .save-btn { + padding: 10px 24px; border-radius: 8px; border: none; + background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; + } + .copy-btn, .discard-btn { + padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; + border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary); + } + .discard-btn { color: var(--rs-error, #ef4444); border-color: var(--rs-error, #ef4444); } + + .toast { + position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); + padding: 10px 20px; border-radius: 8px; background: var(--rs-primary); color: #fff; + font-size: 13px; font-weight: 500; z-index: 100; + } + `; + } +} + +customElements.define('folk-voice-recorder', FolkVoiceRecorder); diff --git a/modules/rnotes/components/slash-command.ts b/modules/rnotes/components/slash-command.ts index 0baf2ad..053383b 100644 --- a/modules/rnotes/components/slash-command.ts +++ b/modules/rnotes/components/slash-command.ts @@ -97,11 +97,28 @@ export const SLASH_ITEMS: SlashMenuItem[] = [ icon: 'image', description: 'Insert an image from URL', command: (e) => { - // Dispatch custom event for parent to show URL popover 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'); diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index 35f2ce5..f10b795 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -20,6 +20,7 @@ import type { NotebookDoc, NoteItem, ConnectionsDoc } from "./schemas"; import { getConverter, getAllConverters } from "./converters/index"; import type { ConvertedNote } from "./converters/index"; import type { SyncServer } from "../../server/local-first/sync-server"; +import { unlockArticle } from "../../lib/article-unlock"; const routes = new Hono(); @@ -1002,7 +1003,134 @@ routes.get("/api/connections", async (c) => { }); }); -// ── Page route ── +// ── File uploads ── + +import { join } from "path"; +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 verifyEncryptIDToken(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 verifyEncryptIDToken(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 verifyEncryptIDToken(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); + } +}); + +// 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 verifyEncryptIDToken(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); + } +}); + +// ── Page routes ── + +// GET /voice — Standalone voice recorder page +routes.get("/voice", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${space} — Voice Recorder | rSpace`, + moduleId: "rnotes", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: ``, + styles: ``, + })); +}); + routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = (c.get("effectiveSpace" as any) as string) || space; @@ -1026,6 +1154,15 @@ export const notesModule: RSpaceModule = { scoping: { defaultScope: 'global', userConfigurable: true }, routes, + settingsSchema: [ + { + key: 'defaultNotebookId', + label: 'Default notebook for imports', + type: 'notebook-id', + description: 'Pre-selected notebook when importing from Logseq, Obsidian, or the web clipper', + }, + ], + docSchemas: [ { pattern: '{space}:notes:notebooks:{notebookId}', diff --git a/server/community-store.ts b/server/community-store.ts index 313bfb9..e7a379b 100644 --- a/server/community-store.ts +++ b/server/community-store.ts @@ -151,6 +151,7 @@ export interface CommunityMeta { connectionPolicy?: ConnectionPolicy; encrypted?: boolean; encryptionKeyId?: string; + moduleSettings?: Record>; } export interface ShapeData { @@ -490,6 +491,7 @@ export function updateSpaceMeta( description?: string; enabledModules?: string[]; moduleScopeOverrides?: Record; + moduleSettings?: Record>; }, ): boolean { const doc = communities.get(slug); @@ -501,6 +503,7 @@ export function updateSpaceMeta( if (fields.description !== undefined) d.meta.description = fields.description; if (fields.enabledModules !== undefined) d.meta.enabledModules = fields.enabledModules; if (fields.moduleScopeOverrides !== undefined) d.meta.moduleScopeOverrides = fields.moduleScopeOverrides; + if (fields.moduleSettings !== undefined) d.meta.moduleSettings = fields.moduleSettings; }); communities.set(slug, newDoc); saveCommunity(slug); diff --git a/server/spaces.ts b/server/spaces.ts index 2f38020..1f170c2 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -347,6 +347,8 @@ spaces.get("/:slug/modules", async (c) => { const enabled = doc.meta.enabledModules; // null = all const overrides = doc.meta.moduleScopeOverrides || {}; + const savedSettings = doc.meta.moduleSettings || {}; + const modules = allModules.map(mod => ({ id: mod.id, name: mod.name, @@ -357,6 +359,8 @@ spaces.get("/:slug/modules", async (c) => { userConfigurable: mod.scoping.userConfigurable, currentScope: overrides[mod.id] || mod.scoping.defaultScope, }, + ...(mod.settingsSchema ? { settingsSchema: mod.settingsSchema } : {}), + ...(savedSettings[mod.id] ? { settings: savedSettings[mod.id] } : {}), })); return c.json({ modules, enabledModules: enabled }); @@ -381,6 +385,7 @@ spaces.patch("/:slug/modules", async (c) => { const body = await c.req.json<{ enabledModules?: string[] | null; scopeOverrides?: Record; + moduleSettings?: Record>; }>(); const updates: Partial = {}; @@ -417,6 +422,23 @@ spaces.patch("/:slug/modules", async (c) => { updates.moduleScopeOverrides = newOverrides; } + // Validate and merge module settings + if (body.moduleSettings) { + const existing = doc.meta.moduleSettings || {}; + const merged = { ...existing }; + for (const [modId, settings] of Object.entries(body.moduleSettings)) { + const mod = getModule(modId); + if (!mod) return c.json({ error: `Unknown module: ${modId}` }, 400); + if (!mod.settingsSchema) return c.json({ error: `Module ${modId} has no settings schema` }, 400); + const validKeys = new Set(mod.settingsSchema.map(f => f.key)); + for (const key of Object.keys(settings)) { + if (!validKeys.has(key)) return c.json({ error: `Unknown setting '${key}' for module ${modId}` }, 400); + } + merged[modId] = { ...(existing[modId] || {}), ...settings }; + } + updates.moduleSettings = merged; + } + if (Object.keys(updates).length > 0) { updateSpaceMeta(slug, updates); } diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index dc4657d..13b56c8 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -844,9 +844,15 @@ export class RStackSpaceSwitcher extends HTMLElement { const modData = await modRes.json(); const encData = encRes.ok ? await encRes.json() : { encrypted: false }; + interface SettingField { + key: string; label: string; type: string; description?: string; + default?: string | boolean; options?: Array<{ value: string; label: string }>; + } interface ModConfig { id: string; name: string; icon: string; enabled: boolean; scoping: { defaultScope: string; userConfigurable: boolean; currentScope: string }; + settingsSchema?: SettingField[]; + settings?: Record; } const modules: ModConfig[] = modData.modules || []; @@ -868,13 +874,17 @@ export class RStackSpaceSwitcher extends HTMLElement { for (const m of modules) { if (m.id === "rspace") continue; // core module, always enabled + const hasSettings = m.settingsSchema && m.settingsSchema.length > 0; html += ` `; if (m.scoping.userConfigurable) { @@ -887,6 +897,35 @@ export class RStackSpaceSwitcher extends HTMLElement {
`; } + + if (hasSettings) { + html += ``; + } } html += ` @@ -897,6 +936,43 @@ export class RStackSpaceSwitcher extends HTMLElement { container.innerHTML = html; + // Gear button toggles settings panel + container.querySelectorAll(".mod-settings-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const modId = (btn as HTMLElement).dataset.modId!; + const panel = container.querySelector(`.mod-settings-panel[data-mod-id="${modId}"]`) as HTMLElement; + if (panel) panel.style.display = panel.style.display === "none" ? "block" : "none"; + }); + }); + + // Populate notebook-id selects + container.querySelectorAll(".mod-notebook-select").forEach(async (sel) => { + const select = sel as HTMLSelectElement; + const space = select.dataset.space!; + const modId = select.dataset.modId!; + const key = select.dataset.key!; + const savedVal = modules.find(m => m.id === modId)?.settings?.[key] || ''; + try { + const token = getAccessToken(); + const nbRes = await fetch(`/${space}/rnotes/api/notebooks`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (nbRes.ok) { + const nbData = await nbRes.json(); + const notebooks = nbData.notebooks || (Array.isArray(nbData) ? nbData : []); + for (const nb of notebooks) { + const opt = document.createElement("option"); + opt.value = nb.id; + opt.textContent = nb.title; + select.appendChild(opt); + } + if (savedVal) select.value = savedVal as string; + } + } catch {} + }); + // Save handler container.querySelector("#es-modules-save")?.addEventListener("click", async () => { statusEl.textContent = "Saving..."; @@ -924,11 +1000,28 @@ export class RStackSpaceSwitcher extends HTMLElement { const token = getAccessToken(); const authHeaders = { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }; + // Collect module settings + const moduleSettings: Record> = {}; + container.querySelectorAll(".mod-setting").forEach((el) => { + const input = el as HTMLInputElement | HTMLSelectElement; + const modId = input.dataset.modId!; + const key = input.dataset.key!; + if (!moduleSettings[modId]) moduleSettings[modId] = {}; + if (input.type === "checkbox") { + moduleSettings[modId][key] = (input as HTMLInputElement).checked; + } else { + moduleSettings[modId][key] = input.value; + } + }); + // Save modules config + const patchBody: Record = { enabledModules, scopeOverrides }; + if (Object.keys(moduleSettings).length > 0) patchBody.moduleSettings = moduleSettings; + const modSaveRes = await fetch(`/api/spaces/${slug}/modules`, { method: "PATCH", headers: authHeaders, - body: JSON.stringify({ enabledModules, scopeOverrides }), + body: JSON.stringify(patchBody), }); if (!modSaveRes.ok) { const d = await modSaveRes.json(); diff --git a/shared/module.ts b/shared/module.ts index 7069bc6..10cce7e 100644 --- a/shared/module.ts +++ b/shared/module.ts @@ -52,6 +52,25 @@ export interface SubPageInfo { bodyHTML?: () => string; } +// ── Per-Module Settings ── + +export type ModuleSettingType = 'string' | 'boolean' | 'select' | 'notebook-id'; + +export interface ModuleSettingField { + /** Storage key */ + key: string; + /** Display label */ + label: string; + /** Field type */ + type: ModuleSettingType; + /** Help text */ + description?: string; + /** Default value */ + default?: string | boolean; + /** Options for 'select' type */ + options?: Array<{ value: string; label: string }>; +} + /** A browsable content type that a module produces. */ export interface OutputPath { /** URL segment: "notebooks" */ @@ -132,6 +151,9 @@ export interface RSpaceModule { /** If true, write operations (POST/PUT/PATCH/DELETE) skip the space role check. * Use for modules whose API endpoints are publicly accessible (e.g. thread builder). */ publicWrite?: boolean; + + /** Per-module settings schema for space-level configuration */ + settingsSchema?: ModuleSettingField[]; } /** Registry of all loaded modules */ @@ -167,6 +189,7 @@ export interface ModuleInfo { }; outputPaths?: OutputPath[]; subPageInfos?: Array<{ path: string; title: string }>; + settingsSchema?: ModuleSettingField[]; } export function getModuleInfoList(): ModuleInfo[] { @@ -185,5 +208,6 @@ export function getModuleInfoList(): ModuleInfo[] { ...(m.externalApp ? { externalApp: m.externalApp } : {}), ...(m.outputPaths ? { outputPaths: m.outputPaths } : {}), ...(m.subPageInfos ? { subPageInfos: m.subPageInfos.map(s => ({ path: s.path, title: s.title })) } : {}), + ...(m.settingsSchema ? { settingsSchema: m.settingsSchema } : {}), })); }