diff --git a/browser-extension/background.js b/browser-extension/background.js new file mode 100644 index 0000000..f2b4d43 --- /dev/null +++ b/browser-extension/background.js @@ -0,0 +1,175 @@ +const DEFAULT_HOST = 'https://rspace.online'; + +// --- Context Menu Setup --- + +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.create({ id: 'clip-page', title: 'Clip page to rSpace', contexts: ['page'] }); + chrome.contextMenus.create({ id: 'save-link', title: 'Save link to rSpace', contexts: ['link'] }); + chrome.contextMenus.create({ id: 'save-image', title: 'Save image to rSpace', contexts: ['image'] }); + chrome.contextMenus.create({ id: 'clip-selection', title: 'Clip selection to rSpace', contexts: ['selection'] }); + chrome.contextMenus.create({ id: 'unlock-article', title: 'Unlock & Clip article to rSpace', contexts: ['page', 'link'] }); +}); + +// --- Helpers --- + +async function getSettings() { + const result = await chrome.storage.sync.get(['rspaceHost', 'rspaceSlug']); + return { + host: result.rspaceHost || DEFAULT_HOST, + slug: result.rspaceSlug || '', + }; +} + +function apiBase(settings) { + return settings.slug ? `${settings.host}/${settings.slug}/rnotes` : `${settings.host}/rnotes`; +} + +async function getToken() { + const result = await chrome.storage.local.get(['encryptid_token']); + return result.encryptid_token || null; +} + +async function getDefaultNotebook() { + const result = await chrome.storage.local.get(['lastNotebookId']); + return result.lastNotebookId || null; +} + +function showNotification(title, message) { + chrome.notifications.create({ + type: 'basic', iconUrl: 'icons/icon-128.png', title, message, + }); +} + +async function createNote(data) { + const token = await getToken(); + if (!token) { showNotification('rSpace Error', 'Not signed in. Open extension settings.'); return; } + + const settings = await getSettings(); + if (!settings.slug) { showNotification('rSpace Error', 'Configure space slug in extension settings.'); return; } + + const notebookId = await getDefaultNotebook(); + const body = { title: data.title, content: data.content, type: data.type || 'CLIP', url: data.url }; + if (notebookId) body.notebook_id = notebookId; + if (data.fileUrl) body.file_url = data.fileUrl; + if (data.mimeType) body.mime_type = data.mimeType; + if (data.fileSize) body.file_size = data.fileSize; + + const response = await fetch(`${apiBase(settings)}/api/notes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify(body), + }); + if (!response.ok) { const text = await response.text(); throw new Error(`${response.status}: ${text}`); } + return response.json(); +} + +async function uploadImage(imageUrl) { + const token = await getToken(); + const settings = await getSettings(); + + const imgResponse = await fetch(imageUrl); + const blob = await imgResponse.blob(); + let filename; + try { filename = new URL(imageUrl).pathname.split('/').pop() || `image-${Date.now()}.jpg`; } + catch { filename = `image-${Date.now()}.jpg`; } + + const formData = new FormData(); + formData.append('file', blob, filename); + + const response = await fetch(`${apiBase(settings)}/api/uploads`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData, + }); + if (!response.ok) { const text = await response.text(); throw new Error(`Upload failed: ${response.status} ${text}`); } + return response.json(); +} + +async function unlockArticle(url) { + const token = await getToken(); + if (!token) { showNotification('rSpace Error', 'Not signed in.'); return null; } + + const settings = await getSettings(); + const response = await fetch(`${apiBase(settings)}/api/articles/unlock`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ url }), + }); + if (!response.ok) { const text = await response.text(); throw new Error(`Unlock failed: ${response.status} ${text}`); } + return response.json(); +} + +// --- Context Menu Handler --- + +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + try { + switch (info.menuItemId) { + case 'clip-page': { + let content = ''; + try { + const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => document.body.innerHTML }); + content = result?.result || ''; + } catch { content = `
Clipped from ${tab.url}
`; } + await createNote({ title: tab.title || 'Untitled Clip', content, type: 'CLIP', url: tab.url }); + showNotification('Page Clipped', `"${tab.title}" saved to rSpace`); + break; + } + case 'save-link': { + const linkUrl = info.linkUrl; + const linkText = info.selectionText || linkUrl; + await createNote({ title: linkText, content: `Found on: ${tab.title}
`, type: 'BOOKMARK', url: linkUrl }); + showNotification('Link Saved', 'Bookmark saved to rSpace'); + break; + } + case 'save-image': { + const upload = await uploadImage(info.srcUrl); + await createNote({ title: `Image from ${tab.title || 'page'}`, content: `Source: ${tab.title}
`, type: 'IMAGE', url: tab.url, fileUrl: upload.url, mimeType: upload.mimeType, fileSize: upload.size }); + showNotification('Image Saved', 'Image saved to rSpace'); + break; + } + case 'unlock-article': { + const targetUrl = info.linkUrl || tab.url; + showNotification('Unlocking Article', `Finding readable version of ${new URL(targetUrl).hostname}...`); + const result = await unlockArticle(targetUrl); + if (result?.success && result.archiveUrl) { + await createNote({ title: tab.title || 'Unlocked Article', content: `Unlocked via ${result.strategy}
Original: ${targetUrl}
Archive: ${result.archiveUrl}
`, type: 'CLIP', url: targetUrl }); + showNotification('Article Unlocked', `Readable version found via ${result.strategy}`); + chrome.tabs.create({ url: result.archiveUrl }); + } else { + showNotification('Unlock Failed', result?.error || 'No archived version found'); + } + break; + } + case 'clip-selection': { + let content = ''; + try { + const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => { const s = window.getSelection(); if (!s || s.rangeCount === 0) return ''; const r = s.getRangeAt(0); const d = document.createElement('div'); d.appendChild(r.cloneContents()); return d.innerHTML; } }); + content = result?.result || ''; + } catch { content = `${info.selectionText || ''}
`; } + if (!content && info.selectionText) content = `${info.selectionText}
`; + await createNote({ title: `Selection from ${tab.title || 'page'}`, content, type: 'CLIP', url: tab.url }); + showNotification('Selection Clipped', 'Saved to rSpace'); + break; + } + } + } catch (err) { + console.error('Context menu action failed:', err); + showNotification('rSpace Error', err.message || 'Failed to save'); + } +}); + +// --- Keyboard shortcut handler --- + +chrome.commands.onCommand.addListener(async (command) => { + if (command === 'open-voice-recorder') { + const settings = await getSettings(); + const voiceUrl = settings.slug ? `${settings.host}/${settings.slug}/rnotes/voice` : `${settings.host}/rnotes/voice`; + chrome.windows.create({ url: voiceUrl, type: 'popup', width: 400, height: 600, focused: true }); + } +}); + +// --- Message Handler --- + +chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'notify') showNotification(message.title, message.message); +}); diff --git a/browser-extension/icons/icon-128.png b/browser-extension/icons/icon-128.png new file mode 100644 index 0000000..1e296f9 Binary files /dev/null and b/browser-extension/icons/icon-128.png differ diff --git a/browser-extension/icons/icon-16.png b/browser-extension/icons/icon-16.png new file mode 100644 index 0000000..62b0620 Binary files /dev/null and b/browser-extension/icons/icon-16.png differ diff --git a/browser-extension/icons/icon-48.png b/browser-extension/icons/icon-48.png new file mode 100644 index 0000000..3851e82 Binary files /dev/null and b/browser-extension/icons/icon-48.png differ diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json new file mode 100644 index 0000000..808b7c9 --- /dev/null +++ b/browser-extension/manifest.json @@ -0,0 +1,50 @@ +{ + "manifest_version": 3, + "name": "rSpace Web Clipper", + "version": "1.0.0", + "description": "Clip pages, text, links, and images to rSpace. Record voice notes with transcription.", + "permissions": [ + "activeTab", + "contextMenus", + "storage", + "notifications", + "offscreen" + ], + "host_permissions": [ + "https://rspace.online/*", + "https://auth.encryptid.io/*", + "*://*/*" + ], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "icons": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "background": { + "service_worker": "background.js" + }, + "options_ui": { + "page": "options.html", + "open_in_tab": false + }, + "content_security_policy": { + "extension_pages": "script-src 'self' https://esm.sh; object-src 'self'" + }, + "commands": { + "open-voice-recorder": { + "suggested_key": { + "default": "Ctrl+Shift+V", + "mac": "Command+Shift+V" + }, + "description": "Open rVoice recorder" + } + } +} diff --git a/browser-extension/options.html b/browser-extension/options.html new file mode 100644 index 0000000..3c409e9 --- /dev/null +++ b/browser-extension/options.html @@ -0,0 +1,91 @@ + + + + + + + +Clipped from ${currentTab.url}
`; + } + + await createNote({ + title: currentTab.title || 'Untitled Clip', + content: pageContent, + type: 'CLIP', + url: currentTab.url, + }); + + showStatus('Clipped! Note saved.', 'success'); + chrome.runtime.sendMessage({ type: 'notify', title: 'Page Clipped', message: `"${currentTab.title}" saved to rSpace` }); + } catch (err) { + showStatus(`Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; + } +}); + +document.getElementById('clipSelectionBtn').addEventListener('click', async () => { + const btn = document.getElementById('clipSelectionBtn'); + btn.disabled = true; + showStatus('Clipping selection...', 'loading'); + + try { + const content = selectedHtml || `${selectedText}
`; + await createNote({ + title: `Selection from ${currentTab.title || 'page'}`, + content, + type: 'CLIP', + url: currentTab.url, + }); + + showStatus('Selection clipped!', 'success'); + chrome.runtime.sendMessage({ type: 'notify', title: 'Selection Clipped', message: 'Saved to rSpace' }); + } catch (err) { + showStatus(`Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; + } +}); + +document.getElementById('unlockBtn').addEventListener('click', async () => { + const btn = document.getElementById('unlockBtn'); + btn.disabled = true; + showStatus('Unlocking article...', 'loading'); + + try { + const token = await getToken(); + const settings = await getSettings(); + const response = await fetch(`${apiBase(settings)}/api/articles/unlock`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ url: currentTab.url }), + }); + const result = await response.json(); + + if (result.success && result.archiveUrl) { + await createNote({ + title: currentTab.title || 'Unlocked Article', + content: `Unlocked via ${result.strategy}
Original: ${currentTab.url}
Archive: ${result.archiveUrl}
`, + type: 'CLIP', + url: currentTab.url, + }); + showStatus(`Unlocked via ${result.strategy}! Opening...`, 'success'); + chrome.tabs.create({ url: result.archiveUrl }); + } else { + showStatus(result.error || 'No archived version found', 'error'); + } + } catch (err) { + showStatus(`Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; + } +}); + +document.getElementById('voiceBtn').addEventListener('click', async () => { + const settings = await getSettings(); + const voiceUrl = settings.slug + ? `${settings.host}/${settings.slug}/rnotes/voice` + : `${settings.host}/rnotes/voice`; + chrome.windows.create({ url: voiceUrl, type: 'popup', width: 400, height: 600, focused: true }); + window.close(); +}); + +document.getElementById('optionsLink').addEventListener('click', (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); +}); + +document.getElementById('openSettings')?.addEventListener('click', (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); +}); + +document.addEventListener('DOMContentLoaded', init); diff --git a/browser-extension/voice.html b/browser-extension/voice.html new file mode 100644 index 0000000..338da7a --- /dev/null +++ b/browser-extension/voice.html @@ -0,0 +1,414 @@ + + + + + + + +${finalTranscript.replace(/\n/g, '
')}
` + : 'Voice recording (no transcript)
', + type: 'AUDIO', + mimeType: uploadedMimeType || 'audio/webm', + fileUrl: uploadedFileUrl, + fileSize: uploadedFileSize, + duration: duration, + tags: ['voice'], + }; + + const notebookId = notebookSelect.value; + if (notebookId) body.notebook_id = notebookId; + + try { + const res = await fetch(`${apiBase(settings)}/api/notes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`${res.status}: ${text}`); + } + + showStatusBar('Saved to rSpace!', 'success'); + + chrome.runtime.sendMessage({ + type: 'notify', + title: 'Voice Note Saved', + message: `${formatTime(duration)} recording saved to rSpace`, + }); + + setTimeout(resetState, 1500); + } catch (err) { + showStatusBar(`Save failed: ${err.message}`, 'error'); + } finally { + saveBtn.disabled = false; + } +} + +// --- Copy to clipboard --- + +async function copyTranscript() { + const text = transcriptText.textContent.trim(); + if (!text || transcriptText.querySelector('.placeholder')) { + showStatusBar('No transcript to copy', 'error'); + return; + } + try { + await navigator.clipboard.writeText(text); + showStatusBar('Copied to clipboard', 'success'); + } catch { + showStatusBar('Copy failed', 'error'); + } +} + +// --- Discard --- + +function resetState() { + state = 'idle'; + mediaRecorder = null; + audioChunks = []; + audioBlob = null; + transcript = ''; + liveTranscript = ''; + uploadedFileUrl = ''; + uploadedMimeType = ''; + uploadedFileSize = 0; + duration = 0; + + stopLiveTranscription(); + + if (audioUrl) { + URL.revokeObjectURL(audioUrl); + audioUrl = null; + } + + timerEl.textContent = '00:00'; + timerEl.classList.remove('recording'); + recBtn.classList.remove('recording'); + setStatusLabel('Ready', 'idle'); + postActions.style.display = 'none'; + audioPreview.classList.remove('visible'); + transcriptArea.classList.remove('visible'); + hideParakeetProgress(); + statusBar.className = 'status-bar'; +} + +// --- Keyboard shortcuts --- + +document.addEventListener('keydown', (e) => { + if (e.code === 'Space' && document.activeElement !== transcriptText) { + e.preventDefault(); + toggleRecording(); + } + if (e.code === 'Escape') { + window.close(); + } + if ((e.ctrlKey || e.metaKey) && e.code === 'Enter' && state === 'done') { + e.preventDefault(); + saveToRSpace(); + } +}); + +transcriptText.addEventListener('focus', () => { + const ph = transcriptText.querySelector('.placeholder'); + if (ph) transcriptText.textContent = ''; +}); + +// --- Event listeners --- + +recBtn.addEventListener('click', toggleRecording); +saveBtn.addEventListener('click', saveToRSpace); +discardBtn.addEventListener('click', resetState); +copyBtn.addEventListener('click', copyTranscript); +closeBtn.addEventListener('click', () => window.close()); + +// --- Init --- + +async function init() { + const token = await getToken(); + const claims = token ? decodeToken(token) : null; + + if (!claims) { + authWarning.style.display = 'block'; + recBtn.style.opacity = '0.3'; + recBtn.style.pointerEvents = 'none'; + return; + } + + authWarning.style.display = 'none'; + await loadNotebooks(); +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/lib/article-unlock.ts b/lib/article-unlock.ts new file mode 100644 index 0000000..f4d648a --- /dev/null +++ b/lib/article-unlock.ts @@ -0,0 +1,113 @@ +/** + * Article unlock strategies — find readable/archived versions of paywalled articles. + * Three strategies tried in sequence: Wayback Machine, Google Cache, archive.ph. + */ + +interface UnlockResult { + success: boolean; + strategy?: string; + archiveUrl?: string; + error?: string; +} + +/** Try the Wayback Machine (web.archive.org). */ +async function tryWaybackMachine(url: string): PromiseMaya is tracking expenses in rF
},
];
+ // Typed demo notes
+ tripPlanningNotes.push(
+ {
+ id: "demo-note-code-1", title: "Expense Tracker Script",
+ content: `const expenses = [\n { item: "Flights", amount: 800, category: "transport" },\n { item: "Lac Blanc Hut", amount: 120, category: "accommodation" },\n { item: "Via Ferrata Rental", amount: 100, category: "activities" },\n];\n\nconst total = expenses.reduce((sum, e) => sum + e.amount, 0);\nconsole.log(\`Total: EUR \${total}\`);`,
+ content_plain: "Expense tracker script for the trip budget",
+ content_format: 'html',
+ type: "CODE", tags: ["budget", "code"], is_pinned: false,
+ language: "javascript",
+ created_at: new Date(now - 2 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(),
+ } as Note,
+ {
+ id: "demo-note-bookmark-1", title: "Chamonix Weather Forecast",
+ content: " Live weather forecast for the Chamonix valley. Check daily before hikes. Maya is tracking expenses in rF
this.demoNotebooks = [
{
id: "demo-nb-1", title: "Alpine Explorer Planning", description: "Shared knowledge base for our July 2026 trip across France, Switzerland, and Italy",
- cover_color: "#f59e0b", note_count: "6", updated_at: new Date(now - hour).toISOString(),
+ cover_color: "#f59e0b", note_count: "8", updated_at: new Date(now - hour).toISOString(),
notes: tripPlanningNotes,
} as any,
{
@@ -388,14 +435,19 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
this.mountEditor(newNote);
}
- private demoCreateNote() {
+ private demoCreateNote(opts: CreateNoteOpts = {}) {
if (!this.selectedNotebook) return;
const now = Date.now();
const noteId = `demo-note-${now}`;
+ const type = opts.type || 'NOTE';
+ const title = opts.title || FolkNotesApp.typeDefaultTitle(type);
const newNote: Note = {
- id: noteId, title: "Untitled Note", content: "", content_plain: "",
- content_format: 'tiptap-json',
- type: "NOTE", tags: null, is_pinned: false,
+ id: noteId, title, content: opts.content || "", content_plain: "",
+ content_format: type === 'CODE' ? 'html' : 'tiptap-json',
+ type, tags: opts.tags || null, is_pinned: false,
+ url: opts.url || null, language: opts.language || null,
+ fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null,
+ duration: opts.duration ?? null,
created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
};
const demoNb = this.demoNotebooks.find(n => n.id === this.selectedNotebook!.id);
@@ -487,6 +539,11 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
type: item.type || "NOTE",
tags: item.tags?.length ? Array.from(item.tags) : null,
is_pinned: item.isPinned || false,
+ url: item.url || null,
+ language: item.language || null,
+ fileUrl: item.fileUrl || null,
+ mimeType: item.mimeType || null,
+ duration: item.duration ?? null,
created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
});
@@ -521,6 +578,11 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
type: noteItem.type || "NOTE",
tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null,
is_pinned: noteItem.isPinned || false,
+ url: noteItem.url || null,
+ language: noteItem.language || null,
+ fileUrl: noteItem.fileUrl || null,
+ mimeType: noteItem.mimeType || null,
+ duration: noteItem.duration ?? null,
created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
};
@@ -575,6 +637,11 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
type: noteItem.type || "NOTE",
tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null,
is_pinned: noteItem.isPinned || false,
+ url: noteItem.url || null,
+ language: noteItem.language || null,
+ fileUrl: noteItem.fileUrl || null,
+ mimeType: noteItem.mimeType || null,
+ duration: noteItem.duration ?? null,
created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
};
@@ -587,34 +654,49 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
// ── Automerge mutations ──
- private createNoteViaSync() {
+ private static typeDefaultTitle(type: NoteType): string {
+ switch (type) {
+ case 'CODE': return 'Untitled Code Snippet';
+ case 'BOOKMARK': return 'Untitled Bookmark';
+ case 'CLIP': return 'Untitled Clip';
+ case 'IMAGE': return 'Untitled Image';
+ case 'AUDIO': return 'Voice Note';
+ case 'FILE': return 'Untitled File';
+ default: return 'Untitled Note';
+ }
+ }
+
+ private createNoteViaSync(opts: CreateNoteOpts = {}) {
if (!this.doc || !this.selectedNotebook || !this.subscribedDocId) return;
const noteId = crypto.randomUUID();
const now = Date.now();
const notebookId = this.selectedNotebook.id;
+ const type = opts.type || 'NOTE';
+ const title = opts.title || FolkNotesApp.typeDefaultTitle(type);
+ const contentFormat = type === 'CODE' ? 'html' : 'tiptap-json';
+
+ const itemData: any = {
+ id: noteId, notebookId, title,
+ content: opts.content || "", contentPlain: "", contentFormat,
+ type, tags: opts.tags || [], isPinned: false, sortOrder: 0,
+ createdAt: now, updatedAt: now,
+ url: opts.url || null, language: opts.language || null,
+ fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null,
+ duration: opts.duration ?? null,
+ };
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime?.isInitialized) {
runtime.change(this.subscribedDocId as DocumentId, "Create note", (d: NotebookDoc) => {
if (!d.items) (d as any).items = {};
- d.items[noteId] = {
- id: noteId, notebookId, title: "Untitled Note",
- content: "", contentPlain: "", contentFormat: "tiptap-json",
- type: "NOTE", tags: [], isPinned: false, sortOrder: 0,
- createdAt: now, updatedAt: now,
- };
+ d.items[noteId] = itemData;
});
this.doc = runtime.get(this.subscribedDocId as DocumentId);
} else {
this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => {
if (!d.items) (d as any).items = {};
- d.items[noteId] = {
- id: noteId, notebookId, title: "Untitled Note",
- content: "", contentPlain: "", contentFormat: "tiptap-json",
- type: "NOTE", tags: [], isPinned: false, sortOrder: 0,
- createdAt: now, updatedAt: now,
- };
+ d.items[noteId] = itemData;
});
}
@@ -622,9 +704,12 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
// Open the new note
this.selectedNote = {
- id: noteId, title: "Untitled Note", content: "", content_plain: "",
- content_format: 'tiptap-json',
- type: "NOTE", tags: null, is_pinned: false,
+ id: noteId, title, content: opts.content || "", content_plain: "",
+ content_format: contentFormat,
+ type, tags: opts.tags || null, is_pinned: false,
+ url: opts.url || null, language: opts.language || null,
+ fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null,
+ duration: opts.duration ?? null,
created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
};
this.view = "note";
@@ -720,6 +805,11 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
type: item.type || "NOTE",
tags: item.tags?.length ? Array.from(item.tags) : null,
is_pinned: item.isPinned || false,
+ url: item.url || null,
+ language: item.language || null,
+ fileUrl: item.fileUrl || null,
+ mimeType: item.mimeType || null,
+ duration: item.duration ?? null,
created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
};
@@ -776,12 +866,24 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
private mountEditor(note: Note) {
this.destroyEditor();
+ this.editorNoteId = note.id;
- // Build content zone
const isDemo = this.space === "demo";
const isAutomerge = !!(this.doc?.items?.[note.id]);
const isEditable = isAutomerge || isDemo;
+ // Branch on note type
+ switch (note.type) {
+ case 'CODE': this.mountCodeEditor(note, isEditable, isDemo); break;
+ case 'BOOKMARK':
+ case 'CLIP': this.mountBookmarkView(note, isEditable, isDemo); break;
+ case 'IMAGE': this.mountImageView(note, isEditable, isDemo); break;
+ case 'AUDIO': this.mountAudioView(note, isEditable, isDemo); break;
+ default: this.mountTiptapEditor(note, isEditable, isDemo); break;
+ }
+ }
+
+ private mountTiptapEditor(note: Note, isEditable: boolean, isDemo: boolean) {
this.contentZone.innerHTML = `
Maya is tracking expenses in rF
const container = this.shadow.getElementById('tiptap-container');
if (!container) return;
- // Determine content to load
let content: any = '';
if (note.content) {
if (note.content_format === 'tiptap-json') {
- try {
- content = JSON.parse(note.content);
- } catch {
- content = note.content;
- }
+ try { content = JSON.parse(note.content); } catch { content = note.content; }
} else {
- // HTML content (legacy or explicit)
content = note.content;
}
}
- const slashPlugin = createSlashCommandPlugin(
- null as any, // Will be set after editor creation
- this.shadow
- );
-
this.editor = new Editor({
element: container,
editable: isEditable,
extensions: [
- StarterKit.configure({
- codeBlock: false,
- heading: { levels: [1, 2, 3, 4] },
- }),
+ StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] } }),
Link.configure({ openOnClick: false }),
- Image,
- TaskList,
- TaskItem.configure({ nested: true }),
+ Image, TaskList, TaskItem.configure({ nested: true }),
Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }),
- CodeBlockLowlight.configure({ lowlight }),
- Typography,
- Underline,
+ CodeBlockLowlight.configure({ lowlight }), Typography, Underline,
],
content,
onUpdate: ({ editor }) => {
@@ -839,7 +923,6 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
const plain = editor.getText();
const noteId = this.editorNoteId;
if (!noteId) return;
-
if (isDemo) {
this.demoUpdateNoteField(noteId, "content", json);
this.demoUpdateNoteField(noteId, "content_plain", plain);
@@ -851,19 +934,11 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
}
}, 800);
},
- onSelectionUpdate: () => {
- this.updateToolbarState();
- },
+ onSelectionUpdate: () => { this.updateToolbarState(); },
});
- // Now register the slash command plugin with the actual editor
- this.editor.registerPlugin(
- createSlashCommandPlugin(this.editor, this.shadow)
- );
+ this.editor.registerPlugin(createSlashCommandPlugin(this.editor, this.shadow));
- this.editorNoteId = note.id;
-
- // Listen for slash command image insert (custom event from slash-command.ts)
container.addEventListener('slash-insert-image', () => {
if (!this.editor) return;
const { from } = this.editor.view.state.selection;
@@ -874,24 +949,284 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
});
});
- // Wire up title input
+ // Listen for slash-create-typed-note events
+ container.addEventListener('slash-create-typed-note', ((e: CustomEvent) => {
+ const { type } = e.detail || {};
+ if (type && this.selectedNotebook) {
+ this.createNoteViaSync({ type });
+ }
+ }) as EventListener);
+
+ this.wireTitleInput(note, isEditable, isDemo);
+ this.attachToolbarListeners();
+ }
+
+ private mountCodeEditor(note: Note, isEditable: boolean, isDemo: boolean) {
+ const languages = ['javascript', 'typescript', 'python', 'rust', 'go', 'html', 'css', 'json', 'sql', 'bash', 'c', 'cpp', 'java', 'ruby', 'php', 'markdown', 'yaml', 'toml', 'other'];
+ const currentLang = note.language || 'javascript';
+
+ this.contentZone.innerHTML = `
+ Maya is tracking expenses in rF
clearTimeout(this.editorUpdateTimer);
this.editorUpdateTimer = null;
}
+ if (this.dictation) {
+ this.dictation.destroy();
+ this.dictation = null;
+ }
if (this.editor) {
this.editor.destroy();
this.editor = null;
@@ -950,6 +1289,11 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
${btn('undo', 'Undo (Ctrl+Z)')}
${btn('redo', 'Redo (Ctrl+Y)')}
Maya is tracking expenses in rF
}
case 'undo': this.editor.chain().focus().undo().run(); break;
case 'redo': this.editor.chain().focus().redo().run(); break;
+ case 'mic': this.toggleDictation(btn); break;
}
});
@@ -1071,6 +1416,31 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
}
}
+ private toggleDictation(btn: HTMLElement) {
+ if (this.dictation?.isRecording) {
+ this.dictation.stop();
+ btn.classList.remove('recording');
+ return;
+ }
+ if (!this.dictation) {
+ this.dictation = new SpeechDictation({
+ onFinal: (text) => {
+ if (this.editor) {
+ this.editor.chain().focus().insertContent(text + ' ').run();
+ }
+ },
+ onStateChange: (recording) => {
+ btn.classList.toggle('recording', recording);
+ },
+ onError: (err) => {
+ console.warn('[Dictation]', err);
+ btn.classList.remove('recording');
+ },
+ });
+ }
+ this.dictation.start();
+ }
+
private updateToolbarState() {
if (!this.editor) return;
const toolbar = this.shadow.getElementById('editor-toolbar');
@@ -1184,6 +1554,14 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
const syncBadge = this.subscribedDocId
? ``
: "";
+ const filterTypes: { label: string; value: NoteType | '' }[] = [
+ { label: 'All', value: '' },
+ { label: 'Notes', value: 'NOTE' },
+ { label: 'Code', value: 'CODE' },
+ { label: 'Bookmarks', value: 'BOOKMARK' },
+ { label: 'Clips', value: 'CLIP' },
+ { label: 'Audio', value: 'AUDIO' },
+ ];
this.navZone.innerHTML = `
Maya is tracking expenses in rF
Export
-
+ 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("")
- : ' Maya is tracking expenses in rF
}
private renderNoteItem(n: Note): string {
+ let typeDetail = '';
+ switch (n.type) {
+ case 'BOOKMARK':
+ case 'CLIP': {
+ if (n.url) {
+ try { typeDetail = `${new URL(n.url).hostname}`; } catch { typeDetail = ''; }
+ }
+ break;
+ }
+ case 'CODE':
+ if (n.language) typeDetail = `${this.esc(n.language)}`;
+ break;
+ case 'AUDIO':
+ if (n.duration) {
+ const m = Math.floor(n.duration / 60);
+ const s = String(n.duration % 60).padStart(2, '0');
+ typeDetail = `${m}:${s}`;
+ }
+ break;
+ }
+
+ const typeBorder = this.getTypeBorderColor(n.type);
+
return `
- Maya is tracking expenses in rF
`;
}
+ private getTypeBorderColor(type: string): string {
+ switch (type) {
+ case 'NOTE': return 'var(--rs-primary, #6366f1)';
+ case 'CODE': return '#10b981';
+ case 'BOOKMARK': return '#f59e0b';
+ case 'CLIP': return '#8b5cf6';
+ case 'IMAGE': return '#ec4899';
+ case 'AUDIO': return '#ef4444';
+ case 'FILE': return '#6b7280';
+ default: return 'var(--rs-border, #e5e7eb)';
+ }
+ }
+
private attachListeners() {
const isDemo = this.space === "demo";
@@ -1498,6 +1963,90 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
.note-item__meta { font-size: 11px; color: var(--rs-text-muted); margin-top: 6px; display: flex; gap: 8px; align-items: center; }
.tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: var(--rs-bg-surface-raised); color: var(--rs-text-secondary); font-size: 10px; }
+ /* ── Type Filter Bar ── */
+ .type-filter-bar { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; }
+ .type-filter-pill {
+ padding: 4px 12px; border-radius: 16px; border: 1px solid var(--rs-border);
+ background: transparent; color: var(--rs-text-secondary); font-size: 12px;
+ cursor: pointer; transition: all 0.15s; font-family: inherit;
+ }
+ .type-filter-pill:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
+ .type-filter-pill.active { background: var(--rs-primary); color: #fff; border-color: var(--rs-primary); }
+
+ /* ── New Note Split Button ── */
+ .new-note-split { display: flex; position: relative; }
+ .new-note-split .rapp-nav__btn:first-child { border-radius: 6px 0 0 6px; }
+ .new-note-dropdown-btn {
+ border-radius: 0 6px 6px 0 !important; padding: 6px 8px !important;
+ border-left: 1px solid rgba(255,255,255,0.2) !important; min-width: 28px;
+ }
+ .new-note-dropdown {
+ position: absolute; top: 100%; right: 0; margin-top: 4px; z-index: 50;
+ background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
+ border-radius: 8px; box-shadow: var(--rs-shadow-md); min-width: 180px;
+ overflow: hidden;
+ }
+ .new-note-dropdown-item {
+ padding: 8px 14px; cursor: pointer; font-size: 13px;
+ color: var(--rs-text-primary); transition: background 0.1s;
+ }
+ .new-note-dropdown-item:hover { background: var(--rs-bg-hover); }
+
+ /* ── Note Item Type Badges ── */
+ .note-item__badge {
+ display: inline-block; padding: 1px 6px; border-radius: 3px;
+ font-size: 10px; font-weight: 500;
+ }
+ .badge-url { background: rgba(245, 158, 11, 0.15); color: #d97706; }
+ .badge-lang { background: rgba(16, 185, 129, 0.15); color: #059669; }
+ .badge-duration { background: rgba(239, 68, 68, 0.15); color: #dc2626; }
+
+ /* ── Code Editor ── */
+ .code-editor-controls { padding: 4px 12px; display: flex; gap: 8px; align-items: center; }
+ .code-textarea {
+ width: 100%; min-height: 400px; padding: 16px 20px; border: none; outline: none;
+ font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
+ font-size: 13px; line-height: 1.6; tab-size: 4; resize: vertical;
+ background: var(--rs-bg-surface-sunken); color: var(--rs-text-primary);
+ }
+
+ /* ── Bookmark / Clip View ── */
+ .bookmark-card {
+ display: flex; gap: 12px; align-items: center;
+ padding: 12px 16px; margin: 0 12px 8px;
+ background: var(--rs-bg-surface-raised); border-radius: 8px;
+ }
+ .bookmark-favicon { border-radius: 4px; flex-shrink: 0; }
+ .bookmark-info { flex: 1; min-width: 0; }
+ .bookmark-url { color: var(--rs-primary); font-size: 13px; text-decoration: none; }
+ .bookmark-url:hover { text-decoration: underline; }
+ .bookmark-url-input-row { margin-top: 4px; }
+ .bookmark-url-input {
+ width: 100%; padding: 6px 10px; border-radius: 6px;
+ border: 1px solid var(--rs-input-border); background: var(--rs-input-bg);
+ color: var(--rs-input-text); font-size: 12px; font-family: inherit;
+ }
+
+ /* ── Image View ── */
+ .image-display { padding: 12px 16px; text-align: center; }
+ .image-preview { max-width: 100%; max-height: 500px; border-radius: 8px; border: 1px solid var(--rs-border-subtle); }
+ .image-upload-placeholder { padding: 16px; }
+
+ /* ── Audio View ── */
+ .audio-player-container {
+ display: flex; gap: 12px; align-items: center;
+ padding: 12px 16px; margin: 0 12px;
+ }
+ .audio-player { flex: 1; max-width: 100%; height: 40px; }
+ .audio-duration { font-size: 13px; color: var(--rs-text-muted); font-weight: 500; }
+ .audio-record-placeholder { padding: 24px; text-align: center; }
+ .audio-transcript-section { padding: 0 4px; }
+ .audio-transcript-label {
+ font-size: 12px; font-weight: 600; text-transform: uppercase;
+ letter-spacing: 0.05em; color: var(--rs-text-muted);
+ padding: 8px 16px 0;
+ }
+
/* ── Editor Title ── */
.editable-title {
background: transparent; border: none; border-bottom: 2px solid transparent;
@@ -1546,6 +2095,8 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
.toolbar-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
.toolbar-btn:hover { background: var(--rs-toolbar-btn-hover); color: var(--rs-toolbar-btn-text); }
.toolbar-btn.active { background: var(--rs-primary); color: #fff; }
+ .toolbar-btn.recording { background: var(--rs-error, #ef4444); color: #fff; animation: pulse-recording 1.5s infinite; }
+ @keyframes pulse-recording { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
.toolbar-select {
padding: 2px 4px; border-radius: 4px; border: 1px solid var(--rs-toolbar-panel-border);
background: var(--rs-toolbar-bg); color: var(--rs-text-secondary); font-size: 12px; cursor: pointer;
diff --git a/modules/rnotes/components/folk-voice-recorder.ts b/modules/rnotes/components/folk-voice-recorder.ts
new file mode 100644
index 0000000..f719e64
--- /dev/null
+++ b/modules/rnotes/components/folk-voice-recorder.ts
@@ -0,0 +1,481 @@
+/**
+ * Record voice notes with automatic transcription Offline model cached Recording... ${esc(this.progressMessage)}` : ''}
+
Voice Recorder
+ Recording Complete
+ ${this.audioUrl ? `` : ''}
+