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:
parent
63b4294c9c
commit
c19142791e
|
|
@ -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 |
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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">×</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 · <kbd>Esc</kbd> to close · Offline ready
|
||||
</div>
|
||||
|
||||
<script src="parakeet-offline.js" type="module"></script>
|
||||
<script src="voice.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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" };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;">⚙</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, '"') : ''}" 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();
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue