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: `
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 0000000..1e296f9
Binary files /dev/null and b/modules/rnotes/browser-extension/icons/icon-128.png differ
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 0000000..62b0620
Binary files /dev/null and b/modules/rnotes/browser-extension/icons/icon-16.png differ
diff --git a/modules/rnotes/browser-extension/icons/icon-48.png b/modules/rnotes/browser-extension/icons/icon-48.png
new file mode 100644
index 0000000..3851e82
Binary files /dev/null and b/modules/rnotes/browser-extension/icons/icon-48.png differ
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+ Sign in via rNotes Clipper settings first.
+
+
+
+
Ready
+
+
00:00
+
+
+ Live transcribe
+
+
+
+
+
+
+
+
+
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) {