feat(rnotes): type-specific notes, voice recording, web clipper, module settings

Phase 1: All 7 note types (NOTE, CODE, BOOKMARK, CLIP, IMAGE, AUDIO, FILE)
with type-specific editors, filter bar, new-note dropdown, and demo notes.
Phase 1.5: Code Snippet and Voice Note slash commands.
Phase 2: Voice recording with 3-tier transcription cascade (server Whisper,
Web Speech API, offline Parakeet TDT), mic button in toolbar, standalone
voice recorder component, upload/transcribe/diarize server routes.
Phase 3: Manifest V3 browser extension (web clipper) retargeted to
rspace.online with slug-based routing, article unlock via Wayback/Google
Cache/archive.ph strategies.
Phase 4: Per-module settings framework — settingsSchema on modules,
moduleSettings in CommunityMeta, gear icon in space settings Modules tab,
rNotes declares defaultNotebookId setting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 22:39:10 -07:00
parent 63b4294c9c
commit c19142791e
22 changed files with 3691 additions and 76 deletions

View File

@ -0,0 +1,175 @@
const DEFAULT_HOST = 'https://rspace.online';
// --- Context Menu Setup ---
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({ id: 'clip-page', title: 'Clip page to rSpace', contexts: ['page'] });
chrome.contextMenus.create({ id: 'save-link', title: 'Save link to rSpace', contexts: ['link'] });
chrome.contextMenus.create({ id: 'save-image', title: 'Save image to rSpace', contexts: ['image'] });
chrome.contextMenus.create({ id: 'clip-selection', title: 'Clip selection to rSpace', contexts: ['selection'] });
chrome.contextMenus.create({ id: 'unlock-article', title: 'Unlock & Clip article to rSpace', contexts: ['page', 'link'] });
});
// --- Helpers ---
async function getSettings() {
const result = await chrome.storage.sync.get(['rspaceHost', 'rspaceSlug']);
return {
host: result.rspaceHost || DEFAULT_HOST,
slug: result.rspaceSlug || '',
};
}
function apiBase(settings) {
return settings.slug ? `${settings.host}/${settings.slug}/rnotes` : `${settings.host}/rnotes`;
}
async function getToken() {
const result = await chrome.storage.local.get(['encryptid_token']);
return result.encryptid_token || null;
}
async function getDefaultNotebook() {
const result = await chrome.storage.local.get(['lastNotebookId']);
return result.lastNotebookId || null;
}
function showNotification(title, message) {
chrome.notifications.create({
type: 'basic', iconUrl: 'icons/icon-128.png', title, message,
});
}
async function createNote(data) {
const token = await getToken();
if (!token) { showNotification('rSpace Error', 'Not signed in. Open extension settings.'); return; }
const settings = await getSettings();
if (!settings.slug) { showNotification('rSpace Error', 'Configure space slug in extension settings.'); return; }
const notebookId = await getDefaultNotebook();
const body = { title: data.title, content: data.content, type: data.type || 'CLIP', url: data.url };
if (notebookId) body.notebook_id = notebookId;
if (data.fileUrl) body.file_url = data.fileUrl;
if (data.mimeType) body.mime_type = data.mimeType;
if (data.fileSize) body.file_size = data.fileSize;
const response = await fetch(`${apiBase(settings)}/api/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify(body),
});
if (!response.ok) { const text = await response.text(); throw new Error(`${response.status}: ${text}`); }
return response.json();
}
async function uploadImage(imageUrl) {
const token = await getToken();
const settings = await getSettings();
const imgResponse = await fetch(imageUrl);
const blob = await imgResponse.blob();
let filename;
try { filename = new URL(imageUrl).pathname.split('/').pop() || `image-${Date.now()}.jpg`; }
catch { filename = `image-${Date.now()}.jpg`; }
const formData = new FormData();
formData.append('file', blob, filename);
const response = await fetch(`${apiBase(settings)}/api/uploads`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
});
if (!response.ok) { const text = await response.text(); throw new Error(`Upload failed: ${response.status} ${text}`); }
return response.json();
}
async function unlockArticle(url) {
const token = await getToken();
if (!token) { showNotification('rSpace Error', 'Not signed in.'); return null; }
const settings = await getSettings();
const response = await fetch(`${apiBase(settings)}/api/articles/unlock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ url }),
});
if (!response.ok) { const text = await response.text(); throw new Error(`Unlock failed: ${response.status} ${text}`); }
return response.json();
}
// --- Context Menu Handler ---
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
try {
switch (info.menuItemId) {
case 'clip-page': {
let content = '';
try {
const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => document.body.innerHTML });
content = result?.result || '';
} catch { content = `<p>Clipped from <a href="${tab.url}">${tab.url}</a></p>`; }
await createNote({ title: tab.title || 'Untitled Clip', content, type: 'CLIP', url: tab.url });
showNotification('Page Clipped', `"${tab.title}" saved to rSpace`);
break;
}
case 'save-link': {
const linkUrl = info.linkUrl;
const linkText = info.selectionText || linkUrl;
await createNote({ title: linkText, content: `<p><a href="${linkUrl}">${linkText}</a></p><p>Found on: <a href="${tab.url}">${tab.title}</a></p>`, type: 'BOOKMARK', url: linkUrl });
showNotification('Link Saved', 'Bookmark saved to rSpace');
break;
}
case 'save-image': {
const upload = await uploadImage(info.srcUrl);
await createNote({ title: `Image from ${tab.title || 'page'}`, content: `<p><img src="${upload.url}" alt="Clipped image" /></p><p>Source: <a href="${tab.url}">${tab.title}</a></p>`, type: 'IMAGE', url: tab.url, fileUrl: upload.url, mimeType: upload.mimeType, fileSize: upload.size });
showNotification('Image Saved', 'Image saved to rSpace');
break;
}
case 'unlock-article': {
const targetUrl = info.linkUrl || tab.url;
showNotification('Unlocking Article', `Finding readable version of ${new URL(targetUrl).hostname}...`);
const result = await unlockArticle(targetUrl);
if (result?.success && result.archiveUrl) {
await createNote({ title: tab.title || 'Unlocked Article', content: `<p>Unlocked via ${result.strategy}</p><p>Original: <a href="${targetUrl}">${targetUrl}</a></p><p>Archive: <a href="${result.archiveUrl}">${result.archiveUrl}</a></p>`, type: 'CLIP', url: targetUrl });
showNotification('Article Unlocked', `Readable version found via ${result.strategy}`);
chrome.tabs.create({ url: result.archiveUrl });
} else {
showNotification('Unlock Failed', result?.error || 'No archived version found');
}
break;
}
case 'clip-selection': {
let content = '';
try {
const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => { const s = window.getSelection(); if (!s || s.rangeCount === 0) return ''; const r = s.getRangeAt(0); const d = document.createElement('div'); d.appendChild(r.cloneContents()); return d.innerHTML; } });
content = result?.result || '';
} catch { content = `<p>${info.selectionText || ''}</p>`; }
if (!content && info.selectionText) content = `<p>${info.selectionText}</p>`;
await createNote({ title: `Selection from ${tab.title || 'page'}`, content, type: 'CLIP', url: tab.url });
showNotification('Selection Clipped', 'Saved to rSpace');
break;
}
}
} catch (err) {
console.error('Context menu action failed:', err);
showNotification('rSpace Error', err.message || 'Failed to save');
}
});
// --- Keyboard shortcut handler ---
chrome.commands.onCommand.addListener(async (command) => {
if (command === 'open-voice-recorder') {
const settings = await getSettings();
const voiceUrl = settings.slug ? `${settings.host}/${settings.slug}/rnotes/voice` : `${settings.host}/rnotes/voice`;
chrome.windows.create({ url: voiceUrl, type: 'popup', width: 400, height: 600, focused: true });
}
});
// --- Message Handler ---
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'notify') showNotification(message.title, message.message);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B

View File

@ -0,0 +1,50 @@
{
"manifest_version": 3,
"name": "rSpace Web Clipper",
"version": "1.0.0",
"description": "Clip pages, text, links, and images to rSpace. Record voice notes with transcription.",
"permissions": [
"activeTab",
"contextMenus",
"storage",
"notifications",
"offscreen"
],
"host_permissions": [
"https://rspace.online/*",
"https://auth.encryptid.io/*",
"*://*/*"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"background": {
"service_worker": "background.js"
},
"options_ui": {
"page": "options.html",
"open_in_tab": false
},
"content_security_policy": {
"extension_pages": "script-src 'self' https://esm.sh; object-src 'self'"
},
"commands": {
"open-voice-recorder": {
"suggested_key": {
"default": "Ctrl+Shift+V",
"mac": "Command+Shift+V"
},
"description": "Open rVoice recorder"
}
}
}

View File

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { width: 400px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 16px; font-size: 13px; }
h2 { font-size: 16px; color: #6366f1; margin-bottom: 16px; font-weight: 700; }
.section { margin-bottom: 16px; padding: 12px; background: #171717; border-radius: 8px; border: 1px solid #262626; }
.section h3 { font-size: 13px; color: #d4d4d4; margin-bottom: 10px; font-weight: 600; }
.field { margin-bottom: 10px; }
.field:last-child { margin-bottom: 0; }
label { display: block; font-size: 11px; color: #a3a3a3; margin-bottom: 4px; font-weight: 500; }
input[type="text"], input[type="password"], textarea { width: 100%; padding: 7px 10px; background: #0a0a0a; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: 12px; font-family: inherit; outline: none; }
input:focus, textarea:focus { border-color: #6366f1; }
textarea { resize: vertical; min-height: 60px; font-family: 'SF Mono', 'Consolas', monospace; font-size: 11px; }
select { width: 100%; padding: 7px 10px; background: #0a0a0a; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: 12px; outline: none; }
.help { font-size: 10px; color: #737373; margin-top: 3px; }
.auth-status { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 4px; margin-bottom: 10px; font-size: 12px; }
.auth-status.authed { background: #052e16; border: 1px solid #166534; color: #4ade80; }
.auth-status.not-authed { background: #1e1b4b; border: 1px solid #3730a3; color: #a5b4fc; }
.btn-row { display: flex; gap: 8px; margin-top: 10px; }
button { padding: 7px 14px; border: none; border-radius: 5px; font-size: 12px; font-weight: 600; cursor: pointer; transition: opacity 0.15s; }
button:hover { opacity: 0.85; }
.btn-primary { background: #6366f1; color: #fff; }
.btn-secondary { background: #262626; color: #e5e5e5; border: 1px solid #404040; }
.btn-danger { background: #991b1b; color: #fca5a5; }
.btn-small { padding: 5px 10px; font-size: 11px; }
.status { margin-top: 12px; padding: 8px 10px; border-radius: 4px; font-size: 12px; display: none; }
.status.success { background: #052e16; border: 1px solid #166534; color: #4ade80; display: block; }
.status.error { background: #450a0a; border: 1px solid #991b1b; color: #fca5a5; display: block; }
</style>
</head>
<body>
<h2>rSpace Web Clipper Settings</h2>
<div class="section">
<h3>Connection</h3>
<div class="field">
<label for="host">rSpace URL</label>
<input type="text" id="host" value="https://rspace.online" />
<div class="help">The URL of your rSpace instance</div>
</div>
<div class="field">
<label for="slug">Space slug</label>
<input type="text" id="slug" placeholder="my-space" />
<div class="help">Your space name in the URL (e.g. "my-space" from rspace.online/my-space)</div>
</div>
</div>
<div class="section">
<h3>Authentication</h3>
<div id="authStatus" class="auth-status not-authed">Not signed in</div>
<div id="loginSection">
<div class="field">
<label>Step 1: Sign in on rSpace</label>
<button class="btn-secondary btn-small" id="openSigninBtn">Open rSpace Sign-in</button>
<div class="help">Opens rSpace in a new tab. Sign in with your passkey.</div>
</div>
<div class="field">
<label for="tokenInput">Step 2: Paste your token</label>
<textarea id="tokenInput" placeholder="Paste your token here..."></textarea>
</div>
<div class="btn-row">
<button class="btn-primary" id="saveTokenBtn">Save Token</button>
</div>
</div>
<div id="loggedInSection" style="display: none;">
<button class="btn-danger btn-small" id="logoutBtn">Logout</button>
</div>
</div>
<div class="section">
<h3>Default Notebook</h3>
<div class="field">
<label for="defaultNotebook">Save clips to</label>
<select id="defaultNotebook"><option value="">No default (choose each time)</option></select>
<div class="help">Pre-selected notebook when clipping</div>
</div>
</div>
<div class="btn-row" style="justify-content: flex-end;">
<button class="btn-secondary" id="testBtn">Test Connection</button>
<button class="btn-primary" id="saveBtn">Save Settings</button>
</div>
<div id="status" class="status"></div>
<script src="options.js"></script>
</body>
</html>

View File

@ -0,0 +1,206 @@
const DEFAULT_HOST = 'https://rspace.online';
// --- Helpers ---
async function getSettings() {
const result = await chrome.storage.sync.get(['rspaceHost', 'rspaceSlug']);
return {
host: result.rspaceHost || DEFAULT_HOST,
slug: result.rspaceSlug || '',
};
}
function apiBase(settings) {
return settings.slug ? `${settings.host}/${settings.slug}/rnotes` : `${settings.host}/rnotes`;
}
function decodeToken(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp && payload.exp * 1000 < Date.now()) return null;
return payload;
} catch { return null; }
}
function showStatus(message, type) {
const el = document.getElementById('status');
el.textContent = message;
el.className = `status ${type}`;
if (type === 'success') {
setTimeout(() => { el.className = 'status'; }, 3000);
}
}
// --- Auth UI ---
async function updateAuthUI() {
const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']);
const claims = encryptid_token ? decodeToken(encryptid_token) : null;
const authStatus = document.getElementById('authStatus');
const loginSection = document.getElementById('loginSection');
const loggedInSection = document.getElementById('loggedInSection');
if (claims) {
const username = claims.username || claims.sub?.slice(0, 20) || 'Authenticated';
authStatus.textContent = `Signed in as ${username}`;
authStatus.className = 'auth-status authed';
loginSection.style.display = 'none';
loggedInSection.style.display = 'block';
} else {
authStatus.textContent = 'Not signed in';
authStatus.className = 'auth-status not-authed';
loginSection.style.display = 'block';
loggedInSection.style.display = 'none';
}
}
async function populateNotebooks() {
const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']);
if (!encryptid_token) return;
const settings = await getSettings();
if (!settings.slug) return;
try {
const response = await fetch(`${apiBase(settings)}/api/notebooks`, {
headers: { 'Authorization': `Bearer ${encryptid_token}` },
});
if (!response.ok) return;
const data = await response.json();
const notebooks = data.notebooks || (Array.isArray(data) ? data : []);
const select = document.getElementById('defaultNotebook');
// Clear existing options (keep first)
while (select.options.length > 1) {
select.remove(1);
}
for (const nb of notebooks) {
const option = document.createElement('option');
option.value = nb.id;
option.textContent = nb.title;
select.appendChild(option);
}
// Restore saved default
const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']);
if (lastNotebookId) select.value = lastNotebookId;
} catch (err) {
console.error('Failed to load notebooks:', err);
}
}
// --- Load settings ---
async function loadSettings() {
const result = await chrome.storage.sync.get(['rspaceHost', 'rspaceSlug']);
document.getElementById('host').value = result.rspaceHost || DEFAULT_HOST;
document.getElementById('slug').value = result.rspaceSlug || '';
await updateAuthUI();
await populateNotebooks();
}
// --- Event handlers ---
// Open rSpace sign-in
document.getElementById('openSigninBtn').addEventListener('click', () => {
const host = document.getElementById('host').value.replace(/\/+$/, '') || DEFAULT_HOST;
const slug = document.getElementById('slug').value.trim();
const signinUrl = slug
? `${host}/${slug}/auth/signin?extension=true`
: `${host}/auth/signin?extension=true`;
chrome.tabs.create({ url: signinUrl });
});
// Save token
document.getElementById('saveTokenBtn').addEventListener('click', async () => {
const tokenInput = document.getElementById('tokenInput').value.trim();
if (!tokenInput) {
showStatus('Please paste a token', 'error');
return;
}
const claims = decodeToken(tokenInput);
if (!claims) {
showStatus('Invalid or expired token', 'error');
return;
}
await chrome.storage.local.set({ encryptid_token: tokenInput });
document.getElementById('tokenInput').value = '';
showStatus(`Signed in as ${claims.username || claims.sub}`, 'success');
await updateAuthUI();
await populateNotebooks();
});
// Logout
document.getElementById('logoutBtn').addEventListener('click', async () => {
await chrome.storage.local.remove(['encryptid_token']);
showStatus('Signed out', 'success');
await updateAuthUI();
});
// Save settings
document.getElementById('saveBtn').addEventListener('click', async () => {
const host = document.getElementById('host').value.trim().replace(/\/+$/, '');
const slug = document.getElementById('slug').value.trim();
const notebookId = document.getElementById('defaultNotebook').value;
await chrome.storage.sync.set({
rspaceHost: host || DEFAULT_HOST,
rspaceSlug: slug,
});
await chrome.storage.local.set({ lastNotebookId: notebookId });
showStatus('Settings saved', 'success');
});
// Test connection
document.getElementById('testBtn').addEventListener('click', async () => {
const settings = {
host: document.getElementById('host').value.trim().replace(/\/+$/, '') || DEFAULT_HOST,
slug: document.getElementById('slug').value.trim(),
};
if (!settings.slug) {
showStatus('Configure a space slug first', 'error');
return;
}
const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']);
try {
const headers = {};
if (encryptid_token) {
headers['Authorization'] = `Bearer ${encryptid_token}`;
}
const response = await fetch(`${apiBase(settings)}/api/notebooks`, { headers });
if (response.ok) {
const data = await response.json();
const notebooks = data.notebooks || (Array.isArray(data) ? data : []);
showStatus(`Connected! Found ${notebooks.length} notebooks.`, 'success');
} else if (response.status === 401) {
showStatus('Connected but not authenticated. Sign in first.', 'error');
} else {
showStatus(`Connection failed: ${response.status}`, 'error');
}
} catch (err) {
showStatus(`Cannot connect: ${err.message}`, 'error');
}
});
// Default notebook change
document.getElementById('defaultNotebook').addEventListener('change', async (e) => {
await chrome.storage.local.set({ lastNotebookId: e.target.value });
});
// Init
document.addEventListener('DOMContentLoaded', loadSettings);

View File

@ -0,0 +1,120 @@
/**
* Offline transcription using parakeet.js (NVIDIA Parakeet TDT 0.6B v2).
* Loaded at runtime from CDN. Model ~634 MB (int8) on first download,
* cached in IndexedDB after. Works fully offline after first download.
*/
const CACHE_KEY = 'parakeet-offline-cached';
let cachedModel = null;
let loadingPromise = null;
function isModelCached() {
try {
return localStorage.getItem(CACHE_KEY) === 'true';
} catch {
return false;
}
}
async function detectWebGPU() {
if (!navigator.gpu) return false;
try {
const adapter = await navigator.gpu.requestAdapter();
return !!adapter;
} catch {
return false;
}
}
async function getModel(onProgress) {
if (cachedModel) return cachedModel;
if (loadingPromise) return loadingPromise;
loadingPromise = (async () => {
onProgress?.({ status: 'loading', message: 'Loading Parakeet model...' });
const { fromHub } = await import('https://esm.sh/parakeet.js@1.1.2');
const backend = (await detectWebGPU()) ? 'webgpu' : 'wasm';
const fileProgress = {};
const model = await fromHub('parakeet-tdt-0.6b-v2', {
backend,
progress: ({ file, loaded, total }) => {
fileProgress[file] = { loaded, total };
let totalBytes = 0;
let loadedBytes = 0;
for (const fp of Object.values(fileProgress)) {
totalBytes += fp.total || 0;
loadedBytes += fp.loaded || 0;
}
if (totalBytes > 0) {
const pct = Math.round((loadedBytes / totalBytes) * 100);
onProgress?.({
status: 'downloading',
progress: pct,
file,
message: `Downloading model... ${pct}%`,
});
}
},
});
localStorage.setItem(CACHE_KEY, 'true');
onProgress?.({ status: 'loading', message: 'Model loaded' });
cachedModel = model;
loadingPromise = null;
return model;
})();
return loadingPromise;
}
async function decodeAudioBlob(blob) {
const arrayBuffer = await blob.arrayBuffer();
const audioCtx = new AudioContext({ sampleRate: 16000 });
try {
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
if (audioBuffer.sampleRate === 16000 && audioBuffer.numberOfChannels === 1) {
return audioBuffer.getChannelData(0);
}
const numSamples = Math.ceil(audioBuffer.duration * 16000);
const offlineCtx = new OfflineAudioContext(1, numSamples, 16000);
const source = offlineCtx.createBufferSource();
source.buffer = audioBuffer;
source.connect(offlineCtx.destination);
source.start();
const resampled = await offlineCtx.startRendering();
return resampled.getChannelData(0);
} finally {
await audioCtx.close();
}
}
async function transcribeOffline(audioBlob, onProgress) {
const model = await getModel(onProgress);
onProgress?.({ status: 'transcribing', message: 'Transcribing audio...' });
const audioData = await decodeAudioBlob(audioBlob);
const result = await model.transcribe(audioData, 16000, {
returnTimestamps: false,
enableProfiling: false,
});
const text = result.utterance_text?.trim() || '';
onProgress?.({ status: 'done', message: 'Transcription complete' });
return text;
}
window.ParakeetOffline = {
isModelCached,
transcribeOffline,
};

View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { width: 340px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; font-size: 13px; }
.header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: #171717; border-bottom: 1px solid #262626; }
.header .brand { font-weight: 700; font-size: 14px; color: #6366f1; }
.header .user { font-size: 11px; color: #a3a3a3; }
.header .user.not-authed { color: #ef4444; }
.auth-warning { padding: 10px 14px; background: #1e1b4b; border-bottom: 1px solid #3730a3; text-align: center; font-size: 12px; color: #a5b4fc; }
.auth-warning a { color: #818cf8; text-decoration: underline; cursor: pointer; }
.current-page { padding: 10px 14px; border-bottom: 1px solid #262626; }
.current-page .title { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
.current-page .url { font-size: 11px; color: #737373; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.controls { padding: 10px 14px; display: flex; flex-direction: column; gap: 8px; }
label { display: block; font-size: 11px; color: #a3a3a3; margin-bottom: 3px; font-weight: 500; }
select, input[type="text"] { width: 100%; padding: 6px 8px; background: #171717; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: 12px; outline: none; }
select:focus, input[type="text"]:focus { border-color: #6366f1; }
.actions { padding: 0 14px 10px; display: flex; gap: 8px; }
button { flex: 1; padding: 8px 12px; border: none; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px; transition: opacity 0.15s; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button:hover:not(:disabled) { opacity: 0.85; }
.btn-primary { background: #6366f1; color: #fff; }
.btn-secondary { background: #262626; color: #e5e5e5; border: 1px solid #404040; }
.btn-voice { background: #450a0a; color: #fca5a5; border: 1px solid #991b1b; }
.btn-voice svg { flex-shrink: 0; }
.btn-unlock { background: #172554; color: #93c5fd; border: 1px solid #1e40af; }
.btn-unlock svg { flex-shrink: 0; }
.status { margin: 0 14px 10px; padding: 8px 10px; border-radius: 4px; font-size: 12px; display: none; }
.status.success { background: #052e16; border: 1px solid #166534; color: #4ade80; display: block; }
.status.error { background: #450a0a; border: 1px solid #991b1b; color: #fca5a5; display: block; }
.status.loading { background: #172554; border: 1px solid #1e40af; color: #93c5fd; display: block; }
.footer { padding: 8px 14px; border-top: 1px solid #262626; text-align: center; }
.footer a { color: #737373; text-decoration: none; font-size: 11px; }
.footer a:hover { color: #6366f1; }
</style>
</head>
<body>
<div class="header">
<span class="brand">rSpace Clipper</span>
<span class="user" id="userStatus">...</span>
</div>
<div id="authWarning" class="auth-warning" style="display: none;">
Sign in to clip pages. <a id="openSettings">Open Settings</a>
</div>
<div class="current-page">
<div class="title" id="pageTitle">Loading...</div>
<div class="url" id="pageUrl"></div>
</div>
<div class="controls">
<div>
<label for="notebook">Notebook</label>
<select id="notebook"><option value="">No notebook</option></select>
</div>
<div>
<label for="tags">Tags (comma-separated)</label>
<input type="text" id="tags" placeholder="web-clip, research, ..." />
</div>
</div>
<div class="actions">
<button class="btn-primary" id="clipPageBtn" disabled><span>+</span> Clip Page</button>
<button class="btn-secondary" id="clipSelectionBtn" disabled><span>T</span> Clip Selection</button>
</div>
<div class="actions">
<button class="btn-voice" id="voiceBtn" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
Voice Note
</button>
</div>
<div class="actions">
<button class="btn-unlock" id="unlockBtn" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
</svg>
Unlock Article
</button>
</div>
<div id="status" class="status"></div>
<div class="footer">
<a href="#" id="optionsLink">Settings</a>
</div>
<script src="popup.js"></script>
</body>
</html>

299
browser-extension/popup.js Normal file
View File

@ -0,0 +1,299 @@
const DEFAULT_HOST = 'https://rspace.online';
let currentTab = null;
let selectedText = '';
let selectedHtml = '';
// --- Helpers ---
async function getSettings() {
const result = await chrome.storage.sync.get(['rspaceHost', 'rspaceSlug']);
return {
host: result.rspaceHost || DEFAULT_HOST,
slug: result.rspaceSlug || '',
};
}
function apiBase(settings) {
return settings.slug ? `${settings.host}/${settings.slug}/rnotes` : `${settings.host}/rnotes`;
}
async function getToken() {
const result = await chrome.storage.local.get(['encryptid_token']);
return result.encryptid_token || null;
}
function decodeToken(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp && payload.exp * 1000 < Date.now()) return null;
return payload;
} catch { return null; }
}
function parseTags(tagString) {
if (!tagString || !tagString.trim()) return [];
return tagString.split(',').map(t => t.trim().toLowerCase()).filter(Boolean);
}
function showStatus(message, type) {
const el = document.getElementById('status');
el.textContent = message;
el.className = `status ${type}`;
if (type === 'success') {
setTimeout(() => { el.className = 'status'; }, 3000);
}
}
// --- API calls ---
async function createNote(data) {
const token = await getToken();
const settings = await getSettings();
if (!settings.slug) {
showStatus('Configure space slug in Settings first', 'error');
return;
}
const body = {
title: data.title,
content: data.content,
type: data.type || 'CLIP',
url: data.url,
};
const notebookId = document.getElementById('notebook').value;
if (notebookId) body.notebook_id = notebookId;
const tags = parseTags(document.getElementById('tags').value);
if (tags.length > 0) body.tags = tags;
const response = await fetch(`${apiBase(settings)}/api/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`${response.status}: ${text}`);
}
return response.json();
}
async function fetchNotebooks() {
const token = await getToken();
const settings = await getSettings();
if (!settings.slug) return [];
const response = await fetch(`${apiBase(settings)}/api/notebooks`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!response.ok) return [];
const data = await response.json();
return data.notebooks || (Array.isArray(data) ? data : []);
}
// --- UI ---
async function populateNotebooks() {
const select = document.getElementById('notebook');
try {
const notebooks = await fetchNotebooks();
for (const nb of notebooks) {
const option = document.createElement('option');
option.value = nb.id;
option.textContent = nb.title;
select.appendChild(option);
}
const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']);
if (lastNotebookId) select.value = lastNotebookId;
} catch (err) {
console.error('Failed to load notebooks:', err);
}
}
function setupNotebookMemory() {
document.getElementById('notebook').addEventListener('change', (e) => {
chrome.storage.local.set({ lastNotebookId: e.target.value });
});
}
async function init() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
currentTab = tab;
document.getElementById('pageTitle').textContent = tab.title || 'Untitled';
document.getElementById('pageUrl').textContent = tab.url || '';
const token = await getToken();
const claims = token ? decodeToken(token) : null;
const settings = await getSettings();
if (!claims) {
document.getElementById('userStatus').textContent = 'Not signed in';
document.getElementById('userStatus').classList.add('not-authed');
document.getElementById('authWarning').style.display = 'block';
return;
}
if (!settings.slug) {
document.getElementById('userStatus').textContent = 'No space configured';
document.getElementById('userStatus').classList.add('not-authed');
document.getElementById('authWarning').style.display = 'block';
document.getElementById('authWarning').innerHTML = 'Configure your space slug. <a id="openSettings">Open Settings</a>';
document.getElementById('openSettings')?.addEventListener('click', (e) => { e.preventDefault(); chrome.runtime.openOptionsPage(); });
return;
}
document.getElementById('userStatus').textContent = claims.username || claims.sub?.slice(0, 16) || 'Authenticated';
document.getElementById('authWarning').style.display = 'none';
document.getElementById('clipPageBtn').disabled = false;
document.getElementById('unlockBtn').disabled = false;
document.getElementById('voiceBtn').disabled = false;
await populateNotebooks();
setupNotebookMemory();
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return { text: '', html: '' };
const range = selection.getRangeAt(0);
const div = document.createElement('div');
div.appendChild(range.cloneContents());
return { text: selection.toString(), html: div.innerHTML };
},
});
if (result?.result?.text) {
selectedText = result.result.text;
selectedHtml = result.result.html;
document.getElementById('clipSelectionBtn').disabled = false;
}
} catch (err) {
console.warn('Cannot access page content:', err);
}
}
// --- Event handlers ---
document.getElementById('clipPageBtn').addEventListener('click', async () => {
const btn = document.getElementById('clipPageBtn');
btn.disabled = true;
showStatus('Clipping page...', 'loading');
try {
let pageContent = '';
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId: currentTab.id },
func: () => document.body.innerHTML,
});
pageContent = result?.result || '';
} catch {
pageContent = `<p>Clipped from <a href="${currentTab.url}">${currentTab.url}</a></p>`;
}
await createNote({
title: currentTab.title || 'Untitled Clip',
content: pageContent,
type: 'CLIP',
url: currentTab.url,
});
showStatus('Clipped! Note saved.', 'success');
chrome.runtime.sendMessage({ type: 'notify', title: 'Page Clipped', message: `"${currentTab.title}" saved to rSpace` });
} catch (err) {
showStatus(`Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
}
});
document.getElementById('clipSelectionBtn').addEventListener('click', async () => {
const btn = document.getElementById('clipSelectionBtn');
btn.disabled = true;
showStatus('Clipping selection...', 'loading');
try {
const content = selectedHtml || `<p>${selectedText}</p>`;
await createNote({
title: `Selection from ${currentTab.title || 'page'}`,
content,
type: 'CLIP',
url: currentTab.url,
});
showStatus('Selection clipped!', 'success');
chrome.runtime.sendMessage({ type: 'notify', title: 'Selection Clipped', message: 'Saved to rSpace' });
} catch (err) {
showStatus(`Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
}
});
document.getElementById('unlockBtn').addEventListener('click', async () => {
const btn = document.getElementById('unlockBtn');
btn.disabled = true;
showStatus('Unlocking article...', 'loading');
try {
const token = await getToken();
const settings = await getSettings();
const response = await fetch(`${apiBase(settings)}/api/articles/unlock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ url: currentTab.url }),
});
const result = await response.json();
if (result.success && result.archiveUrl) {
await createNote({
title: currentTab.title || 'Unlocked Article',
content: `<p>Unlocked via ${result.strategy}</p><p>Original: <a href="${currentTab.url}">${currentTab.url}</a></p><p>Archive: <a href="${result.archiveUrl}">${result.archiveUrl}</a></p>`,
type: 'CLIP',
url: currentTab.url,
});
showStatus(`Unlocked via ${result.strategy}! Opening...`, 'success');
chrome.tabs.create({ url: result.archiveUrl });
} else {
showStatus(result.error || 'No archived version found', 'error');
}
} catch (err) {
showStatus(`Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
}
});
document.getElementById('voiceBtn').addEventListener('click', async () => {
const settings = await getSettings();
const voiceUrl = settings.slug
? `${settings.host}/${settings.slug}/rnotes/voice`
: `${settings.host}/rnotes/voice`;
chrome.windows.create({ url: voiceUrl, type: 'popup', width: 400, height: 600, focused: true });
window.close();
});
document.getElementById('optionsLink').addEventListener('click', (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
document.getElementById('openSettings')?.addEventListener('click', (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
document.addEventListener('DOMContentLoaded', init);

View File

@ -0,0 +1,414 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 360px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a;
color: #e5e5e5;
font-size: 13px;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: #171717;
border-bottom: 1px solid #262626;
-webkit-app-region: drag;
}
.header .brand {
font-weight: 700;
font-size: 14px;
color: #6366f1;
}
.header .brand-sub {
color: #a3a3a3;
font-weight: 400;
font-size: 12px;
}
.header .close-btn {
-webkit-app-region: no-drag;
background: none;
border: none;
color: #737373;
cursor: pointer;
font-size: 18px;
padding: 2px 6px;
border-radius: 4px;
}
.header .close-btn:hover {
color: #e5e5e5;
background: #262626;
}
.auth-warning {
padding: 10px 14px;
background: #1e1b4b;
border-bottom: 1px solid #3730a3;
text-align: center;
font-size: 12px;
color: #a5b4fc;
}
.recorder {
padding: 20px 14px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
/* Record button */
.rec-btn {
width: 72px;
height: 72px;
border-radius: 50%;
border: 3px solid #404040;
background: #171717;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
position: relative;
}
.rec-btn:hover {
border-color: #6366f1;
}
.rec-btn .inner {
width: 32px;
height: 32px;
background: #6366f1;
border-radius: 50%;
transition: all 0.2s;
}
.rec-btn.recording {
border-color: #ef4444;
}
.rec-btn.recording .inner {
width: 24px;
height: 24px;
border-radius: 4px;
background: #ef4444;
}
.rec-btn.recording::after {
content: '';
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid rgba(239, 68, 68, 0.3);
animation: pulse-ring 1.5s infinite;
}
@keyframes pulse-ring {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(1.15); opacity: 0; }
}
.timer {
font-size: 28px;
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
font-weight: 600;
color: #e5e5e5;
letter-spacing: 2px;
}
.timer.recording {
color: #ef4444;
}
.status-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1.5px;
font-weight: 600;
}
.status-label.idle { color: #737373; }
.status-label.recording { color: #ef4444; }
.status-label.processing { color: #6366f1; }
.status-label.done { color: #4ade80; }
/* Transcript area */
.transcript-area {
width: 100%;
padding: 0 14px 12px;
display: none;
}
.transcript-area.visible {
display: block;
}
.transcript-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: #737373;
margin-bottom: 6px;
font-weight: 600;
}
.transcript-text {
background: #171717;
border: 1px solid #262626;
border-radius: 6px;
padding: 10px 12px;
font-size: 13px;
line-height: 1.5;
color: #d4d4d4;
max-height: 120px;
overflow-y: auto;
min-height: 40px;
white-space: pre-wrap;
}
.transcript-text.editable {
outline: none;
border-color: #404040;
cursor: text;
}
.transcript-text.editable:focus {
border-color: #6366f1;
}
.transcript-text .placeholder {
color: #525252;
font-style: italic;
}
.transcript-text .final-text {
color: #d4d4d4;
}
.transcript-text .interim-text {
color: #737373;
font-style: italic;
}
/* Controls row */
.controls {
width: 100%;
padding: 0 14px 10px;
}
.controls select {
width: 100%;
padding: 6px 8px;
background: #171717;
border: 1px solid #404040;
border-radius: 4px;
color: #e5e5e5;
font-size: 12px;
outline: none;
}
.controls select:focus {
border-color: #6366f1;
}
.controls label {
display: block;
font-size: 10px;
color: #737373;
margin-bottom: 3px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Action buttons */
.actions {
width: 100%;
padding: 0 14px 12px;
display: flex;
gap: 8px;
}
.actions button {
flex: 1;
padding: 8px 12px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.actions button:hover:not(:disabled) { opacity: 0.85; }
.actions button:disabled { opacity: 0.35; cursor: not-allowed; }
.btn-save {
background: #6366f1;
color: #fff;
}
.btn-discard {
background: #262626;
color: #a3a3a3;
border: 1px solid #404040;
}
.btn-copy {
background: #172554;
color: #93c5fd;
border: 1px solid #1e40af;
}
/* Status bar */
.status-bar {
padding: 8px 14px;
border-top: 1px solid #262626;
font-size: 11px;
color: #525252;
text-align: center;
display: none;
}
.status-bar.visible {
display: block;
}
.status-bar.success { color: #4ade80; background: #052e16; border-top-color: #166534; }
.status-bar.error { color: #fca5a5; background: #450a0a; border-top-color: #991b1b; }
.status-bar.loading { color: #93c5fd; background: #172554; border-top-color: #1e40af; }
/* Live indicator */
.live-indicator {
display: none;
align-items: center;
gap: 5px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: #4ade80;
}
.live-indicator.visible {
display: flex;
}
.live-indicator .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #4ade80;
animation: pulse-dot 1s infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Progress bar (for model download) */
.progress-area {
width: 100%;
padding: 0 14px 8px;
display: none;
}
.progress-area.visible {
display: block;
}
.progress-label {
font-size: 11px;
color: #a3a3a3;
margin-bottom: 4px;
}
.progress-bar {
width: 100%;
height: 6px;
background: #262626;
border-radius: 3px;
overflow: hidden;
}
.progress-bar .fill {
height: 100%;
background: #6366f1;
border-radius: 3px;
transition: width 0.3s;
width: 0%;
}
/* Audio preview */
.audio-preview {
width: 100%;
padding: 0 14px 8px;
display: none;
}
.audio-preview.visible {
display: block;
}
.audio-preview audio {
width: 100%;
height: 32px;
}
/* Keyboard hint */
.kbd-hint {
padding: 4px 14px 8px;
text-align: center;
font-size: 10px;
color: #404040;
}
.kbd-hint kbd {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 3px;
padding: 1px 5px;
font-family: inherit;
font-size: 10px;
}
</style>
</head>
<body>
<div class="header">
<span>
<span class="brand">rVoice</span>
<span class="brand-sub">voice notes</span>
</span>
<button class="close-btn" id="closeBtn" title="Close">&times;</button>
</div>
<div id="authWarning" class="auth-warning" style="display: none;">
Sign in via rSpace Clipper settings first.
</div>
<div class="recorder">
<div class="status-label idle" id="statusLabel">Ready</div>
<button class="rec-btn" id="recBtn" title="Start recording">
<div class="inner"></div>
</button>
<div class="timer" id="timer">00:00</div>
<div class="live-indicator" id="liveIndicator">
<span class="dot"></span>
Live transcribe
</div>
</div>
<div class="progress-area" id="progressArea">
<div class="progress-label" id="progressLabel">Loading model...</div>
<div class="progress-bar"><div class="fill" id="progressFill"></div></div>
</div>
<div class="audio-preview" id="audioPreview">
<audio controls id="audioPlayer"></audio>
</div>
<div class="transcript-area" id="transcriptArea">
<div class="transcript-label">Transcript</div>
<div class="transcript-text editable" id="transcriptText" contenteditable="true">
<span class="placeholder">Transcribing...</span>
</div>
</div>
<div class="controls" id="notebookControls">
<label for="notebook">Save to notebook</label>
<select id="notebook">
<option value="">Default notebook</option>
</select>
</div>
<div class="actions" id="postActions" style="display: none;">
<button class="btn-discard" id="discardBtn">Discard</button>
<button class="btn-copy" id="copyBtn" title="Copy transcript">Copy</button>
<button class="btn-save" id="saveBtn">Save to rSpace</button>
</div>
<div class="status-bar" id="statusBar"></div>
<div class="kbd-hint">
<kbd>Space</kbd> to record &middot; <kbd>Esc</kbd> to close &middot; Offline ready
</div>
<script src="parakeet-offline.js" type="module"></script>
<script src="voice.js"></script>
</body>
</html>

579
browser-extension/voice.js Normal file
View File

@ -0,0 +1,579 @@
const DEFAULT_HOST = 'https://rspace.online';
// --- State ---
let state = 'idle'; // idle | recording | processing | done
let mediaRecorder = null;
let audioChunks = [];
let timerInterval = null;
let startTime = 0;
let audioBlob = null;
let audioUrl = null;
let transcript = '';
let liveTranscript = '';
let uploadedFileUrl = '';
let uploadedMimeType = '';
let uploadedFileSize = 0;
let duration = 0;
// Web Speech API
let recognition = null;
let speechSupported = !!(window.SpeechRecognition || window.webkitSpeechRecognition);
// --- DOM refs ---
const recBtn = document.getElementById('recBtn');
const timerEl = document.getElementById('timer');
const statusLabel = document.getElementById('statusLabel');
const transcriptArea = document.getElementById('transcriptArea');
const transcriptText = document.getElementById('transcriptText');
const liveIndicator = document.getElementById('liveIndicator');
const audioPreview = document.getElementById('audioPreview');
const audioPlayer = document.getElementById('audioPlayer');
const notebookSelect = document.getElementById('notebook');
const postActions = document.getElementById('postActions');
const saveBtn = document.getElementById('saveBtn');
const discardBtn = document.getElementById('discardBtn');
const copyBtn = document.getElementById('copyBtn');
const statusBar = document.getElementById('statusBar');
const authWarning = document.getElementById('authWarning');
const closeBtn = document.getElementById('closeBtn');
// --- Helpers ---
async function getSettings() {
const result = await chrome.storage.sync.get(['rspaceHost', 'rspaceSlug']);
return {
host: result.rspaceHost || DEFAULT_HOST,
slug: result.rspaceSlug || '',
};
}
function apiBase(settings) {
return settings.slug ? `${settings.host}/${settings.slug}/rnotes` : `${settings.host}/rnotes`;
}
async function getToken() {
const result = await chrome.storage.local.get(['encryptid_token']);
return result.encryptid_token || null;
}
function decodeToken(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp && payload.exp * 1000 < Date.now()) return null;
return payload;
} catch { return null; }
}
function formatTime(seconds) {
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
function setStatusLabel(text, cls) {
statusLabel.textContent = text;
statusLabel.className = `status-label ${cls}`;
}
function showStatusBar(message, type) {
statusBar.textContent = message;
statusBar.className = `status-bar visible ${type}`;
if (type === 'success') {
setTimeout(() => { statusBar.className = 'status-bar'; }, 3000);
}
}
// --- Parakeet progress UI ---
const progressArea = document.getElementById('progressArea');
const progressLabel = document.getElementById('progressLabel');
const progressFill = document.getElementById('progressFill');
function showParakeetProgress(p) {
if (!progressArea) return;
progressArea.classList.add('visible');
if (p.message) progressLabel.textContent = p.message;
if (p.status === 'downloading' && p.progress !== undefined) {
progressFill.style.width = `${p.progress}%`;
} else if (p.status === 'transcribing') {
progressFill.style.width = '100%';
} else if (p.status === 'loading') {
progressFill.style.width = '0%';
}
}
function hideParakeetProgress() {
if (progressArea) {
progressArea.classList.remove('visible');
progressFill.style.width = '0%';
}
}
// --- Notebook loader ---
async function loadNotebooks() {
const token = await getToken();
if (!token) return;
const settings = await getSettings();
if (!settings.slug) return;
try {
const res = await fetch(`${apiBase(settings)}/api/notebooks`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) return;
const data = await res.json();
const notebooks = data.notebooks || (Array.isArray(data) ? data : []);
for (const nb of notebooks) {
const opt = document.createElement('option');
opt.value = nb.id;
opt.textContent = nb.title;
notebookSelect.appendChild(opt);
}
const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']);
if (lastNotebookId) notebookSelect.value = lastNotebookId;
} catch (err) {
console.error('Failed to load notebooks:', err);
}
}
notebookSelect.addEventListener('change', (e) => {
chrome.storage.local.set({ lastNotebookId: e.target.value });
});
// --- Live transcription (Web Speech API) ---
function startLiveTranscription() {
if (!speechSupported) return;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
let finalizedText = '';
recognition.onresult = (event) => {
let interimText = '';
finalizedText = '';
for (let i = 0; i < event.results.length; i++) {
const result = event.results[i];
if (result.isFinal) {
finalizedText += result[0].transcript.trim() + ' ';
} else {
interimText += result[0].transcript;
}
}
liveTranscript = finalizedText.trim();
updateLiveDisplay(finalizedText.trim(), interimText.trim());
};
recognition.onerror = (event) => {
if (event.error !== 'aborted' && event.error !== 'no-speech') {
console.warn('Speech recognition error:', event.error);
}
};
recognition.onend = () => {
if (state === 'recording' && recognition) {
try { recognition.start(); } catch {}
}
};
try {
recognition.start();
if (liveIndicator) liveIndicator.classList.add('visible');
} catch (err) {
console.warn('Could not start speech recognition:', err);
speechSupported = false;
}
}
function stopLiveTranscription() {
if (recognition) {
const ref = recognition;
recognition = null;
try { ref.stop(); } catch {}
}
if (liveIndicator) liveIndicator.classList.remove('visible');
}
function updateLiveDisplay(finalText, interimText) {
if (state !== 'recording') return;
transcriptArea.classList.add('visible');
let html = '';
if (finalText) html += `<span class="final-text">${escapeHtml(finalText)}</span>`;
if (interimText) html += `<span class="interim-text">${escapeHtml(interimText)}</span>`;
if (!finalText && !interimText) html = '<span class="placeholder">Listening...</span>';
transcriptText.innerHTML = html;
transcriptText.scrollTop = transcriptText.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// --- Recording ---
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
mediaRecorder = new MediaRecorder(stream, { mimeType });
audioChunks = [];
liveTranscript = '';
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) audioChunks.push(e.data);
};
mediaRecorder.start(1000);
startTime = Date.now();
state = 'recording';
recBtn.classList.add('recording');
timerEl.classList.add('recording');
setStatusLabel('Recording', 'recording');
postActions.style.display = 'none';
audioPreview.classList.remove('visible');
statusBar.className = 'status-bar';
if (speechSupported) {
transcriptArea.classList.add('visible');
transcriptText.innerHTML = '<span class="placeholder">Listening...</span>';
} else {
transcriptArea.classList.remove('visible');
}
timerInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
timerEl.textContent = formatTime(elapsed);
}, 1000);
startLiveTranscription();
} catch (err) {
showStatusBar(err.message || 'Microphone access denied', 'error');
}
}
async function stopRecording() {
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
clearInterval(timerInterval);
timerInterval = null;
duration = Math.floor((Date.now() - startTime) / 1000);
const capturedLiveTranscript = liveTranscript;
stopLiveTranscription();
state = 'processing';
recBtn.classList.remove('recording');
timerEl.classList.remove('recording');
setStatusLabel('Processing...', 'processing');
audioBlob = await new Promise((resolve) => {
mediaRecorder.onstop = () => {
mediaRecorder.stream.getTracks().forEach(t => t.stop());
resolve(new Blob(audioChunks, { type: mediaRecorder.mimeType }));
};
mediaRecorder.stop();
});
if (audioUrl) URL.revokeObjectURL(audioUrl);
audioUrl = URL.createObjectURL(audioBlob);
audioPlayer.src = audioUrl;
audioPreview.classList.add('visible');
transcriptArea.classList.add('visible');
if (capturedLiveTranscript) {
transcriptText.textContent = capturedLiveTranscript;
showStatusBar('Improving transcript...', 'loading');
} else {
transcriptText.innerHTML = '<span class="placeholder">Transcribing...</span>';
showStatusBar('Uploading & transcribing...', 'loading');
}
const token = await getToken();
const settings = await getSettings();
try {
const uploadForm = new FormData();
uploadForm.append('file', audioBlob, 'voice-note.webm');
const uploadRes = await fetch(`${apiBase(settings)}/api/uploads`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: uploadForm,
});
if (!uploadRes.ok) throw new Error('Upload failed');
const uploadResult = await uploadRes.json();
uploadedFileUrl = uploadResult.url;
uploadedMimeType = uploadResult.mimeType;
uploadedFileSize = uploadResult.size;
// --- Three-tier transcription cascade ---
// Tier 1: Server (Whisper)
let bestTranscript = '';
try {
showStatusBar('Transcribing via server...', 'loading');
const transcribeForm = new FormData();
transcribeForm.append('audio', audioBlob, 'voice-note.webm');
const transcribeRes = await fetch(`${apiBase(settings)}/api/voice/transcribe`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: transcribeForm,
});
if (transcribeRes.ok) {
const transcribeResult = await transcribeRes.json();
bestTranscript = transcribeResult.text || '';
}
} catch {
console.warn('Tier 1 (batch API) unavailable');
}
// Tier 2: Web Speech API (already captured)
if (!bestTranscript && capturedLiveTranscript) {
bestTranscript = capturedLiveTranscript;
}
// Tier 3: Offline Parakeet.js
if (!bestTranscript && window.ParakeetOffline) {
try {
showStatusBar('Transcribing offline (Parakeet)...', 'loading');
bestTranscript = await window.ParakeetOffline.transcribeOffline(audioBlob, (p) => {
showParakeetProgress(p);
});
hideParakeetProgress();
} catch (offlineErr) {
console.warn('Tier 3 (Parakeet offline) failed:', offlineErr);
hideParakeetProgress();
}
}
transcript = bestTranscript;
if (transcript) {
transcriptText.textContent = transcript;
} else {
transcriptText.innerHTML = '<span class="placeholder">No transcript available - you can type one here</span>';
}
state = 'done';
setStatusLabel('Done', 'done');
postActions.style.display = 'flex';
statusBar.className = 'status-bar';
} catch (err) {
let fallbackTranscript = capturedLiveTranscript || '';
if (!fallbackTranscript && window.ParakeetOffline) {
try {
showStatusBar('Upload failed, transcribing offline...', 'loading');
fallbackTranscript = await window.ParakeetOffline.transcribeOffline(audioBlob, (p) => {
showParakeetProgress(p);
});
hideParakeetProgress();
} catch {
hideParakeetProgress();
}
}
transcript = fallbackTranscript;
if (transcript) transcriptText.textContent = transcript;
showStatusBar(`Error: ${err.message}`, 'error');
state = 'done';
setStatusLabel('Error', 'idle');
postActions.style.display = 'flex';
}
}
function toggleRecording() {
if (state === 'idle' || state === 'done') {
startRecording();
} else if (state === 'recording') {
stopRecording();
}
}
// --- Save to rSpace ---
async function saveToRSpace() {
saveBtn.disabled = true;
showStatusBar('Saving to rSpace...', 'loading');
const token = await getToken();
const settings = await getSettings();
const editedTranscript = transcriptText.textContent.trim();
const isPlaceholder = transcriptText.querySelector('.placeholder') !== null;
const finalTranscript = isPlaceholder ? '' : editedTranscript;
const now = new Date();
const timeStr = now.toLocaleString('en-US', {
month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
hour12: true
});
const body = {
title: `Voice note - ${timeStr}`,
content: finalTranscript
? `<p>${finalTranscript.replace(/\n/g, '</p><p>')}</p>`
: '<p><em>Voice recording (no transcript)</em></p>',
type: 'AUDIO',
mimeType: uploadedMimeType || 'audio/webm',
fileUrl: uploadedFileUrl,
fileSize: uploadedFileSize,
duration: duration,
tags: ['voice'],
};
const notebookId = notebookSelect.value;
if (notebookId) body.notebook_id = notebookId;
try {
const res = await fetch(`${apiBase(settings)}/api/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text}`);
}
showStatusBar('Saved to rSpace!', 'success');
chrome.runtime.sendMessage({
type: 'notify',
title: 'Voice Note Saved',
message: `${formatTime(duration)} recording saved to rSpace`,
});
setTimeout(resetState, 1500);
} catch (err) {
showStatusBar(`Save failed: ${err.message}`, 'error');
} finally {
saveBtn.disabled = false;
}
}
// --- Copy to clipboard ---
async function copyTranscript() {
const text = transcriptText.textContent.trim();
if (!text || transcriptText.querySelector('.placeholder')) {
showStatusBar('No transcript to copy', 'error');
return;
}
try {
await navigator.clipboard.writeText(text);
showStatusBar('Copied to clipboard', 'success');
} catch {
showStatusBar('Copy failed', 'error');
}
}
// --- Discard ---
function resetState() {
state = 'idle';
mediaRecorder = null;
audioChunks = [];
audioBlob = null;
transcript = '';
liveTranscript = '';
uploadedFileUrl = '';
uploadedMimeType = '';
uploadedFileSize = 0;
duration = 0;
stopLiveTranscription();
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
audioUrl = null;
}
timerEl.textContent = '00:00';
timerEl.classList.remove('recording');
recBtn.classList.remove('recording');
setStatusLabel('Ready', 'idle');
postActions.style.display = 'none';
audioPreview.classList.remove('visible');
transcriptArea.classList.remove('visible');
hideParakeetProgress();
statusBar.className = 'status-bar';
}
// --- Keyboard shortcuts ---
document.addEventListener('keydown', (e) => {
if (e.code === 'Space' && document.activeElement !== transcriptText) {
e.preventDefault();
toggleRecording();
}
if (e.code === 'Escape') {
window.close();
}
if ((e.ctrlKey || e.metaKey) && e.code === 'Enter' && state === 'done') {
e.preventDefault();
saveToRSpace();
}
});
transcriptText.addEventListener('focus', () => {
const ph = transcriptText.querySelector('.placeholder');
if (ph) transcriptText.textContent = '';
});
// --- Event listeners ---
recBtn.addEventListener('click', toggleRecording);
saveBtn.addEventListener('click', saveToRSpace);
discardBtn.addEventListener('click', resetState);
copyBtn.addEventListener('click', copyTranscript);
closeBtn.addEventListener('click', () => window.close());
// --- Init ---
async function init() {
const token = await getToken();
const claims = token ? decodeToken(token) : null;
if (!claims) {
authWarning.style.display = 'block';
recBtn.style.opacity = '0.3';
recBtn.style.pointerEvents = 'none';
return;
}
authWarning.style.display = 'none';
await loadNotebooks();
}
document.addEventListener('DOMContentLoaded', init);

113
lib/article-unlock.ts Normal file
View File

@ -0,0 +1,113 @@
/**
* Article unlock strategies find readable/archived versions of paywalled articles.
* Three strategies tried in sequence: Wayback Machine, Google Cache, archive.ph.
*/
interface UnlockResult {
success: boolean;
strategy?: string;
archiveUrl?: string;
error?: string;
}
/** Try the Wayback Machine (web.archive.org). */
async function tryWaybackMachine(url: string): Promise<UnlockResult> {
try {
const apiUrl = `https://archive.org/wayback/available?url=${encodeURIComponent(url)}`;
const res = await fetch(apiUrl, { signal: AbortSignal.timeout(10000) });
if (!res.ok) return { success: false };
const data = await res.json();
const snapshot = data?.archived_snapshots?.closest;
if (snapshot?.available && snapshot.url) {
return {
success: true,
strategy: "Wayback Machine",
archiveUrl: snapshot.url.replace(/^http:/, "https:"),
};
}
return { success: false };
} catch {
return { success: false };
}
}
/** Try Google Cache. */
async function tryGoogleCache(url: string): Promise<UnlockResult> {
try {
const cacheUrl = `https://webcache.googleusercontent.com/search?q=cache:${encodeURIComponent(url)}`;
const res = await fetch(cacheUrl, {
signal: AbortSignal.timeout(10000),
redirect: "manual",
});
// Google cache returns 200 if cached, redirects/errors otherwise
if (res.status === 200) {
return {
success: true,
strategy: "Google Cache",
archiveUrl: cacheUrl,
};
}
return { success: false };
} catch {
return { success: false };
}
}
/** Try archive.ph (archive.today). */
async function tryArchivePh(url: string): Promise<UnlockResult> {
try {
const checkUrl = `https://archive.ph/newest/${url}`;
const res = await fetch(checkUrl, {
signal: AbortSignal.timeout(10000),
redirect: "manual",
});
// archive.ph returns 302 redirect to the archived page if it exists
if (res.status === 301 || res.status === 302) {
const location = res.headers.get("location");
if (location) {
return {
success: true,
strategy: "archive.ph",
archiveUrl: location,
};
}
}
// Sometimes it returns 200 directly with the archived content
if (res.status === 200) {
return {
success: true,
strategy: "archive.ph",
archiveUrl: checkUrl,
};
}
return { success: false };
} catch {
return { success: false };
}
}
/**
* Try all unlock strategies in sequence. Returns the first successful result.
*/
export async function unlockArticle(url: string): Promise<UnlockResult> {
// Validate URL
try {
new URL(url);
} catch {
return { success: false, error: "Invalid URL" };
}
// Try strategies in order
const strategies = [tryWaybackMachine, tryGoogleCache, tryArchivePh];
for (const strategy of strategies) {
const result = await strategy(url);
if (result.success) return result;
}
return { success: false, error: "No archived version found" };
}

139
lib/parakeet-offline.ts Normal file
View File

@ -0,0 +1,139 @@
/**
* Offline transcription using parakeet.js (NVIDIA Parakeet TDT 0.6B v2).
* Loaded at runtime from CDN to avoid bundling issues with onnxruntime-web.
* Model is ~634 MB (int8) on first download, cached in IndexedDB after.
*
* Ported from rnotes-online/src/lib/parakeetOffline.ts
*/
const CACHE_KEY = 'parakeet-offline-cached';
export interface TranscriptionProgress {
status: 'checking' | 'downloading' | 'loading' | 'transcribing' | 'done' | 'error';
progress?: number;
file?: string;
message?: string;
}
type ProgressCallback = (progress: TranscriptionProgress) => void;
// Singleton model — don't reload on subsequent calls
let cachedModel: any = null;
let loadingPromise: Promise<any> | null = null;
/**
* Check if the Parakeet model has been downloaded before.
* Best-effort check via localStorage flag; actual cache is in IndexedDB.
*/
export function isModelCached(): boolean {
if (typeof window === 'undefined') return false;
return localStorage.getItem(CACHE_KEY) === 'true';
}
/** Detect WebGPU availability in the current browser. */
async function detectWebGPU(): Promise<boolean> {
if (typeof navigator === 'undefined' || !(navigator as any).gpu) return false;
try {
const adapter = await (navigator as any).gpu.requestAdapter();
return !!adapter;
} catch {
return false;
}
}
/** Get or create the Parakeet model singleton. */
async function getModel(onProgress?: ProgressCallback): Promise<any> {
if (cachedModel) return cachedModel;
if (loadingPromise) return loadingPromise;
loadingPromise = (async () => {
onProgress?.({ status: 'loading', message: 'Loading Parakeet model...' });
// Load from CDN at runtime — avoids webpack/Terser issues with onnxruntime-web.
const importModule = new Function('url', 'return import(url)');
const { fromHub } = await importModule('https://esm.sh/parakeet.js@1.1.2');
const backend = (await detectWebGPU()) ? 'webgpu' : 'wasm';
const fileProgress: Record<string, { loaded: number; total: number }> = {};
const model = await fromHub('parakeet-tdt-0.6b-v2', {
backend,
progress: ({ file, loaded, total }: { file: string; loaded: number; total: number }) => {
fileProgress[file] = { loaded, total };
let totalBytes = 0;
let loadedBytes = 0;
for (const fp of Object.values(fileProgress)) {
totalBytes += fp.total || 0;
loadedBytes += fp.loaded || 0;
}
if (totalBytes > 0) {
const pct = Math.round((loadedBytes / totalBytes) * 100);
onProgress?.({ status: 'downloading', progress: pct, file, message: `Downloading Parakeet model... ${pct}%` });
}
},
});
localStorage.setItem(CACHE_KEY, 'true');
onProgress?.({ status: 'loading', message: 'Model loaded' });
cachedModel = model;
loadingPromise = null;
return model;
})();
return loadingPromise;
}
/** Decode an audio Blob to Float32Array at 16 kHz mono. */
async function decodeAudioBlob(blob: Blob): Promise<Float32Array> {
const arrayBuffer = await blob.arrayBuffer();
const audioCtx = new AudioContext({ sampleRate: 16000 });
try {
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
if (audioBuffer.sampleRate === 16000 && audioBuffer.numberOfChannels === 1) {
return audioBuffer.getChannelData(0);
}
// Resample via OfflineAudioContext
const numSamples = Math.ceil(audioBuffer.duration * 16000);
const offlineCtx = new OfflineAudioContext(1, numSamples, 16000);
const source = offlineCtx.createBufferSource();
source.buffer = audioBuffer;
source.connect(offlineCtx.destination);
source.start();
const resampled = await offlineCtx.startRendering();
return resampled.getChannelData(0);
} finally {
await audioCtx.close();
}
}
/**
* Transcribe an audio Blob offline using Parakeet in the browser.
* First call downloads the model (~634 MB). Subsequent calls use cached model.
*/
export async function transcribeOffline(
audioBlob: Blob,
onProgress?: ProgressCallback
): Promise<string> {
try {
const model = await getModel(onProgress);
onProgress?.({ status: 'transcribing', message: 'Transcribing audio...' });
const audioData = await decodeAudioBlob(audioBlob);
const result = await model.transcribe(audioData, 16000, {
returnTimestamps: false,
enableProfiling: false,
});
const text = result.utterance_text?.trim() || '';
onProgress?.({ status: 'done', message: 'Transcription complete' });
return text;
} catch (err) {
const message = err instanceof Error ? err.message : 'Transcription failed';
onProgress?.({ status: 'error', message });
throw err;
}
}

View File

@ -26,6 +26,7 @@ import Underline from '@tiptap/extension-underline';
import { common, createLowlight } from 'lowlight';
import { createSlashCommandPlugin } from './slash-command';
import type { ImportExportDialog } from './import-export-dialog';
import { SpeechDictation } from '../../../lib/speech-dictation';
const lowlight = createLowlight(common);
@ -46,6 +47,7 @@ const ICONS: Record<string, string> = {
image: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2.5" width="13" height="11" rx="2"/><circle cx="5.5" cy="6" r="1.5"/><path d="M14.5 10.5l-3.5-3.5-5 5"/></svg>',
undo: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 6 2 8 4 10"/><path d="M2 8h8a4 4 0 0 1 0 8H8"/></svg>',
redo: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="12 6 14 8 12 10"/><path d="M14 8H6a4 4 0 0 0 0 8h2"/></svg>',
mic: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="1" width="6" height="9" rx="3"/><path d="M3 7v1a5 5 0 0 0 10 0V7"/><line x1="8" y1="13" x2="8" y2="15"/><line x1="5.5" y1="15" x2="10.5" y2="15"/></svg>',
};
interface Notebook {
@ -66,10 +68,29 @@ interface Note {
type: string;
tags: string[] | null;
is_pinned: boolean;
url?: string | null;
language?: string | null;
fileUrl?: string | null;
mimeType?: string | null;
duration?: number | null;
created_at: string;
updated_at: string;
}
type NoteType = 'NOTE' | 'CODE' | 'BOOKMARK' | 'CLIP' | 'IMAGE' | 'AUDIO' | 'FILE';
interface CreateNoteOpts {
type?: NoteType;
title?: string;
url?: string;
fileUrl?: string;
mimeType?: string;
duration?: number;
language?: string;
content?: string;
tags?: string[];
}
/** Shape of Automerge notebook doc (matches PG→Automerge migration) */
interface NotebookDoc {
meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number };
@ -81,6 +102,8 @@ interface NotebookDoc {
id: string; notebookId: string; title: string; content: string;
contentPlain: string; contentFormat?: string; type: string; tags: string[]; isPinned: boolean;
sortOrder: number; createdAt: number; updatedAt: number;
url?: string | null; language?: string | null; fileUrl?: string | null;
mimeType?: string | null; duration?: number | null;
}>;
}
@ -93,6 +116,7 @@ class FolkNotesApp extends HTMLElement {
private selectedNote: Note | null = null;
private searchQuery = "";
private searchResults: Note[] = [];
private typeFilter: NoteType | '' = '';
private loading = false;
private error = "";
@ -106,6 +130,7 @@ class FolkNotesApp extends HTMLElement {
private editorNoteId: string | null = null;
private isRemoteUpdate = false;
private editorUpdateTimer: ReturnType<typeof setTimeout> | null = null;
private dictation: SpeechDictation | null = null;
// Automerge sync state (via shared runtime)
private doc: Automerge.Doc<NotebookDoc> | null = null;
@ -245,6 +270,28 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
},
];
// Typed demo notes
tripPlanningNotes.push(
{
id: "demo-note-code-1", title: "Expense Tracker Script",
content: `const expenses = [\n { item: "Flights", amount: 800, category: "transport" },\n { item: "Lac Blanc Hut", amount: 120, category: "accommodation" },\n { item: "Via Ferrata Rental", amount: 100, category: "activities" },\n];\n\nconst total = expenses.reduce((sum, e) => sum + e.amount, 0);\nconsole.log(\`Total: EUR \${total}\`);`,
content_plain: "Expense tracker script for the trip budget",
content_format: 'html',
type: "CODE", tags: ["budget", "code"], is_pinned: false,
language: "javascript",
created_at: new Date(now - 2 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(),
} as Note,
{
id: "demo-note-bookmark-1", title: "Chamonix Weather Forecast",
content: "<p>Live weather forecast for the Chamonix valley. Check daily before hikes.</p>",
content_plain: "Live weather forecast for the Chamonix valley",
content_format: 'html',
type: "BOOKMARK", tags: ["weather", "chamonix"], is_pinned: false,
url: "https://www.chamonix.com/weather",
created_at: new Date(now - 3 * day).toISOString(), updated_at: new Date(now - 4 * hour).toISOString(),
} as Note,
);
const packingNotes: Note[] = [
{
id: "demo-note-7", title: "Packing Checklist",
@ -302,7 +349,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this.demoNotebooks = [
{
id: "demo-nb-1", title: "Alpine Explorer Planning", description: "Shared knowledge base for our July 2026 trip across France, Switzerland, and Italy",
cover_color: "#f59e0b", note_count: "6", updated_at: new Date(now - hour).toISOString(),
cover_color: "#f59e0b", note_count: "8", updated_at: new Date(now - hour).toISOString(),
notes: tripPlanningNotes,
} as any,
{
@ -388,14 +435,19 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this.mountEditor(newNote);
}
private demoCreateNote() {
private demoCreateNote(opts: CreateNoteOpts = {}) {
if (!this.selectedNotebook) return;
const now = Date.now();
const noteId = `demo-note-${now}`;
const type = opts.type || 'NOTE';
const title = opts.title || FolkNotesApp.typeDefaultTitle(type);
const newNote: Note = {
id: noteId, title: "Untitled Note", content: "", content_plain: "",
content_format: 'tiptap-json',
type: "NOTE", tags: null, is_pinned: false,
id: noteId, title, content: opts.content || "", content_plain: "",
content_format: type === 'CODE' ? 'html' : 'tiptap-json',
type, tags: opts.tags || null, is_pinned: false,
url: opts.url || null, language: opts.language || null,
fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null,
duration: opts.duration ?? null,
created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
};
const demoNb = this.demoNotebooks.find(n => n.id === this.selectedNotebook!.id);
@ -487,6 +539,11 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
type: item.type || "NOTE",
tags: item.tags?.length ? Array.from(item.tags) : null,
is_pinned: item.isPinned || false,
url: item.url || null,
language: item.language || null,
fileUrl: item.fileUrl || null,
mimeType: item.mimeType || null,
duration: item.duration ?? null,
created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
});
@ -521,6 +578,11 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
type: noteItem.type || "NOTE",
tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null,
is_pinned: noteItem.isPinned || false,
url: noteItem.url || null,
language: noteItem.language || null,
fileUrl: noteItem.fileUrl || null,
mimeType: noteItem.mimeType || null,
duration: noteItem.duration ?? null,
created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
};
@ -575,6 +637,11 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
type: noteItem.type || "NOTE",
tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null,
is_pinned: noteItem.isPinned || false,
url: noteItem.url || null,
language: noteItem.language || null,
fileUrl: noteItem.fileUrl || null,
mimeType: noteItem.mimeType || null,
duration: noteItem.duration ?? null,
created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
};
@ -587,34 +654,49 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
// ── Automerge mutations ──
private createNoteViaSync() {
private static typeDefaultTitle(type: NoteType): string {
switch (type) {
case 'CODE': return 'Untitled Code Snippet';
case 'BOOKMARK': return 'Untitled Bookmark';
case 'CLIP': return 'Untitled Clip';
case 'IMAGE': return 'Untitled Image';
case 'AUDIO': return 'Voice Note';
case 'FILE': return 'Untitled File';
default: return 'Untitled Note';
}
}
private createNoteViaSync(opts: CreateNoteOpts = {}) {
if (!this.doc || !this.selectedNotebook || !this.subscribedDocId) return;
const noteId = crypto.randomUUID();
const now = Date.now();
const notebookId = this.selectedNotebook.id;
const type = opts.type || 'NOTE';
const title = opts.title || FolkNotesApp.typeDefaultTitle(type);
const contentFormat = type === 'CODE' ? 'html' : 'tiptap-json';
const itemData: any = {
id: noteId, notebookId, title,
content: opts.content || "", contentPlain: "", contentFormat,
type, tags: opts.tags || [], isPinned: false, sortOrder: 0,
createdAt: now, updatedAt: now,
url: opts.url || null, language: opts.language || null,
fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null,
duration: opts.duration ?? null,
};
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime?.isInitialized) {
runtime.change(this.subscribedDocId as DocumentId, "Create note", (d: NotebookDoc) => {
if (!d.items) (d as any).items = {};
d.items[noteId] = {
id: noteId, notebookId, title: "Untitled Note",
content: "", contentPlain: "", contentFormat: "tiptap-json",
type: "NOTE", tags: [], isPinned: false, sortOrder: 0,
createdAt: now, updatedAt: now,
};
d.items[noteId] = itemData;
});
this.doc = runtime.get(this.subscribedDocId as DocumentId);
} else {
this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => {
if (!d.items) (d as any).items = {};
d.items[noteId] = {
id: noteId, notebookId, title: "Untitled Note",
content: "", contentPlain: "", contentFormat: "tiptap-json",
type: "NOTE", tags: [], isPinned: false, sortOrder: 0,
createdAt: now, updatedAt: now,
};
d.items[noteId] = itemData;
});
}
@ -622,9 +704,12 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
// Open the new note
this.selectedNote = {
id: noteId, title: "Untitled Note", content: "", content_plain: "",
content_format: 'tiptap-json',
type: "NOTE", tags: null, is_pinned: false,
id: noteId, title, content: opts.content || "", content_plain: "",
content_format: contentFormat,
type, tags: opts.tags || null, is_pinned: false,
url: opts.url || null, language: opts.language || null,
fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null,
duration: opts.duration ?? null,
created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
};
this.view = "note";
@ -720,6 +805,11 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
type: item.type || "NOTE",
tags: item.tags?.length ? Array.from(item.tags) : null,
is_pinned: item.isPinned || false,
url: item.url || null,
language: item.language || null,
fileUrl: item.fileUrl || null,
mimeType: item.mimeType || null,
duration: item.duration ?? null,
created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
};
@ -776,12 +866,24 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
private mountEditor(note: Note) {
this.destroyEditor();
this.editorNoteId = note.id;
// Build content zone
const isDemo = this.space === "demo";
const isAutomerge = !!(this.doc?.items?.[note.id]);
const isEditable = isAutomerge || isDemo;
// Branch on note type
switch (note.type) {
case 'CODE': this.mountCodeEditor(note, isEditable, isDemo); break;
case 'BOOKMARK':
case 'CLIP': this.mountBookmarkView(note, isEditable, isDemo); break;
case 'IMAGE': this.mountImageView(note, isEditable, isDemo); break;
case 'AUDIO': this.mountAudioView(note, isEditable, isDemo); break;
default: this.mountTiptapEditor(note, isEditable, isDemo); break;
}
}
private mountTiptapEditor(note: Note, isEditable: boolean, isDemo: boolean) {
this.contentZone.innerHTML = `
<div class="editor-wrapper">
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title...">
@ -793,42 +895,24 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
const container = this.shadow.getElementById('tiptap-container');
if (!container) return;
// Determine content to load
let content: any = '';
if (note.content) {
if (note.content_format === 'tiptap-json') {
try {
content = JSON.parse(note.content);
} catch {
content = note.content;
}
try { content = JSON.parse(note.content); } catch { content = note.content; }
} else {
// HTML content (legacy or explicit)
content = note.content;
}
}
const slashPlugin = createSlashCommandPlugin(
null as any, // Will be set after editor creation
this.shadow
);
this.editor = new Editor({
element: container,
editable: isEditable,
extensions: [
StarterKit.configure({
codeBlock: false,
heading: { levels: [1, 2, 3, 4] },
}),
StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] } }),
Link.configure({ openOnClick: false }),
Image,
TaskList,
TaskItem.configure({ nested: true }),
Image, TaskList, TaskItem.configure({ nested: true }),
Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }),
CodeBlockLowlight.configure({ lowlight }),
Typography,
Underline,
CodeBlockLowlight.configure({ lowlight }), Typography, Underline,
],
content,
onUpdate: ({ editor }) => {
@ -839,7 +923,6 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
const plain = editor.getText();
const noteId = this.editorNoteId;
if (!noteId) return;
if (isDemo) {
this.demoUpdateNoteField(noteId, "content", json);
this.demoUpdateNoteField(noteId, "content_plain", plain);
@ -851,19 +934,11 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
}, 800);
},
onSelectionUpdate: () => {
this.updateToolbarState();
},
onSelectionUpdate: () => { this.updateToolbarState(); },
});
// Now register the slash command plugin with the actual editor
this.editor.registerPlugin(
createSlashCommandPlugin(this.editor, this.shadow)
);
this.editor.registerPlugin(createSlashCommandPlugin(this.editor, this.shadow));
this.editorNoteId = note.id;
// Listen for slash command image insert (custom event from slash-command.ts)
container.addEventListener('slash-insert-image', () => {
if (!this.editor) return;
const { from } = this.editor.view.state.selection;
@ -874,24 +949,284 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
});
});
// Wire up title input
// Listen for slash-create-typed-note events
container.addEventListener('slash-create-typed-note', ((e: CustomEvent) => {
const { type } = e.detail || {};
if (type && this.selectedNotebook) {
this.createNoteViaSync({ type });
}
}) as EventListener);
this.wireTitleInput(note, isEditable, isDemo);
this.attachToolbarListeners();
}
private mountCodeEditor(note: Note, isEditable: boolean, isDemo: boolean) {
const languages = ['javascript', 'typescript', 'python', 'rust', 'go', 'html', 'css', 'json', 'sql', 'bash', 'c', 'cpp', 'java', 'ruby', 'php', 'markdown', 'yaml', 'toml', 'other'];
const currentLang = note.language || 'javascript';
this.contentZone.innerHTML = `
<div class="editor-wrapper code-editor-wrapper">
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Code snippet title...">
<div class="code-editor-controls">
<select id="code-lang-select" class="toolbar-select"${!isEditable ? ' disabled' : ''}>
${languages.map(l => `<option value="${l}"${l === currentLang ? ' selected' : ''}>${l}</option>`).join('')}
</select>
</div>
<textarea id="code-textarea" class="code-textarea" placeholder="Paste or type code here..."${!isEditable ? ' readonly' : ''}>${this.esc(note.content || '')}</textarea>
</div>
`;
const textarea = this.shadow.getElementById('code-textarea') as HTMLTextAreaElement;
const langSelect = this.shadow.getElementById('code-lang-select') as HTMLSelectElement;
if (textarea && isEditable) {
let timer: any;
textarea.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (isDemo) {
this.demoUpdateNoteField(note.id, "content", textarea.value);
this.demoUpdateNoteField(note.id, "content_plain", textarea.value);
} else {
this.updateNoteField(note.id, "content", textarea.value);
this.updateNoteField(note.id, "contentPlain", textarea.value);
}
}, 800);
});
// Tab inserts a tab character
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const start = textarea.selectionStart;
textarea.value = textarea.value.substring(0, start) + '\t' + textarea.value.substring(textarea.selectionEnd);
textarea.selectionStart = textarea.selectionEnd = start + 1;
textarea.dispatchEvent(new Event('input'));
}
});
}
if (langSelect && isEditable) {
langSelect.addEventListener('change', () => {
if (isDemo) {
this.demoUpdateNoteField(note.id, "language", langSelect.value);
} else {
this.updateNoteField(note.id, "language", langSelect.value);
}
});
}
this.wireTitleInput(note, isEditable, isDemo);
}
private mountBookmarkView(note: Note, isEditable: boolean, isDemo: boolean) {
const hostname = note.url ? (() => { try { return new URL(note.url).hostname; } catch { return note.url; } })() : '';
const favicon = note.url ? `https://www.google.com/s2/favicons?sz=32&domain=${hostname}` : '';
this.contentZone.innerHTML = `
<div class="editor-wrapper bookmark-wrapper">
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="${note.type === 'CLIP' ? 'Clip title...' : 'Bookmark title...'}">
<div class="bookmark-card">
${favicon ? `<img src="${favicon}" class="bookmark-favicon" alt="" width="24" height="24">` : ''}
<div class="bookmark-info">
${note.url ? `<a href="${this.esc(note.url)}" target="_blank" rel="noopener" class="bookmark-url">${this.esc(hostname)}</a>` : ''}
<div class="bookmark-url-input-row">
<input id="bookmark-url-input" class="bookmark-url-input" value="${this.esc(note.url || '')}" placeholder="Enter URL..."${!isEditable ? ' readonly' : ''}>
</div>
</div>
</div>
<div class="tiptap-container" id="tiptap-container"></div>
</div>
`;
// Mount tiptap for the excerpt/notes
const container = this.shadow.getElementById('tiptap-container');
if (!container) return;
let content: any = '';
if (note.content) {
if (note.content_format === 'tiptap-json') {
try { content = JSON.parse(note.content); } catch { content = note.content; }
} else { content = note.content; }
}
this.editor = new Editor({
element: container, editable: isEditable,
extensions: [
StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3] } }),
Link.configure({ openOnClick: false }), Image,
Placeholder.configure({ placeholder: note.type === 'CLIP' ? 'Clipped content...' : 'Add notes about this bookmark...' }),
Typography, Underline,
],
content,
onUpdate: ({ editor }) => {
if (this.isRemoteUpdate) return;
if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer);
this.editorUpdateTimer = setTimeout(() => {
const json = JSON.stringify(editor.getJSON());
const plain = editor.getText();
const noteId = this.editorNoteId;
if (!noteId) return;
if (isDemo) {
this.demoUpdateNoteField(noteId, "content", json);
this.demoUpdateNoteField(noteId, "content_plain", plain);
} else {
this.updateNoteField(noteId, "content", json);
this.updateNoteField(noteId, "contentPlain", plain);
}
}, 800);
},
});
// Wire URL input
const urlInput = this.shadow.getElementById('bookmark-url-input') as HTMLInputElement;
if (urlInput && isEditable) {
let timer: any;
urlInput.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (isDemo) {
this.demoUpdateNoteField(note.id, "url", urlInput.value);
} else {
this.updateNoteField(note.id, "url", urlInput.value);
}
}, 500);
});
}
this.wireTitleInput(note, isEditable, isDemo);
}
private mountImageView(note: Note, isEditable: boolean, isDemo: boolean) {
this.contentZone.innerHTML = `
<div class="editor-wrapper image-wrapper">
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Image title...">
${note.fileUrl
? `<div class="image-display"><img src="${this.esc(note.fileUrl)}" alt="${this.esc(note.title)}" class="image-preview"></div>`
: `<div class="image-upload-placeholder">
<input id="image-url-input" class="bookmark-url-input" value="${this.esc(note.fileUrl || '')}" placeholder="Enter image URL..."${!isEditable ? ' readonly' : ''}>
</div>`
}
<div class="tiptap-container" id="tiptap-container"></div>
</div>
`;
// Mount tiptap for caption/notes
const container = this.shadow.getElementById('tiptap-container');
if (container) {
let content: any = '';
if (note.content) {
if (note.content_format === 'tiptap-json') {
try { content = JSON.parse(note.content); } catch { content = note.content; }
} else { content = note.content; }
}
this.editor = new Editor({
element: container, editable: isEditable,
extensions: [
StarterKit.configure({ codeBlock: false }), Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: 'Add a caption or notes...' }), Typography, Underline,
],
content,
onUpdate: ({ editor }) => {
if (this.isRemoteUpdate) return;
if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer);
this.editorUpdateTimer = setTimeout(() => {
const json = JSON.stringify(editor.getJSON());
const plain = editor.getText();
const noteId = this.editorNoteId;
if (!noteId) return;
if (isDemo) { this.demoUpdateNoteField(noteId, "content", json); this.demoUpdateNoteField(noteId, "content_plain", plain); }
else { this.updateNoteField(noteId, "content", json); this.updateNoteField(noteId, "contentPlain", plain); }
}, 800);
},
});
}
// Wire image URL input
const imgUrlInput = this.shadow.getElementById('image-url-input') as HTMLInputElement;
if (imgUrlInput && isEditable) {
let timer: any;
imgUrlInput.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (isDemo) { this.demoUpdateNoteField(note.id, "fileUrl", imgUrlInput.value); }
else { this.updateNoteField(note.id, "fileUrl", imgUrlInput.value); }
}, 500);
});
}
this.wireTitleInput(note, isEditable, isDemo);
}
private mountAudioView(note: Note, isEditable: boolean, isDemo: boolean) {
const durationStr = note.duration ? `${Math.floor(note.duration / 60)}:${String(note.duration % 60).padStart(2, '0')}` : '';
this.contentZone.innerHTML = `
<div class="editor-wrapper audio-wrapper">
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Voice note title...">
${note.fileUrl
? `<div class="audio-player-container">
<audio controls src="${this.esc(note.fileUrl)}" class="audio-player"></audio>
${durationStr ? `<span class="audio-duration">${durationStr}</span>` : ''}
</div>`
: `<div class="audio-record-placeholder">
<button class="rapp-nav__btn" id="btn-start-recording">Record Voice Note</button>
</div>`
}
<div class="audio-transcript-section">
<div class="audio-transcript-label">Transcript</div>
<div class="tiptap-container" id="tiptap-container"></div>
</div>
</div>
`;
// Mount tiptap for transcript
const container = this.shadow.getElementById('tiptap-container');
if (container) {
let content: any = '';
if (note.content) {
if (note.content_format === 'tiptap-json') {
try { content = JSON.parse(note.content); } catch { content = note.content; }
} else { content = note.content; }
}
this.editor = new Editor({
element: container, editable: isEditable,
extensions: [
StarterKit.configure({ codeBlock: false }), Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: 'Transcript will appear here...' }), Typography, Underline,
],
content,
onUpdate: ({ editor }) => {
if (this.isRemoteUpdate) return;
if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer);
this.editorUpdateTimer = setTimeout(() => {
const json = JSON.stringify(editor.getJSON());
const plain = editor.getText();
const noteId = this.editorNoteId;
if (!noteId) return;
if (isDemo) { this.demoUpdateNoteField(noteId, "content", json); this.demoUpdateNoteField(noteId, "content_plain", plain); }
else { this.updateNoteField(noteId, "content", json); this.updateNoteField(noteId, "contentPlain", plain); }
}, 800);
},
});
}
this.wireTitleInput(note, isEditable, isDemo);
}
/** Shared title input wiring for all editor types */
private wireTitleInput(note: Note, _isEditable: boolean, isDemo: boolean) {
const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement;
if (titleInput) {
let titleTimeout: any;
titleInput.addEventListener("input", () => {
clearTimeout(titleTimeout);
titleTimeout = setTimeout(() => {
if (isDemo) {
this.demoUpdateNoteField(note.id, "title", titleInput.value);
} else {
this.updateNoteField(note.id, "title", titleInput.value);
}
if (isDemo) { this.demoUpdateNoteField(note.id, "title", titleInput.value); }
else { this.updateNoteField(note.id, "title", titleInput.value); }
}, 500);
});
}
// Wire up toolbar
this.attachToolbarListeners();
}
private destroyEditor() {
@ -899,6 +1234,10 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
clearTimeout(this.editorUpdateTimer);
this.editorUpdateTimer = null;
}
if (this.dictation) {
this.dictation.destroy();
this.dictation = null;
}
if (this.editor) {
this.editor.destroy();
this.editor = null;
@ -950,6 +1289,11 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
${btn('undo', 'Undo (Ctrl+Z)')}
${btn('redo', 'Redo (Ctrl+Y)')}
</div>
${SpeechDictation.isSupported() ? `
<div class="toolbar-sep"></div>
<div class="toolbar-group">
${btn('mic', 'Voice Dictation')}
</div>` : ''}
</div>`;
}
@ -1053,6 +1397,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
case 'undo': this.editor.chain().focus().undo().run(); break;
case 'redo': this.editor.chain().focus().redo().run(); break;
case 'mic': this.toggleDictation(btn); break;
}
});
@ -1071,6 +1416,31 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
}
private toggleDictation(btn: HTMLElement) {
if (this.dictation?.isRecording) {
this.dictation.stop();
btn.classList.remove('recording');
return;
}
if (!this.dictation) {
this.dictation = new SpeechDictation({
onFinal: (text) => {
if (this.editor) {
this.editor.chain().focus().insertContent(text + ' ').run();
}
},
onStateChange: (recording) => {
btn.classList.toggle('recording', recording);
},
onError: (err) => {
console.warn('[Dictation]', err);
btn.classList.remove('recording');
},
});
}
this.dictation.start();
}
private updateToolbarState() {
if (!this.editor) return;
const toolbar = this.shadow.getElementById('editor-toolbar');
@ -1184,6 +1554,14 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
const syncBadge = this.subscribedDocId
? `<span class="sync-badge ${this.syncConnected ? "connected" : "disconnected"}" title="${this.syncConnected ? "Live sync" : "Reconnecting..."}"></span>`
: "";
const filterTypes: { label: string; value: NoteType | '' }[] = [
{ label: 'All', value: '' },
{ label: 'Notes', value: 'NOTE' },
{ label: 'Code', value: 'CODE' },
{ label: 'Bookmarks', value: 'BOOKMARK' },
{ label: 'Clips', value: 'CLIP' },
{ label: 'Audio', value: 'AUDIO' },
];
this.navZone.innerHTML = `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="notebooks">\u2190 Notebooks</button>
@ -1192,8 +1570,55 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 10V2M5 5l3-3 3 3"/><path d="M2 12v2h12v-2"/></svg>
Export
</button>
<div class="new-note-split">
<button class="rapp-nav__btn" id="create-note">+ New Note</button>
<button class="rapp-nav__btn new-note-dropdown-btn" id="new-note-dropdown-toggle">\u25BE</button>
<div class="new-note-dropdown" id="new-note-dropdown" style="display:none">
<div class="new-note-dropdown-item" data-create-type="CODE">\u{1F4BB} Code Snippet</div>
<div class="new-note-dropdown-item" data-create-type="BOOKMARK">\u{1F517} Bookmark</div>
<div class="new-note-dropdown-item" data-create-type="CLIP">\u2702\uFE0F Clip</div>
<div class="new-note-dropdown-item" data-create-type="AUDIO">\u{1F3A4} Voice Note</div>
<div class="new-note-dropdown-item" data-create-type="IMAGE">\u{1F5BC}\uFE0F Image</div>
</div>
</div>
</div>
<div class="type-filter-bar">
${filterTypes.map(f => `<button class="type-filter-pill${this.typeFilter === f.value ? ' active' : ''}" data-type-filter="${f.value}">${f.label}</button>`).join('')}
</div>`;
// Wire type filter pills
this.navZone.querySelectorAll('[data-type-filter]').forEach(el => {
el.addEventListener('click', () => {
this.typeFilter = ((el as HTMLElement).dataset.typeFilter || '') as NoteType | '';
this.renderNav();
this.renderContent();
this.attachListeners();
});
});
// Wire new note dropdown
const toggleBtn = this.navZone.querySelector('#new-note-dropdown-toggle');
const dropdown = this.navZone.querySelector('#new-note-dropdown') as HTMLElement;
if (toggleBtn && dropdown) {
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
});
dropdown.querySelectorAll('[data-create-type]').forEach(el => {
el.addEventListener('click', () => {
const type = (el as HTMLElement).dataset.createType as NoteType;
dropdown.style.display = 'none';
if (this.space === 'demo') {
this.demoCreateNote({ type });
} else {
this.createNoteViaSync({ type });
}
});
});
// Close dropdown on outside click
document.addEventListener('click', () => { dropdown.style.display = 'none'; }, { once: true });
}
return;
}
@ -1222,9 +1647,12 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
if (this.view === "notebook" && this.selectedNotebook) {
const nb = this.selectedNotebook;
this.contentZone.innerHTML = nb.notes && nb.notes.length > 0
? nb.notes.map((n) => this.renderNoteItem(n)).join("")
: '<div class="empty">No notes in this notebook.</div>';
const filtered = this.typeFilter
? nb.notes.filter(n => n.type === this.typeFilter)
: nb.notes;
this.contentZone.innerHTML = filtered.length > 0
? filtered.map((n) => this.renderNoteItem(n)).join("")
: `<div class="empty">${this.typeFilter ? `No ${this.typeFilter.toLowerCase()} notes in this notebook.` : 'No notes in this notebook.'}</div>`;
return;
}
@ -1275,14 +1703,38 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
private renderNoteItem(n: Note): string {
let typeDetail = '';
switch (n.type) {
case 'BOOKMARK':
case 'CLIP': {
if (n.url) {
try { typeDetail = `<span class="note-item__badge badge-url">${new URL(n.url).hostname}</span>`; } catch { typeDetail = ''; }
}
break;
}
case 'CODE':
if (n.language) typeDetail = `<span class="note-item__badge badge-lang">${this.esc(n.language)}</span>`;
break;
case 'AUDIO':
if (n.duration) {
const m = Math.floor(n.duration / 60);
const s = String(n.duration % 60).padStart(2, '0');
typeDetail = `<span class="note-item__badge badge-duration">${m}:${s}</span>`;
}
break;
}
const typeBorder = this.getTypeBorderColor(n.type);
return `
<div class="note-item" data-note="${n.id}">
<div class="note-item" data-note="${n.id}" style="border-left: 3px solid ${typeBorder}">
<span class="note-item__icon">${this.getNoteIcon(n.type)}</span>
<div class="note-item__body">
<div class="note-item__title">${n.is_pinned ? '<span class="note-item__pin">\u{1F4CC}</span> ' : ""}${this.esc(n.title)}</div>
<div class="note-item__preview">${this.esc(n.content_plain || "")}</div>
<div class="note-item__meta">
<span>${this.formatDate(n.updated_at)}</span>
${typeDetail}
<span>${n.type}</span>
${n.tags ? n.tags.map((t) => `<span class="tag">${this.esc(t)}</span>`).join("") : ""}
</div>
@ -1291,6 +1743,19 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
`;
}
private getTypeBorderColor(type: string): string {
switch (type) {
case 'NOTE': return 'var(--rs-primary, #6366f1)';
case 'CODE': return '#10b981';
case 'BOOKMARK': return '#f59e0b';
case 'CLIP': return '#8b5cf6';
case 'IMAGE': return '#ec4899';
case 'AUDIO': return '#ef4444';
case 'FILE': return '#6b7280';
default: return 'var(--rs-border, #e5e7eb)';
}
}
private attachListeners() {
const isDemo = this.space === "demo";
@ -1498,6 +1963,90 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
.note-item__meta { font-size: 11px; color: var(--rs-text-muted); margin-top: 6px; display: flex; gap: 8px; align-items: center; }
.tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: var(--rs-bg-surface-raised); color: var(--rs-text-secondary); font-size: 10px; }
/* ── Type Filter Bar ── */
.type-filter-bar { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; }
.type-filter-pill {
padding: 4px 12px; border-radius: 16px; border: 1px solid var(--rs-border);
background: transparent; color: var(--rs-text-secondary); font-size: 12px;
cursor: pointer; transition: all 0.15s; font-family: inherit;
}
.type-filter-pill:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
.type-filter-pill.active { background: var(--rs-primary); color: #fff; border-color: var(--rs-primary); }
/* ── New Note Split Button ── */
.new-note-split { display: flex; position: relative; }
.new-note-split .rapp-nav__btn:first-child { border-radius: 6px 0 0 6px; }
.new-note-dropdown-btn {
border-radius: 0 6px 6px 0 !important; padding: 6px 8px !important;
border-left: 1px solid rgba(255,255,255,0.2) !important; min-width: 28px;
}
.new-note-dropdown {
position: absolute; top: 100%; right: 0; margin-top: 4px; z-index: 50;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
border-radius: 8px; box-shadow: var(--rs-shadow-md); min-width: 180px;
overflow: hidden;
}
.new-note-dropdown-item {
padding: 8px 14px; cursor: pointer; font-size: 13px;
color: var(--rs-text-primary); transition: background 0.1s;
}
.new-note-dropdown-item:hover { background: var(--rs-bg-hover); }
/* ── Note Item Type Badges ── */
.note-item__badge {
display: inline-block; padding: 1px 6px; border-radius: 3px;
font-size: 10px; font-weight: 500;
}
.badge-url { background: rgba(245, 158, 11, 0.15); color: #d97706; }
.badge-lang { background: rgba(16, 185, 129, 0.15); color: #059669; }
.badge-duration { background: rgba(239, 68, 68, 0.15); color: #dc2626; }
/* ── Code Editor ── */
.code-editor-controls { padding: 4px 12px; display: flex; gap: 8px; align-items: center; }
.code-textarea {
width: 100%; min-height: 400px; padding: 16px 20px; border: none; outline: none;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 13px; line-height: 1.6; tab-size: 4; resize: vertical;
background: var(--rs-bg-surface-sunken); color: var(--rs-text-primary);
}
/* ── Bookmark / Clip View ── */
.bookmark-card {
display: flex; gap: 12px; align-items: center;
padding: 12px 16px; margin: 0 12px 8px;
background: var(--rs-bg-surface-raised); border-radius: 8px;
}
.bookmark-favicon { border-radius: 4px; flex-shrink: 0; }
.bookmark-info { flex: 1; min-width: 0; }
.bookmark-url { color: var(--rs-primary); font-size: 13px; text-decoration: none; }
.bookmark-url:hover { text-decoration: underline; }
.bookmark-url-input-row { margin-top: 4px; }
.bookmark-url-input {
width: 100%; padding: 6px 10px; border-radius: 6px;
border: 1px solid var(--rs-input-border); background: var(--rs-input-bg);
color: var(--rs-input-text); font-size: 12px; font-family: inherit;
}
/* ── Image View ── */
.image-display { padding: 12px 16px; text-align: center; }
.image-preview { max-width: 100%; max-height: 500px; border-radius: 8px; border: 1px solid var(--rs-border-subtle); }
.image-upload-placeholder { padding: 16px; }
/* ── Audio View ── */
.audio-player-container {
display: flex; gap: 12px; align-items: center;
padding: 12px 16px; margin: 0 12px;
}
.audio-player { flex: 1; max-width: 100%; height: 40px; }
.audio-duration { font-size: 13px; color: var(--rs-text-muted); font-weight: 500; }
.audio-record-placeholder { padding: 24px; text-align: center; }
.audio-transcript-section { padding: 0 4px; }
.audio-transcript-label {
font-size: 12px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--rs-text-muted);
padding: 8px 16px 0;
}
/* ── Editor Title ── */
.editable-title {
background: transparent; border: none; border-bottom: 2px solid transparent;
@ -1546,6 +2095,8 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
.toolbar-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
.toolbar-btn:hover { background: var(--rs-toolbar-btn-hover); color: var(--rs-toolbar-btn-text); }
.toolbar-btn.active { background: var(--rs-primary); color: #fff; }
.toolbar-btn.recording { background: var(--rs-error, #ef4444); color: #fff; animation: pulse-recording 1.5s infinite; }
@keyframes pulse-recording { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
.toolbar-select {
padding: 2px 4px; border-radius: 4px; border: 1px solid var(--rs-toolbar-panel-border);
background: var(--rs-toolbar-bg); color: var(--rs-text-secondary); font-size: 12px; cursor: pointer;

View File

@ -0,0 +1,481 @@
/**
* <folk-voice-recorder> Standalone voice recorder web component.
*
* Full-page recorder with MediaRecorder, SpeechDictation (live),
* and three-tier transcription cascade:
* 1. Server (voice-command-api)
* 2. Live (Web Speech API captured during recording)
* 3. Offline (Parakeet TDT 0.6B in-browser)
*
* Saves AUDIO notes to rNotes via REST API.
*/
import { SpeechDictation } from '../../../lib/speech-dictation';
import { transcribeOffline, isModelCached } from '../../../lib/parakeet-offline';
import type { TranscriptionProgress } from '../../../lib/parakeet-offline';
import { getAccessToken } from '../../../shared/components/rstack-identity';
type RecorderState = 'idle' | 'recording' | 'processing' | 'done';
class FolkVoiceRecorder extends HTMLElement {
private shadow!: ShadowRoot;
private space = '';
private state: RecorderState = 'idle';
private mediaRecorder: MediaRecorder | null = null;
private audioChunks: Blob[] = [];
private dictation: SpeechDictation | null = null;
private liveTranscript = '';
private finalTranscript = '';
private recordingStartTime = 0;
private durationTimer: ReturnType<typeof setInterval> | null = null;
private elapsedSeconds = 0;
private audioBlob: Blob | null = null;
private audioUrl: string | null = null;
private progressMessage = '';
private selectedNotebookId = '';
private notebooks: { id: string; title: string }[] = [];
private tags = '';
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.space = this.getAttribute('space') || 'demo';
this.loadNotebooks();
this.render();
}
disconnectedCallback() {
this.cleanup();
}
private cleanup() {
this.stopDurationTimer();
this.dictation?.destroy();
this.dictation = null;
if (this.mediaRecorder?.state === 'recording') {
this.mediaRecorder.stop();
}
this.mediaRecorder = null;
if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rnotes/);
return match ? match[0] : '';
}
private authHeaders(extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = { ...extra };
const token = getAccessToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
}
private async loadNotebooks() {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/notebooks`, { headers: this.authHeaders() });
const data = await res.json();
this.notebooks = (data.notebooks || []).map((nb: any) => ({ id: nb.id, title: nb.title }));
if (this.notebooks.length > 0 && !this.selectedNotebookId) {
this.selectedNotebookId = this.notebooks[0].id;
}
this.render();
} catch { /* fallback: empty list */ }
}
private async startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Determine supported mimeType
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: 'audio/mp4';
this.audioChunks = [];
this.mediaRecorder = new MediaRecorder(stream, { mimeType });
this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) this.audioChunks.push(e.data);
};
this.mediaRecorder.onstop = () => {
stream.getTracks().forEach(t => t.stop());
this.audioBlob = new Blob(this.audioChunks, { type: mimeType });
if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);
this.audioUrl = URL.createObjectURL(this.audioBlob);
this.processRecording();
};
this.mediaRecorder.start(1000); // 1s timeslice
// Start live transcription via Web Speech API
this.liveTranscript = '';
if (SpeechDictation.isSupported()) {
this.dictation = new SpeechDictation({
onFinal: (text) => { this.liveTranscript += text + ' '; this.render(); },
onInterim: () => { this.render(); },
});
this.dictation.start();
}
// Start timer
this.recordingStartTime = Date.now();
this.elapsedSeconds = 0;
this.durationTimer = setInterval(() => {
this.elapsedSeconds = Math.floor((Date.now() - this.recordingStartTime) / 1000);
this.render();
}, 1000);
this.state = 'recording';
this.render();
} catch (err) {
console.error('Failed to start recording:', err);
}
}
private stopRecording() {
this.stopDurationTimer();
this.dictation?.stop();
if (this.mediaRecorder?.state === 'recording') {
this.mediaRecorder.stop();
}
}
private stopDurationTimer() {
if (this.durationTimer) {
clearInterval(this.durationTimer);
this.durationTimer = null;
}
}
private async processRecording() {
this.state = 'processing';
this.progressMessage = 'Processing recording...';
this.render();
// Three-tier transcription cascade
let transcript = '';
// Tier 1: Server transcription
if (this.audioBlob && this.space !== 'demo') {
try {
this.progressMessage = 'Sending to server for transcription...';
this.render();
const base = this.getApiBase();
const formData = new FormData();
formData.append('file', this.audioBlob, 'recording.webm');
const res = await fetch(`${base}/api/voice/transcribe`, {
method: 'POST',
headers: this.authHeaders(),
body: formData,
});
if (res.ok) {
const data = await res.json();
transcript = data.text || data.transcript || '';
}
} catch { /* fall through to next tier */ }
}
// Tier 2: Live transcript from Web Speech API
if (!transcript && this.liveTranscript.trim()) {
transcript = this.liveTranscript.trim();
}
// Tier 3: Offline Parakeet transcription
if (!transcript && this.audioBlob) {
try {
transcript = await transcribeOffline(this.audioBlob, (p: TranscriptionProgress) => {
this.progressMessage = p.message || 'Processing...';
this.render();
});
} catch {
this.progressMessage = 'Transcription failed. You can still save the recording.';
this.render();
}
}
this.finalTranscript = transcript;
this.state = 'done';
this.progressMessage = '';
this.render();
}
private async saveNote() {
if (!this.audioBlob || !this.selectedNotebookId) return;
const base = this.getApiBase();
// Upload audio file
let fileUrl = '';
try {
const formData = new FormData();
formData.append('file', this.audioBlob, 'recording.webm');
const uploadRes = await fetch(`${base}/api/uploads`, {
method: 'POST',
headers: this.authHeaders(),
body: formData,
});
if (uploadRes.ok) {
const uploadData = await uploadRes.json();
fileUrl = uploadData.url;
}
} catch { /* continue without file */ }
// Create the note
const tagList = this.tags.split(',').map(t => t.trim()).filter(Boolean);
tagList.push('voice');
try {
const res = await fetch(`${base}/api/notes`, {
method: 'POST',
headers: this.authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
notebook_id: this.selectedNotebookId,
title: `Voice Note — ${new Date().toLocaleDateString()}`,
content: this.finalTranscript || '',
type: 'AUDIO',
tags: tagList,
file_url: fileUrl,
mime_type: this.audioBlob.type,
duration: this.elapsedSeconds,
}),
});
if (res.ok) {
this.state = 'idle';
this.finalTranscript = '';
this.liveTranscript = '';
this.audioBlob = null;
if (this.audioUrl) { URL.revokeObjectURL(this.audioUrl); this.audioUrl = null; }
this.render();
// Show success briefly
this.progressMessage = 'Note saved!';
this.render();
setTimeout(() => { this.progressMessage = ''; this.render(); }, 2000);
}
} catch (err) {
this.progressMessage = 'Failed to save note';
this.render();
}
}
private discard() {
this.cleanup();
this.state = 'idle';
this.finalTranscript = '';
this.liveTranscript = '';
this.audioBlob = null;
this.audioUrl = null;
this.elapsedSeconds = 0;
this.progressMessage = '';
this.render();
}
private formatTime(s: number): string {
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${String(sec).padStart(2, '0')}`;
}
private render() {
const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
let body = '';
switch (this.state) {
case 'idle':
body = `
<div class="recorder-idle">
<div class="recorder-icon">
<svg width="64" height="64" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="1" width="6" height="9" rx="3"/><path d="M3 7v1a5 5 0 0 0 10 0V7"/>
<line x1="8" y1="13" x2="8" y2="15"/><line x1="5.5" y1="15" x2="10.5" y2="15"/>
</svg>
</div>
<h2>Voice Recorder</h2>
<p class="recorder-subtitle">Record voice notes with automatic transcription</p>
<div class="recorder-config">
<label>Save to notebook:
<select id="notebook-select">
${this.notebooks.map(nb => `<option value="${nb.id}"${nb.id === this.selectedNotebookId ? ' selected' : ''}>${esc(nb.title)}</option>`).join('')}
</select>
</label>
<label>Tags: <input id="tags-input" value="${esc(this.tags)}" placeholder="comma, separated"></label>
</div>
<button class="record-btn" id="btn-start">Start Recording</button>
${isModelCached() ? '<p class="model-status">Offline model cached</p>' : ''}
</div>`;
break;
case 'recording':
body = `
<div class="recorder-recording">
<div class="recording-pulse"></div>
<div class="recording-timer">${this.formatTime(this.elapsedSeconds)}</div>
<p class="recording-status">Recording...</p>
${this.liveTranscript ? `<div class="live-transcript">${esc(this.liveTranscript)}</div>` : ''}
<button class="stop-btn" id="btn-stop">Stop</button>
</div>`;
break;
case 'processing':
body = `
<div class="recorder-processing">
<div class="processing-spinner"></div>
<p>${esc(this.progressMessage)}</p>
</div>`;
break;
case 'done':
body = `
<div class="recorder-done">
<h3>Recording Complete</h3>
${this.audioUrl ? `<audio controls src="${this.audioUrl}" class="result-audio"></audio>` : ''}
<div class="result-duration">Duration: ${this.formatTime(this.elapsedSeconds)}</div>
<div class="transcript-section">
<label>Transcript:</label>
<textarea id="transcript-edit" class="transcript-textarea">${esc(this.finalTranscript)}</textarea>
</div>
<div class="result-actions">
<button class="save-btn" id="btn-save">Save Note</button>
<button class="copy-btn" id="btn-copy">Copy Transcript</button>
<button class="discard-btn" id="btn-discard">Discard</button>
</div>
</div>`;
break;
}
this.shadow.innerHTML = `
<style>${this.getStyles()}</style>
<div class="voice-recorder">${body}</div>
${this.progressMessage && this.state === 'idle' ? `<div class="toast">${esc(this.progressMessage)}</div>` : ''}
`;
this.attachListeners();
}
private attachListeners() {
this.shadow.getElementById('btn-start')?.addEventListener('click', () => this.startRecording());
this.shadow.getElementById('btn-stop')?.addEventListener('click', () => this.stopRecording());
this.shadow.getElementById('btn-save')?.addEventListener('click', () => this.saveNote());
this.shadow.getElementById('btn-discard')?.addEventListener('click', () => this.discard());
this.shadow.getElementById('btn-copy')?.addEventListener('click', () => {
const textarea = this.shadow.getElementById('transcript-edit') as HTMLTextAreaElement;
if (textarea) navigator.clipboard.writeText(textarea.value);
});
const nbSelect = this.shadow.getElementById('notebook-select') as HTMLSelectElement;
if (nbSelect) nbSelect.addEventListener('change', () => { this.selectedNotebookId = nbSelect.value; });
const tagsInput = this.shadow.getElementById('tags-input') as HTMLInputElement;
if (tagsInput) tagsInput.addEventListener('input', () => { this.tags = tagsInput.value; });
const transcriptEdit = this.shadow.getElementById('transcript-edit') as HTMLTextAreaElement;
if (transcriptEdit) transcriptEdit.addEventListener('input', () => { this.finalTranscript = transcriptEdit.value; });
}
private getStyles(): string {
return `
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
* { box-sizing: border-box; }
.voice-recorder {
max-width: 600px; margin: 0 auto; padding: 40px 20px;
display: flex; flex-direction: column; align-items: center; text-align: center;
}
h2 { font-size: 24px; font-weight: 700; margin: 16px 0 4px; }
h3 { font-size: 18px; font-weight: 600; margin: 0 0 16px; }
.recorder-subtitle { color: var(--rs-text-muted); margin: 0 0 24px; }
.recorder-icon { color: var(--rs-primary); margin-bottom: 8px; }
.recorder-config {
display: flex; flex-direction: column; gap: 12px; width: 100%;
max-width: 400px; margin-bottom: 24px; text-align: left;
}
.recorder-config label { font-size: 13px; color: var(--rs-text-secondary); display: flex; flex-direction: column; gap: 4px; }
.recorder-config select, .recorder-config input {
padding: 8px 12px; border-radius: 6px; border: 1px solid var(--rs-input-border);
background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 14px; font-family: inherit;
}
.record-btn {
padding: 14px 36px; border-radius: 50px; border: none;
background: var(--rs-error, #ef4444); color: #fff; font-size: 16px; font-weight: 600;
cursor: pointer; transition: all 0.2s;
}
.record-btn:hover { transform: scale(1.05); filter: brightness(1.1); }
.model-status { font-size: 11px; color: var(--rs-text-muted); margin-top: 12px; }
/* Recording state */
.recorder-recording { display: flex; flex-direction: column; align-items: center; gap: 16px; }
.recording-pulse {
width: 80px; height: 80px; border-radius: 50%;
background: var(--rs-error, #ef4444); animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
70% { transform: scale(1.05); opacity: 0.8; box-shadow: 0 0 0 20px rgba(239, 68, 68, 0); }
100% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
}
.recording-timer { font-size: 48px; font-weight: 700; font-variant-numeric: tabular-nums; }
.recording-status { color: var(--rs-error, #ef4444); font-weight: 500; }
.live-transcript {
max-width: 500px; padding: 12px 16px; border-radius: 8px;
background: var(--rs-bg-surface-raised); font-size: 14px; line-height: 1.6;
text-align: left; max-height: 200px; overflow-y: auto; color: var(--rs-text-secondary);
}
.stop-btn {
padding: 12px 32px; border-radius: 50px; border: none;
background: var(--rs-text-primary); color: var(--rs-bg-surface); font-size: 15px; font-weight: 600;
cursor: pointer;
}
/* Processing */
.recorder-processing { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 40px; }
.processing-spinner {
width: 48px; height: 48px; border: 3px solid var(--rs-border);
border-top-color: var(--rs-primary); border-radius: 50%; animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Done */
.recorder-done { display: flex; flex-direction: column; align-items: center; gap: 12px; width: 100%; }
.result-audio { width: 100%; max-width: 500px; height: 40px; margin-bottom: 8px; }
.result-duration { font-size: 13px; color: var(--rs-text-muted); }
.transcript-section { width: 100%; max-width: 500px; text-align: left; }
.transcript-section label { font-size: 12px; font-weight: 600; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.transcript-textarea {
width: 100%; min-height: 120px; padding: 12px; margin-top: 4px;
border-radius: 8px; border: 1px solid var(--rs-input-border);
background: var(--rs-input-bg); color: var(--rs-input-text);
font-size: 14px; font-family: inherit; line-height: 1.6; resize: vertical;
}
.result-actions { display: flex; gap: 8px; margin-top: 8px; }
.save-btn {
padding: 10px 24px; border-radius: 8px; border: none;
background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer;
}
.copy-btn, .discard-btn {
padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer;
border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary);
}
.discard-btn { color: var(--rs-error, #ef4444); border-color: var(--rs-error, #ef4444); }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
padding: 10px 20px; border-radius: 8px; background: var(--rs-primary); color: #fff;
font-size: 13px; font-weight: 500; z-index: 100;
}
`;
}
}
customElements.define('folk-voice-recorder', FolkVoiceRecorder);

View File

@ -97,11 +97,28 @@ export const SLASH_ITEMS: SlashMenuItem[] = [
icon: 'image',
description: 'Insert an image from URL',
command: (e) => {
// Dispatch custom event for parent to show URL popover
const event = new CustomEvent('slash-insert-image', { bubbles: true, composed: true });
(e.view.dom as HTMLElement).dispatchEvent(event);
},
},
{
title: 'Code Snippet',
icon: 'codeBlock',
description: 'Create a new code snippet note',
command: (e) => {
const event = new CustomEvent('slash-create-typed-note', { bubbles: true, composed: true, detail: { type: 'CODE' } });
(e.view.dom as HTMLElement).dispatchEvent(event);
},
},
{
title: 'Voice Note',
icon: 'text',
description: 'Create a new voice recording note',
command: (e) => {
const event = new CustomEvent('slash-create-typed-note', { bubbles: true, composed: true, detail: { type: 'AUDIO' } });
(e.view.dom as HTMLElement).dispatchEvent(event);
},
},
];
const pluginKey = new PluginKey('slashCommand');

View File

@ -20,6 +20,7 @@ import type { NotebookDoc, NoteItem, ConnectionsDoc } from "./schemas";
import { getConverter, getAllConverters } from "./converters/index";
import type { ConvertedNote } from "./converters/index";
import type { SyncServer } from "../../server/local-first/sync-server";
import { unlockArticle } from "../../lib/article-unlock";
const routes = new Hono();
@ -1002,7 +1003,134 @@ routes.get("/api/connections", async (c) => {
});
});
// ── Page route ──
// ── File uploads ──
import { join } from "path";
import { existsSync, mkdirSync } from "fs";
const UPLOAD_DIR = "/data/files/generated";
// POST /api/uploads — Upload a file (audio, image, etc.)
routes.post("/api/uploads", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const formData = await c.req.formData();
const file = formData.get("file") as File | null;
if (!file) return c.json({ error: "No file provided" }, 400);
// Ensure upload dir exists
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
const ext = file.name.split('.').pop() || 'bin';
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const filepath = join(UPLOAD_DIR, filename);
const arrayBuffer = await file.arrayBuffer();
await Bun.write(filepath, arrayBuffer);
return c.json({
url: `/data/files/generated/${filename}`,
mimeType: file.type || 'application/octet-stream',
size: file.size,
filename,
}, 201);
});
// GET /api/uploads/:filename — Serve uploaded file
routes.get("/api/uploads/:filename", async (c) => {
const filename = c.req.param("filename");
// Path traversal protection
if (filename.includes('..') || filename.includes('/')) {
return c.json({ error: "Invalid filename" }, 400);
}
const filepath = join(UPLOAD_DIR, filename);
const file = Bun.file(filepath);
if (!(await file.exists())) return c.json({ error: "File not found" }, 404);
return new Response(file.stream(), {
headers: { "Content-Type": file.type || "application/octet-stream" },
});
});
// ── Voice transcription proxy ──
// POST /api/voice/transcribe — Proxy to voice-command-api
routes.post("/api/voice/transcribe", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
try {
const formData = await c.req.formData();
const upstream = await fetch("http://voice-command-api:8000/api/voice/transcribe", {
method: "POST",
body: formData,
signal: AbortSignal.timeout(60000),
});
if (!upstream.ok) return c.json({ error: "Transcription service error" }, 502);
const result = await upstream.json();
return c.json(result);
} catch (err) {
return c.json({ error: "Voice service unavailable" }, 502);
}
});
// POST /api/voice/diarize — Proxy to voice-command-api (speaker diarization)
routes.post("/api/voice/diarize", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
try {
const formData = await c.req.formData();
const upstream = await fetch("http://voice-command-api:8000/api/voice/diarize", {
method: "POST",
body: formData,
signal: AbortSignal.timeout(120000),
});
if (!upstream.ok) return c.json({ error: "Diarization service error" }, 502);
const result = await upstream.json();
return c.json(result);
} catch (err) {
return c.json({ error: "Voice service unavailable" }, 502);
}
});
// POST /api/articles/unlock — Find archived version of a paywalled article
routes.post("/api/articles/unlock", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Unauthorized" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const { url } = await c.req.json<{ url: string }>();
if (!url) return c.json({ error: "Missing url" }, 400);
try {
const result = await unlockArticle(url);
return c.json(result);
} catch (err) {
return c.json({ success: false, error: "Unlock failed" }, 500);
}
});
// ── Page routes ──
// GET /voice — Standalone voice recorder page
routes.get("/voice", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Voice Recorder | rSpace`,
moduleId: "rnotes",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-voice-recorder space="${space}"></folk-voice-recorder>`,
scripts: `<script type="module" src="/modules/rnotes/folk-voice-recorder.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=3">`,
}));
});
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
@ -1026,6 +1154,15 @@ export const notesModule: RSpaceModule = {
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
settingsSchema: [
{
key: 'defaultNotebookId',
label: 'Default notebook for imports',
type: 'notebook-id',
description: 'Pre-selected notebook when importing from Logseq, Obsidian, or the web clipper',
},
],
docSchemas: [
{
pattern: '{space}:notes:notebooks:{notebookId}',

View File

@ -151,6 +151,7 @@ export interface CommunityMeta {
connectionPolicy?: ConnectionPolicy;
encrypted?: boolean;
encryptionKeyId?: string;
moduleSettings?: Record<string, Record<string, string | boolean>>;
}
export interface ShapeData {
@ -490,6 +491,7 @@ export function updateSpaceMeta(
description?: string;
enabledModules?: string[];
moduleScopeOverrides?: Record<string, 'space' | 'global'>;
moduleSettings?: Record<string, Record<string, string | boolean>>;
},
): boolean {
const doc = communities.get(slug);
@ -501,6 +503,7 @@ export function updateSpaceMeta(
if (fields.description !== undefined) d.meta.description = fields.description;
if (fields.enabledModules !== undefined) d.meta.enabledModules = fields.enabledModules;
if (fields.moduleScopeOverrides !== undefined) d.meta.moduleScopeOverrides = fields.moduleScopeOverrides;
if (fields.moduleSettings !== undefined) d.meta.moduleSettings = fields.moduleSettings;
});
communities.set(slug, newDoc);
saveCommunity(slug);

View File

@ -347,6 +347,8 @@ spaces.get("/:slug/modules", async (c) => {
const enabled = doc.meta.enabledModules; // null = all
const overrides = doc.meta.moduleScopeOverrides || {};
const savedSettings = doc.meta.moduleSettings || {};
const modules = allModules.map(mod => ({
id: mod.id,
name: mod.name,
@ -357,6 +359,8 @@ spaces.get("/:slug/modules", async (c) => {
userConfigurable: mod.scoping.userConfigurable,
currentScope: overrides[mod.id] || mod.scoping.defaultScope,
},
...(mod.settingsSchema ? { settingsSchema: mod.settingsSchema } : {}),
...(savedSettings[mod.id] ? { settings: savedSettings[mod.id] } : {}),
}));
return c.json({ modules, enabledModules: enabled });
@ -381,6 +385,7 @@ spaces.patch("/:slug/modules", async (c) => {
const body = await c.req.json<{
enabledModules?: string[] | null;
scopeOverrides?: Record<string, 'space' | 'global'>;
moduleSettings?: Record<string, Record<string, string | boolean>>;
}>();
const updates: Partial<typeof doc.meta> = {};
@ -417,6 +422,23 @@ spaces.patch("/:slug/modules", async (c) => {
updates.moduleScopeOverrides = newOverrides;
}
// Validate and merge module settings
if (body.moduleSettings) {
const existing = doc.meta.moduleSettings || {};
const merged = { ...existing };
for (const [modId, settings] of Object.entries(body.moduleSettings)) {
const mod = getModule(modId);
if (!mod) return c.json({ error: `Unknown module: ${modId}` }, 400);
if (!mod.settingsSchema) return c.json({ error: `Module ${modId} has no settings schema` }, 400);
const validKeys = new Set(mod.settingsSchema.map(f => f.key));
for (const key of Object.keys(settings)) {
if (!validKeys.has(key)) return c.json({ error: `Unknown setting '${key}' for module ${modId}` }, 400);
}
merged[modId] = { ...(existing[modId] || {}), ...settings };
}
updates.moduleSettings = merged;
}
if (Object.keys(updates).length > 0) {
updateSpaceMeta(slug, updates);
}

View File

@ -844,9 +844,15 @@ export class RStackSpaceSwitcher extends HTMLElement {
const modData = await modRes.json();
const encData = encRes.ok ? await encRes.json() : { encrypted: false };
interface SettingField {
key: string; label: string; type: string; description?: string;
default?: string | boolean; options?: Array<{ value: string; label: string }>;
}
interface ModConfig {
id: string; name: string; icon: string; enabled: boolean;
scoping: { defaultScope: string; userConfigurable: boolean; currentScope: string };
settingsSchema?: SettingField[];
settings?: Record<string, string | boolean>;
}
const modules: ModConfig[] = modData.modules || [];
@ -868,13 +874,17 @@ export class RStackSpaceSwitcher extends HTMLElement {
for (const m of modules) {
if (m.id === "rspace") continue; // core module, always enabled
const hasSettings = m.settingsSchema && m.settingsSchema.length > 0;
html += `
<label class="module-row">
<span class="module-label">${m.icon} ${m.name}</span>
<span style="display:flex;align-items:center;gap:6px;">
${hasSettings ? `<button class="mod-settings-btn" data-mod-id="${m.id}" title="Settings" style="background:none;border:none;color:#737373;cursor:pointer;font-size:14px;padding:2px;">&#9881;</button>` : ''}
<label class="toggle-switch">
<input type="checkbox" class="mod-toggle" data-mod-id="${m.id}" ${m.enabled ? "checked" : ""} />
<span class="toggle-slider"></span>
</label>
</span>
</label>`;
if (m.scoping.userConfigurable) {
@ -887,6 +897,35 @@ export class RStackSpaceSwitcher extends HTMLElement {
</select>
</div>`;
}
if (hasSettings) {
html += `<div class="mod-settings-panel" data-mod-id="${m.id}" style="display:none;padding:8px 12px;background:#111;border:1px solid #333;border-radius:6px;margin:4px 0 8px;">`;
for (const field of m.settingsSchema!) {
const val = m.settings?.[field.key] ?? field.default ?? '';
html += `<div style="margin-bottom:8px;">`;
html += `<label style="display:block;font-size:11px;color:#a3a3a3;margin-bottom:3px;">${field.label}</label>`;
if (field.type === 'boolean') {
html += `<label class="toggle-switch"><input type="checkbox" class="mod-setting" data-mod-id="${m.id}" data-key="${field.key}" ${val ? 'checked' : ''} /><span class="toggle-slider"></span></label>`;
} else if (field.type === 'select') {
html += `<select class="mod-setting scope-select" data-mod-id="${m.id}" data-key="${field.key}">`;
for (const opt of field.options || []) {
html += `<option value="${opt.value}" ${val === opt.value ? 'selected' : ''}>${opt.label}</option>`;
}
html += `</select>`;
} else if (field.type === 'notebook-id') {
html += `<select class="mod-setting scope-select mod-notebook-select" data-mod-id="${m.id}" data-key="${field.key}" data-space="${slug}"><option value="">None</option></select>`;
} else {
html += `<input type="text" class="mod-setting" data-mod-id="${m.id}" data-key="${field.key}" value="${typeof val === 'string' ? val.replace(/"/g, '&quot;') : ''}" style="width:100%;padding:5px 8px;background:#0a0a0a;border:1px solid #404040;border-radius:4px;color:#e5e5e5;font-size:12px;" />`;
}
if (field.description) {
html += `<div style="font-size:10px;color:#525252;margin-top:2px;">${field.description}</div>`;
}
html += `</div>`;
}
html += `</div>`;
}
}
html += `
@ -897,6 +936,43 @@ export class RStackSpaceSwitcher extends HTMLElement {
container.innerHTML = html;
// Gear button toggles settings panel
container.querySelectorAll(".mod-settings-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const modId = (btn as HTMLElement).dataset.modId!;
const panel = container.querySelector(`.mod-settings-panel[data-mod-id="${modId}"]`) as HTMLElement;
if (panel) panel.style.display = panel.style.display === "none" ? "block" : "none";
});
});
// Populate notebook-id selects
container.querySelectorAll(".mod-notebook-select").forEach(async (sel) => {
const select = sel as HTMLSelectElement;
const space = select.dataset.space!;
const modId = select.dataset.modId!;
const key = select.dataset.key!;
const savedVal = modules.find(m => m.id === modId)?.settings?.[key] || '';
try {
const token = getAccessToken();
const nbRes = await fetch(`/${space}/rnotes/api/notebooks`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (nbRes.ok) {
const nbData = await nbRes.json();
const notebooks = nbData.notebooks || (Array.isArray(nbData) ? nbData : []);
for (const nb of notebooks) {
const opt = document.createElement("option");
opt.value = nb.id;
opt.textContent = nb.title;
select.appendChild(opt);
}
if (savedVal) select.value = savedVal as string;
}
} catch {}
});
// Save handler
container.querySelector("#es-modules-save")?.addEventListener("click", async () => {
statusEl.textContent = "Saving...";
@ -924,11 +1000,28 @@ export class RStackSpaceSwitcher extends HTMLElement {
const token = getAccessToken();
const authHeaders = { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) };
// Collect module settings
const moduleSettings: Record<string, Record<string, string | boolean>> = {};
container.querySelectorAll(".mod-setting").forEach((el) => {
const input = el as HTMLInputElement | HTMLSelectElement;
const modId = input.dataset.modId!;
const key = input.dataset.key!;
if (!moduleSettings[modId]) moduleSettings[modId] = {};
if (input.type === "checkbox") {
moduleSettings[modId][key] = (input as HTMLInputElement).checked;
} else {
moduleSettings[modId][key] = input.value;
}
});
// Save modules config
const patchBody: Record<string, unknown> = { enabledModules, scopeOverrides };
if (Object.keys(moduleSettings).length > 0) patchBody.moduleSettings = moduleSettings;
const modSaveRes = await fetch(`/api/spaces/${slug}/modules`, {
method: "PATCH",
headers: authHeaders,
body: JSON.stringify({ enabledModules, scopeOverrides }),
body: JSON.stringify(patchBody),
});
if (!modSaveRes.ok) {
const d = await modSaveRes.json();

View File

@ -52,6 +52,25 @@ export interface SubPageInfo {
bodyHTML?: () => string;
}
// ── Per-Module Settings ──
export type ModuleSettingType = 'string' | 'boolean' | 'select' | 'notebook-id';
export interface ModuleSettingField {
/** Storage key */
key: string;
/** Display label */
label: string;
/** Field type */
type: ModuleSettingType;
/** Help text */
description?: string;
/** Default value */
default?: string | boolean;
/** Options for 'select' type */
options?: Array<{ value: string; label: string }>;
}
/** A browsable content type that a module produces. */
export interface OutputPath {
/** URL segment: "notebooks" */
@ -132,6 +151,9 @@ export interface RSpaceModule {
/** If true, write operations (POST/PUT/PATCH/DELETE) skip the space role check.
* Use for modules whose API endpoints are publicly accessible (e.g. thread builder). */
publicWrite?: boolean;
/** Per-module settings schema for space-level configuration */
settingsSchema?: ModuleSettingField[];
}
/** Registry of all loaded modules */
@ -167,6 +189,7 @@ export interface ModuleInfo {
};
outputPaths?: OutputPath[];
subPageInfos?: Array<{ path: string; title: string }>;
settingsSchema?: ModuleSettingField[];
}
export function getModuleInfoList(): ModuleInfo[] {
@ -185,5 +208,6 @@ export function getModuleInfoList(): ModuleInfo[] {
...(m.externalApp ? { externalApp: m.externalApp } : {}),
...(m.outputPaths ? { outputPaths: m.outputPaths } : {}),
...(m.subPageInfos ? { subPageInfos: m.subPageInfos.map(s => ({ path: s.path, title: s.title })) } : {}),
...(m.settingsSchema ? { settingsSchema: m.settingsSchema } : {}),
}));
}