From 7d5209021a6b2f24e87562151bdf7e8753d83384 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 31 Mar 2026 10:37:13 -0700 Subject: [PATCH] feat(rnotes): web clipper download, URL/file note creation from sidebar - Copy browser extension into repo so /extension/download works in Docker - Add "Web Clipper" button to sidebar footer - Replace simple "+" with context menu: New Note / From URL / Upload File - BOOKMARK notes from URL, IMAGE/AUDIO/FILE notes from uploaded files Co-Authored-By: Claude Opus 4.6 --- .../rnotes/browser-extension/background.js | 315 +++++++++ .../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 .../rnotes/browser-extension/manifest.json | 50 ++ modules/rnotes/browser-extension/options.html | 231 +++++++ modules/rnotes/browser-extension/options.js | 179 +++++ .../browser-extension/parakeet-offline.js | 147 +++++ modules/rnotes/browser-extension/popup.html | 262 ++++++++ modules/rnotes/browser-extension/popup.js | 328 ++++++++++ modules/rnotes/browser-extension/voice.html | 414 ++++++++++++ modules/rnotes/browser-extension/voice.js | 610 ++++++++++++++++++ modules/rnotes/components/folk-notes-app.ts | 149 ++++- modules/rnotes/mod.ts | 2 +- 14 files changed, 2683 insertions(+), 4 deletions(-) create mode 100644 modules/rnotes/browser-extension/background.js create mode 100644 modules/rnotes/browser-extension/icons/icon-128.png create mode 100644 modules/rnotes/browser-extension/icons/icon-16.png create mode 100644 modules/rnotes/browser-extension/icons/icon-48.png create mode 100644 modules/rnotes/browser-extension/manifest.json create mode 100644 modules/rnotes/browser-extension/options.html create mode 100644 modules/rnotes/browser-extension/options.js create mode 100644 modules/rnotes/browser-extension/parakeet-offline.js create mode 100644 modules/rnotes/browser-extension/popup.html create mode 100644 modules/rnotes/browser-extension/popup.js create mode 100644 modules/rnotes/browser-extension/voice.html create mode 100644 modules/rnotes/browser-extension/voice.js diff --git a/modules/rnotes/browser-extension/background.js b/modules/rnotes/browser-extension/background.js new file mode 100644 index 0000000..a07f8b3 --- /dev/null +++ b/modules/rnotes/browser-extension/background.js @@ -0,0 +1,315 @@ +const DEFAULT_HOST = 'https://rnotes.online'; + +// --- Context Menu Setup --- + +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.create({ + id: 'clip-page', + title: 'Clip page to rNotes', + contexts: ['page'], + }); + + chrome.contextMenus.create({ + id: 'save-link', + title: 'Save link to rNotes', + contexts: ['link'], + }); + + chrome.contextMenus.create({ + id: 'save-image', + title: 'Save image to rNotes', + contexts: ['image'], + }); + + chrome.contextMenus.create({ + id: 'clip-selection', + title: 'Clip selection to rNotes', + contexts: ['selection'], + }); + + chrome.contextMenus.create({ + id: 'unlock-article', + title: 'Unlock & Clip article to rNotes', + contexts: ['page', 'link'], + }); +}); + +// --- Helpers --- + +async function getSettings() { + const result = await chrome.storage.sync.get(['rnotesHost']); + return { + host: result.rnotesHost || DEFAULT_HOST, + }; +} + +async function getToken() { + const result = await chrome.storage.local.get(['encryptid_token']); + return result.encryptid_token || null; +} + +async function getDefaultNotebook() { + const result = await chrome.storage.local.get(['lastNotebookId']); + return result.lastNotebookId || null; +} + +function showNotification(title, message) { + chrome.notifications.create({ + type: 'basic', + iconUrl: 'icons/icon-128.png', + title: title, + message: message, + }); +} + +async function createNote(data) { + const token = await getToken(); + if (!token) { + showNotification('rNotes Error', 'Not signed in. Open extension settings to sign in.'); + return; + } + + const settings = await getSettings(); + const notebookId = await getDefaultNotebook(); + + const body = { + title: data.title, + content: data.content, + type: data.type || 'CLIP', + url: data.url, + }; + + if (notebookId) body.notebookId = notebookId; + if (data.fileUrl) body.fileUrl = data.fileUrl; + if (data.mimeType) body.mimeType = data.mimeType; + if (data.fileSize) body.fileSize = data.fileSize; + + const response = await fetch(`${settings.host}/api/notes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`${response.status}: ${text}`); + } + + return response.json(); +} + +async function uploadImage(imageUrl) { + const token = await getToken(); + const settings = await getSettings(); + + // Fetch the image + const imgResponse = await fetch(imageUrl); + const blob = await imgResponse.blob(); + + // Extract filename + let filename; + try { + const urlPath = new URL(imageUrl).pathname; + filename = urlPath.split('/').pop() || `image-${Date.now()}.jpg`; + } catch { + filename = `image-${Date.now()}.jpg`; + } + + // Upload to rNotes + const formData = new FormData(); + formData.append('file', blob, filename); + + const response = await fetch(`${settings.host}/api/uploads`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Upload failed: ${response.status} ${text}`); + } + + return response.json(); +} + +async function unlockArticle(url) { + const token = await getToken(); + if (!token) { + showNotification('rNotes Error', 'Not signed in. Open extension settings to sign in.'); + return null; + } + + const settings = await getSettings(); + const response = await fetch(`${settings.host}/api/articles/unlock`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ url }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Unlock failed: ${response.status} ${text}`); + } + + return response.json(); +} + +// --- Context Menu Handler --- + +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + try { + switch (info.menuItemId) { + case 'clip-page': { + // Get page HTML + let content = ''; + try { + const [result] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => document.body.innerHTML, + }); + content = result?.result || ''; + } catch { + content = `

Clipped from ${tab.url}

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

${linkText}

Found on: ${tab.title}

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

Clipped image

Source: ${tab.title}

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

Unlocked via ${result.strategy}

Original: ${targetUrl}

Archive: ${result.archiveUrl}

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

${info.selectionText || ''}

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

${info.selectionText}

`; + } + + await createNote({ + title: `Selection from ${tab.title || 'page'}`, + content: content, + type: 'CLIP', + url: tab.url, + }); + + showNotification('Selection Clipped', `Saved to rNotes`); + break; + } + } + } catch (err) { + console.error('Context menu action failed:', err); + showNotification('rNotes Error', err.message || 'Failed to save'); + } +}); + +// --- Keyboard shortcut handler --- + +chrome.commands.onCommand.addListener(async (command) => { + if (command === 'open-voice-recorder') { + const settings = await getSettings(); + chrome.windows.create({ + url: `${settings.host}/voice`, + type: 'popup', + width: 400, + height: 600, + focused: true, + }); + } +}); + +// --- Message Handler (from popup) --- + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === 'notify') { + showNotification(message.title, message.message); + } +}); diff --git a/modules/rnotes/browser-extension/icons/icon-128.png b/modules/rnotes/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/modules/rnotes/browser-extension/icons/icon-16.png b/modules/rnotes/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/modules/rnotes/browser-extension/manifest.json b/modules/rnotes/browser-extension/manifest.json new file mode 100644 index 0000000..95f6da2 --- /dev/null +++ b/modules/rnotes/browser-extension/manifest.json @@ -0,0 +1,50 @@ +{ + "manifest_version": 3, + "name": "rNotes Web Clipper & Voice", + "version": "1.1.0", + "description": "Clip pages, text, links, and images to rNotes.online. Record voice notes with transcription.", + "permissions": [ + "activeTab", + "contextMenus", + "storage", + "notifications", + "offscreen" + ], + "host_permissions": [ + "https://rnotes.online/*", + "https://auth.ridentity.online/*", + "*://*/*" + ], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "icons": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "background": { + "service_worker": "background.js" + }, + "options_ui": { + "page": "options.html", + "open_in_tab": false + }, + "content_security_policy": { + "extension_pages": "script-src 'self' https://esm.sh; object-src 'self'" + }, + "commands": { + "open-voice-recorder": { + "suggested_key": { + "default": "Ctrl+Shift+V", + "mac": "Command+Shift+V" + }, + "description": "Open rVoice recorder" + } + } +} diff --git a/modules/rnotes/browser-extension/options.html b/modules/rnotes/browser-extension/options.html new file mode 100644 index 0000000..9946840 --- /dev/null +++ b/modules/rnotes/browser-extension/options.html @@ -0,0 +1,231 @@ + + + + + + + +

rNotes Web Clipper Settings

+ + +
+

Connection

+
+ + +
The URL of your rNotes instance
+
+
+ + +
+

Authentication

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

Default Notebook

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

Clipped from ${currentTab.url}

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

${selectedText}

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

Unlocked via ${result.strategy}

Original: ${currentTab.url}

Archive: ${result.archiveUrl}

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

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

')}

` + : '

Voice recording (no transcript)

', + type: 'AUDIO', + mimeType: uploadedMimeType || 'audio/webm', + fileUrl: uploadedFileUrl, + fileSize: uploadedFileSize, + duration: duration, + tags: ['voice'], + }; + + const notebookId = notebookSelect.value; + if (notebookId) body.notebookId = notebookId; + + try { + const res = await fetch(`${settings.host}/api/notes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`${res.status}: ${text}`); + } + + showStatusBar('Saved to rNotes!', 'success'); + + // Notify + chrome.runtime.sendMessage({ + type: 'notify', + title: 'Voice Note Saved', + message: `${formatTime(duration)} recording saved to rNotes`, + }); + + // Reset after short delay + setTimeout(resetState, 1500); + + } catch (err) { + showStatusBar(`Save failed: ${err.message}`, 'error'); + } finally { + saveBtn.disabled = false; + } +} + +// --- Copy to clipboard --- + +async function copyTranscript() { + const text = transcriptText.textContent.trim(); + if (!text || transcriptText.querySelector('.placeholder')) { + showStatusBar('No transcript to copy', 'error'); + return; + } + try { + await navigator.clipboard.writeText(text); + showStatusBar('Copied to clipboard', 'success'); + } catch { + showStatusBar('Copy failed', 'error'); + } +} + +// --- Discard --- + +function resetState() { + state = 'idle'; + mediaRecorder = null; + audioChunks = []; + audioBlob = null; + transcript = ''; + liveTranscript = ''; + uploadedFileUrl = ''; + uploadedMimeType = ''; + uploadedFileSize = 0; + duration = 0; + + stopLiveTranscription(); + + if (audioUrl) { + URL.revokeObjectURL(audioUrl); + audioUrl = null; + } + + timerEl.textContent = '00:00'; + timerEl.classList.remove('recording'); + recBtn.classList.remove('recording'); + setStatusLabel('Ready', 'idle'); + postActions.style.display = 'none'; + audioPreview.classList.remove('visible'); + transcriptArea.classList.remove('visible'); + hideParakeetProgress(); + statusBar.className = 'status-bar'; +} + +// --- Keyboard shortcuts --- + +document.addEventListener('keydown', (e) => { + // Space bar: toggle recording (unless editing transcript) + if (e.code === 'Space' && document.activeElement !== transcriptText) { + e.preventDefault(); + toggleRecording(); + } + // Escape: close window + if (e.code === 'Escape') { + window.close(); + } + // Ctrl+Enter: save (when in done state) + if ((e.ctrlKey || e.metaKey) && e.code === 'Enter' && state === 'done') { + e.preventDefault(); + saveToRNotes(); + } +}); + +// Clear placeholder on focus +transcriptText.addEventListener('focus', () => { + const ph = transcriptText.querySelector('.placeholder'); + if (ph) transcriptText.textContent = ''; +}); + +// --- Event listeners --- + +recBtn.addEventListener('click', toggleRecording); +saveBtn.addEventListener('click', saveToRNotes); +discardBtn.addEventListener('click', resetState); +copyBtn.addEventListener('click', copyTranscript); +closeBtn.addEventListener('click', () => window.close()); + +// --- Init --- + +async function init() { + const token = await getToken(); + const claims = token ? decodeToken(token) : null; + + if (!claims) { + authWarning.style.display = 'block'; + recBtn.style.opacity = '0.3'; + recBtn.style.pointerEvents = 'none'; + return; + } + + authWarning.style.display = 'none'; + await loadNotebooks(); +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 89f4a7b..7c9777d 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -1284,6 +1284,128 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.createNoteViaSync(); } + /** Show a small dropdown menu near the "+" button with note creation options. */ + private showAddNoteMenu(nbId: string, anchorEl: HTMLElement) { + // Remove any existing menu + this.shadow.querySelector('.add-note-menu')?.remove(); + + const menu = document.createElement('div'); + menu.className = 'add-note-menu'; + + // Position near the anchor + const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect(); + const anchorRect = anchorEl.getBoundingClientRect(); + menu.style.left = `${anchorRect.left - hostRect.left}px`; + menu.style.top = `${anchorRect.bottom - hostRect.top + 4}px`; + + menu.innerHTML = ` + + + + `; + + this.shadow.appendChild(menu); + + const close = () => menu.remove(); + + menu.querySelectorAll('.add-note-menu-item').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const action = (btn as HTMLElement).dataset.action; + close(); + if (action === 'note') this.addNoteToNotebook(nbId); + else if (action === 'url') this.createNoteFromUrl(nbId); + else if (action === 'upload') this.createNoteFromFile(nbId); + }); + }); + + // Close on outside click + const onOutside = (e: Event) => { + if (!menu.contains(e.target as Node)) { + close(); + this.shadow.removeEventListener('click', onOutside); + } + }; + requestAnimationFrame(() => this.shadow.addEventListener('click', onOutside)); + } + + /** Prompt for a URL and create a BOOKMARK note. */ + private async createNoteFromUrl(nbId: string) { + const url = prompt('Enter URL:'); + if (!url) return; + + let title: string; + try { title = new URL(url).hostname; } catch { title = url; } + + // Ensure notebook is selected and subscribed + const nb = this.notebooks.find(n => n.id === nbId); + if (!nb) return; + this.selectedNotebook = { ...nb, notes: this.notebookNotes.get(nbId) || [] }; + if (!this.expandedNotebooks.has(nbId)) this.expandedNotebooks.add(nbId); + + if (this.space === 'demo') { + this.demoCreateNote(); + return; + } + + const needSubscribe = !this.subscribedDocId || !this.subscribedDocId.endsWith(`:${nbId}`); + if (needSubscribe) await this.loadNotebook(nbId); + + this.createNoteViaSync({ type: 'BOOKMARK', url, title }); + } + + /** Open a file picker, upload the file, and create a FILE or IMAGE note. */ + private createNoteFromFile(nbId: string) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*,audio/*,video/*,.pdf,.doc,.docx,.txt,.md,.csv,.json'; + + input.addEventListener('change', async () => { + const file = input.files?.[0]; + if (!file) return; + + // Upload the file + const base = this.getApiBase(); + const fd = new FormData(); + fd.append('file', file, file.name); + + try { + const uploadRes = await fetch(`${base}/api/uploads`, { + method: 'POST', headers: this.authHeaders(), body: fd, + }); + if (!uploadRes.ok) throw new Error('Upload failed'); + const uploadData = await uploadRes.json(); + const fileUrl = uploadData.url || uploadData.path; + + // Determine note type + const mime = file.type || ''; + const type: NoteType = mime.startsWith('image/') ? 'IMAGE' + : mime.startsWith('audio/') ? 'AUDIO' : 'FILE'; + + // Ensure notebook ready + const nb = this.notebooks.find(n => n.id === nbId); + if (!nb) return; + this.selectedNotebook = { ...nb, notes: this.notebookNotes.get(nbId) || [] }; + if (!this.expandedNotebooks.has(nbId)) this.expandedNotebooks.add(nbId); + + if (this.space === 'demo') { + this.demoCreateNote(); + return; + } + + const needSubscribe = !this.subscribedDocId || !this.subscribedDocId.endsWith(`:${nbId}`); + if (needSubscribe) await this.loadNotebook(nbId); + + this.createNoteViaSync({ type, fileUrl, mimeType: mime, title: file.name }); + } catch (err) { + console.error('File upload failed:', err); + alert('Failed to upload file. Please try again.'); + } + }); + + input.click(); + } + private loadNote(id: string) { // Note is already in the Automerge doc if (this.doc?.items?.[id]) { @@ -3030,6 +3152,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF

@@ -3188,6 +3311,11 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.openImportExportDialog(); }); + // Web Clipper download + this.shadow.getElementById("btn-web-clipper")?.addEventListener("click", () => { + window.open(`${this.getApiBase()}/extension/download`, '_blank'); + }); + // Tour this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour()); @@ -3200,12 +3328,12 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF }); }); - // Add note to notebook + // Add note to notebook (context menu on +) this.shadow.querySelectorAll("[data-add-note]").forEach(el => { el.addEventListener("click", (e) => { e.stopPropagation(); const nbId = (el as HTMLElement).dataset.addNote!; - this.addNoteToNotebook(nbId); + this.showAddNoteMenu(nbId, el as HTMLElement); }); }); @@ -3545,7 +3673,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .sidebar-footer { padding: 8px 12px; border-top: 1px solid var(--rs-border-subtle); - display: flex; gap: 6px; + display: flex; gap: 6px; flex-wrap: wrap; } .sidebar-footer-btn { padding: 5px 10px; border-radius: 5px; @@ -3555,6 +3683,21 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } .sidebar-footer-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); } + /* Add-note context menu */ + .add-note-menu { + position: absolute; z-index: 100; + background: var(--rs-surface, #fff); border: 1px solid var(--rs-border); + border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); + padding: 4px; min-width: 130px; + } + .add-note-menu-item { + display: block; width: 100%; padding: 6px 10px; + border: none; background: transparent; text-align: left; + font-size: 12px; font-family: inherit; cursor: pointer; + color: var(--rs-text-primary); border-radius: 4px; + } + .add-note-menu-item:hover { background: var(--rs-surface-hover, #f3f4f6); } + /* Sidebar collab info */ .sidebar-collab-info { display: flex; align-items: center; gap: 6px; diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index b4ea758..56f91a1 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -1563,7 +1563,7 @@ routes.get("/extension/download", async (c) => { const { readdir, readFile } = await import("fs/promises"); const { join, resolve } = await import("path"); - const extDir = resolve(import.meta.dir, "../../../rnotes-online/browser-extension"); + const extDir = resolve(import.meta.dir, "browser-extension"); const zip = new JSZip(); async function addDir(dir: string, prefix: string) {