feat: rebuild rNotes as vault browser, editor code now in rDocs

Phase 2-3 of the rNotes/rDocs split. Rewrites rNotes from a full TipTap
editor (~1800 lines) into a lightweight Obsidian/Logseq vault sync and
browse module (~560 lines). Rich editing features remain in rDocs.

rNotes vault browser:
- VaultDoc schema: metadata-only in Automerge (title, tags, hash, wikilinks)
- ZIP vault uploads stored on disk at /data/files/uploads/vaults/
- File tree browser, search, read-only markdown preview
- Wikilink graph data endpoint for visualization
- 5 MCP tools: list_vaults, browse_vault, search_vault, get_vault_note, sync_status
- Browser extension compat shim redirects old API calls to rDocs

Cleanup:
- Removed dead editor files from rnotes (converters, components, local-first-client)
- Updated MI integration to use getRecentVaultNotesForMI
- Updated ONTOLOGY.md with new module descriptions
- Bumped JS cache versions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-10 18:05:35 -04:00
parent 99492cc532
commit 18b61fa5e6
44 changed files with 1850 additions and 14868 deletions

View File

@ -221,7 +221,7 @@ Flows are typed connections between modules:
| Kind | Description | Example |
|------|-------------|---------|
| `data` | Information flow | rNotes → rPubs (publish) |
| `data` | Information flow | rDocs → rPubs (publish) |
| `economic` | Value/payment flow | rFunds → rWallet (treasury) |
| `trust` | Reputation/attestation | rVote → rNetwork (delegation) |
| `attention` | Signal/notification | rInbox → rForum (mentions) |
@ -251,10 +251,10 @@ redirects to the unified server with subdomain-based space routing.
| Module | Domain | Purpose |
|--------|--------|---------|
| **rNotes** | rnotes.online | Collaborative notebooks (Automerge) |
| **rDocs** | rdocs.online | Rich editor — notebooks, voice transcription, AI, import/export (TipTap + Automerge) |
| **rNotes** | rnotes.online | Vault sync & browse for Obsidian and Logseq |
| **rPubs** | rpubs.online | Long-form publishing (Typst PDF) |
| **rBooks** | rbooks.online | PDF library with flipbook reader |
| **rDocs** | rdocs.online | Document management |
| **rData** | rdata.online | Data visualization & analysis |
### Planning & Spatial

View File

@ -1,9 +1,10 @@
---
id: TASK-29
title: Port folk-drawfast shape (collaborative drawing/gesture recognition)
status: To Do
status: Done
assignee: []
created_date: '2026-02-18 19:50'
updated_date: '2026-04-10 21:28'
labels:
- shape-port
- phase-2
@ -34,8 +35,16 @@ Features to implement:
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Freehand drawing works with pointer/touch input
- [ ] #2 Gesture recognition detects basic shapes
- [ ] #3 Drawing state syncs across clients
- [ ] #4 Toolbar button added to canvas.html
- [x] #1 Freehand drawing works with pointer/touch input
- [x] #2 Gesture recognition detects basic shapes
- [x] #3 Drawing state syncs across clients
- [x] #4 Toolbar button added to canvas.html
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-04-10: Added AI sketch-to-image generation (fal.ai + Gemini via /api/image-gen/img2img). Split-view layout with drawing canvas + AI result. Auto-generate toggle, strength slider, provider selector. Image preloading for smooth transitions. Port descriptors for folk-arrow connections. AC#1 (freehand drawing) and AC#4 (toolbar button) were already implemented. AC#2 (gesture recognition) and AC#3 (collaborative sync) still outstanding.
AC#2: Implemented Unistroke Recognizer with templates for circle, rectangle, triangle, line, arrow, checkmark. Freehand strokes matching >70% confidence are auto-converted to clean geometric shapes with a floating badge. AC#3: Fixed applyData() to restore strokes array, prompt text, and last result URL from Automerge sync data. toJSON() now exports prompt text for sync.
<!-- SECTION:NOTES:END -->

224
lib/mi-voice-bridge.ts Normal file
View File

@ -0,0 +1,224 @@
/**
* MiVoiceBridge TTS output via Edge TTS bridge + Web Speech Synthesis fallback.
*
* Connects to claude-voice.jeffemmett.com for high-quality neural voice synthesis.
* Falls back to browser speechSynthesis if the bridge is unavailable.
*/
export type VoiceState = "idle" | "listening" | "thinking" | "speaking";
export interface MiVoiceBridgeOptions {
bridgeUrl?: string;
voice?: string;
onStateChange?: (state: VoiceState) => void;
}
const DEFAULT_BRIDGE = "https://claude-voice.jeffemmett.com";
const WS_PATH = "/ws/audio";
const TTS_PATH = "/api/tts/speak";
export class MiVoiceBridge {
#bridgeUrl: string;
#voice: string;
#onStateChange: ((s: VoiceState) => void) | null;
#ws: WebSocket | null = null;
#audioCtx: AudioContext | null = null;
#currentSource: AudioBufferSourceNode | null = null;
#speaking = false;
#destroyed = false;
#speakResolve: (() => void) | null = null;
constructor(opts: MiVoiceBridgeOptions = {}) {
this.#bridgeUrl = opts.bridgeUrl ?? DEFAULT_BRIDGE;
this.#voice = opts.voice ?? "en-US-AriaNeural";
this.#onStateChange = opts.onStateChange ?? null;
}
get isSpeaking(): boolean {
return this.#speaking;
}
setVoice(voice: string): void {
this.#voice = voice;
}
async speak(text: string): Promise<void> {
if (this.#destroyed || !text.trim()) return;
this.#speaking = true;
try {
await this.#speakViaBridge(text);
} catch {
// Bridge unavailable — fall back to browser TTS
await this.#speakViaBrowser(text);
} finally {
this.#speaking = false;
}
}
stop(): void {
// Stop AudioContext playback
if (this.#currentSource) {
try { this.#currentSource.stop(); } catch { /* already stopped */ }
this.#currentSource = null;
}
// Stop browser TTS
if (window.speechSynthesis?.speaking) {
window.speechSynthesis.cancel();
}
this.#speaking = false;
if (this.#speakResolve) {
this.#speakResolve();
this.#speakResolve = null;
}
}
destroy(): void {
this.#destroyed = true;
this.stop();
if (this.#ws) {
this.#ws.close();
this.#ws = null;
}
if (this.#audioCtx) {
this.#audioCtx.close();
this.#audioCtx = null;
}
}
// ── Bridge TTS ──
#ensureAudioCtx(): AudioContext {
if (!this.#audioCtx || this.#audioCtx.state === "closed") {
this.#audioCtx = new AudioContext();
}
if (this.#audioCtx.state === "suspended") {
this.#audioCtx.resume();
}
return this.#audioCtx;
}
#connectWs(): Promise<WebSocket> {
if (this.#ws && this.#ws.readyState === WebSocket.OPEN) {
return Promise.resolve(this.#ws);
}
return new Promise((resolve, reject) => {
const wsUrl = this.#bridgeUrl.replace(/^http/, "ws") + WS_PATH;
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
const timeout = setTimeout(() => {
ws.close();
reject(new Error("WS connect timeout"));
}, 5000);
ws.onopen = () => {
clearTimeout(timeout);
this.#ws = ws;
resolve(ws);
};
ws.onerror = () => {
clearTimeout(timeout);
reject(new Error("WS connection failed"));
};
ws.onclose = () => {
if (this.#ws === ws) this.#ws = null;
};
});
}
async #speakViaBridge(text: string): Promise<void> {
// Connect WS first so we're ready to receive audio
const ws = await this.#connectWs();
return new Promise<void>(async (resolve, reject) => {
this.#speakResolve = resolve;
// Listen for the audio frame
const handler = async (ev: MessageEvent) => {
if (!(ev.data instanceof ArrayBuffer)) return;
ws.removeEventListener("message", handler);
try {
const buf = ev.data as ArrayBuffer;
const view = new DataView(buf);
// Frame format: [4B header_len][JSON header][MP3 bytes]
const headerLen = view.getUint32(0, true);
const mp3Bytes = buf.slice(4 + headerLen);
const ctx = this.#ensureAudioCtx();
const audioBuffer = await ctx.decodeAudioData(mp3Bytes.slice(0)); // slice to copy
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
this.#currentSource = source;
source.onended = () => {
this.#currentSource = null;
this.#speakResolve = null;
resolve();
};
source.start();
} catch (err) {
this.#speakResolve = null;
reject(err);
}
};
ws.addEventListener("message", handler);
// POST the TTS request
try {
const res = await fetch(`${this.#bridgeUrl}${TTS_PATH}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, voice: this.#voice, volume: 100 }),
});
if (!res.ok) {
ws.removeEventListener("message", handler);
this.#speakResolve = null;
reject(new Error(`TTS POST failed: ${res.status}`));
}
} catch (err) {
ws.removeEventListener("message", handler);
this.#speakResolve = null;
reject(err);
}
// Timeout: if no audio frame in 15s, reject
setTimeout(() => {
ws.removeEventListener("message", handler);
if (this.#speakResolve === resolve) {
this.#speakResolve = null;
reject(new Error("TTS audio timeout"));
}
}, 15000);
});
}
// ── Browser fallback ──
async #speakViaBrowser(text: string): Promise<void> {
if (!window.speechSynthesis) return;
return new Promise<void>((resolve) => {
this.#speakResolve = resolve;
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = 1.05;
utterance.onend = () => {
this.#speakResolve = null;
resolve();
};
utterance.onerror = () => {
this.#speakResolve = null;
resolve();
};
window.speechSynthesis.speak(utterance);
});
}
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 837 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 B

View File

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

View File

@ -1,231 +0,0 @@
<!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: #f59e0b;
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: #f59e0b;
}
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: #451a03;
border: 1px solid #78350f;
color: #fbbf24;
}
.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; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary {
background: #f59e0b;
color: #0a0a0a;
}
.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>rNotes Web Clipper Settings</h2>
<!-- Connection -->
<div class="section">
<h3>Connection</h3>
<div class="field">
<label for="host">rNotes URL</label>
<input type="text" id="host" value="https://rnotes.online" />
<div class="help">The URL of your rNotes instance</div>
</div>
</div>
<!-- Authentication -->
<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 rNotes</label>
<button class="btn-secondary btn-small" id="openSigninBtn">Open rNotes Sign-in</button>
<div class="help">Opens rNotes 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 from the rNotes sign-in page here..."></textarea>
<div class="help">After signing in, copy the extension token and paste it here.</div>
</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>
<!-- Default Notebook -->
<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>
<!-- Actions -->
<div class="btn-row" style="justify-content: flex-end;">
<button class="btn-secondary" id="testBtn">Test Connection</button>
<button class="btn-primary" id="saveBtn">Save Settings</button>
</div>
<div id="status" class="status"></div>
<script src="options.js"></script>
</body>
</html>

View File

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

View File

@ -1,147 +0,0 @@
/**
* Offline transcription using parakeet.js (NVIDIA Parakeet TDT 0.6B v2).
* Loaded at runtime from CDN. Model ~634 MB (int8) on first download,
* cached in IndexedDB after. Works fully offline after first download.
*
* Port of src/lib/parakeetOffline.ts for the browser extension.
*/
const CACHE_KEY = 'parakeet-offline-cached';
// Singleton model — don't reload on subsequent calls
let cachedModel = null;
let loadingPromise = null;
/**
* Check if the Parakeet model has been downloaded before.
*/
function isModelCached() {
try {
return localStorage.getItem(CACHE_KEY) === 'true';
} catch {
return false;
}
}
/**
* Detect WebGPU availability.
*/
async function detectWebGPU() {
if (!navigator.gpu) return false;
try {
const adapter = await navigator.gpu.requestAdapter();
return !!adapter;
} catch {
return false;
}
}
/**
* Get or create the Parakeet model singleton.
* @param {function} onProgress - callback({ status, progress, file, message })
*/
async function getModel(onProgress) {
if (cachedModel) return cachedModel;
if (loadingPromise) return loadingPromise;
loadingPromise = (async () => {
onProgress?.({ status: 'loading', message: 'Loading Parakeet model...' });
// Dynamic import from CDN at runtime
const { fromHub } = await import('https://esm.sh/parakeet.js@1.1.2');
const backend = (await detectWebGPU()) ? 'webgpu' : 'wasm';
const fileProgress = {};
const model = await fromHub('parakeet-tdt-0.6b-v2', {
backend,
progress: ({ file, loaded, total }) => {
fileProgress[file] = { loaded, total };
let totalBytes = 0;
let loadedBytes = 0;
for (const fp of Object.values(fileProgress)) {
totalBytes += fp.total || 0;
loadedBytes += fp.loaded || 0;
}
if (totalBytes > 0) {
const pct = Math.round((loadedBytes / totalBytes) * 100);
onProgress?.({
status: 'downloading',
progress: pct,
file,
message: `Downloading model... ${pct}%`,
});
}
},
});
localStorage.setItem(CACHE_KEY, 'true');
onProgress?.({ status: 'loading', message: 'Model loaded' });
cachedModel = model;
loadingPromise = null;
return model;
})();
return loadingPromise;
}
/**
* Decode an audio Blob to Float32Array at 16 kHz mono.
*/
async function decodeAudioBlob(blob) {
const arrayBuffer = await blob.arrayBuffer();
const audioCtx = new AudioContext({ sampleRate: 16000 });
try {
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
if (audioBuffer.sampleRate === 16000 && audioBuffer.numberOfChannels === 1) {
return audioBuffer.getChannelData(0);
}
// Resample via OfflineAudioContext
const numSamples = Math.ceil(audioBuffer.duration * 16000);
const offlineCtx = new OfflineAudioContext(1, numSamples, 16000);
const source = offlineCtx.createBufferSource();
source.buffer = audioBuffer;
source.connect(offlineCtx.destination);
source.start();
const resampled = await offlineCtx.startRendering();
return resampled.getChannelData(0);
} finally {
await audioCtx.close();
}
}
/**
* Transcribe an audio Blob offline using Parakeet in the browser.
* First call downloads the model (~634 MB). Subsequent calls use cached.
*
* @param {Blob} audioBlob
* @param {function} onProgress - callback({ status, progress, file, message })
* @returns {Promise<string>} transcribed text
*/
async function transcribeOffline(audioBlob, onProgress) {
const model = await getModel(onProgress);
onProgress?.({ status: 'transcribing', message: 'Transcribing audio...' });
const audioData = await decodeAudioBlob(audioBlob);
const result = await model.transcribe(audioData, 16000, {
returnTimestamps: false,
enableProfiling: false,
});
const text = result.utterance_text?.trim() || '';
onProgress?.({ status: 'done', message: 'Transcription complete' });
return text;
}
// Export for use in voice.js (loaded as ES module)
window.ParakeetOffline = {
isModelCached,
transcribeOffline,
};

View File

@ -1,262 +0,0 @@
<!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: #f59e0b;
}
.header .user {
font-size: 11px;
color: #a3a3a3;
}
.header .user.not-authed {
color: #ef4444;
}
.auth-warning {
padding: 10px 14px;
background: #451a03;
border-bottom: 1px solid #78350f;
text-align: center;
font-size: 12px;
color: #fbbf24;
}
.auth-warning a {
color: #f59e0b;
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: #f59e0b;
}
.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: #f59e0b;
color: #0a0a0a;
}
.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: #f59e0b;
}
</style>
</head>
<body>
<div class="header">
<span class="brand">rNotes 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>

View File

@ -1,328 +0,0 @@
const DEFAULT_HOST = 'https://rnotes.online';
let currentTab = null;
let selectedText = '';
let selectedHtml = '';
// --- Helpers ---
async function getSettings() {
const result = await chrome.storage.sync.get(['rnotesHost']);
return {
host: result.rnotesHost || DEFAULT_HOST,
};
}
async function getToken() {
const result = await chrome.storage.local.get(['encryptid_token']);
return result.encryptid_token || null;
}
function decodeToken(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
// Check expiry
if (payload.exp && payload.exp * 1000 < Date.now()) {
return null; // expired
}
return payload;
} catch {
return null;
}
}
function parseTags(tagString) {
if (!tagString || !tagString.trim()) return [];
return tagString.split(',').map(t => t.trim().toLowerCase()).filter(Boolean);
}
function showStatus(message, type) {
const el = document.getElementById('status');
el.textContent = message;
el.className = `status ${type}`;
if (type === 'success') {
setTimeout(() => { el.className = 'status'; }, 3000);
}
}
// --- API calls ---
async function createNote(data) {
const token = await getToken();
const settings = await getSettings();
const body = {
title: data.title,
content: data.content,
type: data.type || 'CLIP',
url: data.url,
};
const notebookId = document.getElementById('notebook').value;
if (notebookId) body.notebookId = notebookId;
const tags = parseTags(document.getElementById('tags').value);
if (tags.length > 0) body.tags = tags;
const response = await fetch(`${settings.host}/api/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`${response.status}: ${text}`);
}
return response.json();
}
async function fetchNotebooks() {
const token = await getToken();
const settings = await getSettings();
const response = await fetch(`${settings.host}/api/notebooks`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) return [];
const data = await response.json();
return Array.isArray(data) ? data : [];
}
// --- UI ---
async function populateNotebooks() {
const select = document.getElementById('notebook');
try {
const notebooks = await fetchNotebooks();
// Keep the "No notebook" option
for (const nb of notebooks) {
const option = document.createElement('option');
option.value = nb.id;
option.textContent = nb.title;
select.appendChild(option);
}
// Restore last used notebook
const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']);
if (lastNotebookId) {
select.value = lastNotebookId;
}
} catch (err) {
console.error('Failed to load notebooks:', err);
}
}
// Save last used notebook when changed
function setupNotebookMemory() {
document.getElementById('notebook').addEventListener('change', (e) => {
chrome.storage.local.set({ lastNotebookId: e.target.value });
});
}
async function init() {
// Get current tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
currentTab = tab;
// Display page info
document.getElementById('pageTitle').textContent = tab.title || 'Untitled';
document.getElementById('pageUrl').textContent = tab.url || '';
// Check auth
const token = await getToken();
const claims = token ? decodeToken(token) : null;
if (!claims) {
document.getElementById('userStatus').textContent = 'Not signed in';
document.getElementById('userStatus').classList.add('not-authed');
document.getElementById('authWarning').style.display = 'block';
return;
}
document.getElementById('userStatus').textContent = claims.username || claims.sub?.slice(0, 16) || 'Authenticated';
document.getElementById('authWarning').style.display = 'none';
// Enable buttons
document.getElementById('clipPageBtn').disabled = false;
document.getElementById('unlockBtn').disabled = false;
document.getElementById('voiceBtn').disabled = false;
// Load notebooks
await populateNotebooks();
setupNotebookMemory();
// Detect text selection
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
return { text: '', html: '' };
}
const range = selection.getRangeAt(0);
const div = document.createElement('div');
div.appendChild(range.cloneContents());
return { text: selection.toString(), html: div.innerHTML };
},
});
if (result?.result?.text) {
selectedText = result.result.text;
selectedHtml = result.result.html;
document.getElementById('clipSelectionBtn').disabled = false;
}
} catch (err) {
// Can't access some pages (chrome://, etc.)
console.warn('Cannot access page content:', err);
}
}
// --- Event handlers ---
document.getElementById('clipPageBtn').addEventListener('click', async () => {
const btn = document.getElementById('clipPageBtn');
btn.disabled = true;
showStatus('Clipping page...', 'loading');
try {
// Get page HTML content
let pageContent = '';
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId: currentTab.id },
func: () => document.body.innerHTML,
});
pageContent = result?.result || '';
} catch {
// Fallback: just use URL as content
pageContent = `<p>Clipped from <a href="${currentTab.url}">${currentTab.url}</a></p>`;
}
const note = await createNote({
title: currentTab.title || 'Untitled Clip',
content: pageContent,
type: 'CLIP',
url: currentTab.url,
});
showStatus(`Clipped! Note saved.`, 'success');
// Notify background worker
chrome.runtime.sendMessage({
type: 'notify',
title: 'Page Clipped',
message: `"${currentTab.title}" saved to rNotes`,
});
} catch (err) {
showStatus(`Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
}
});
document.getElementById('clipSelectionBtn').addEventListener('click', async () => {
const btn = document.getElementById('clipSelectionBtn');
btn.disabled = true;
showStatus('Clipping selection...', 'loading');
try {
const content = selectedHtml || `<p>${selectedText}</p>`;
const note = await createNote({
title: `Selection from ${currentTab.title || 'page'}`,
content: content,
type: 'CLIP',
url: currentTab.url,
});
showStatus(`Selection clipped!`, 'success');
chrome.runtime.sendMessage({
type: 'notify',
title: 'Selection Clipped',
message: `Saved to rNotes`,
});
} catch (err) {
showStatus(`Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
}
});
document.getElementById('unlockBtn').addEventListener('click', async () => {
const btn = document.getElementById('unlockBtn');
btn.disabled = true;
showStatus('Unlocking article...', 'loading');
try {
const token = await getToken();
const settings = await getSettings();
const response = await fetch(`${settings.host}/api/articles/unlock`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ url: currentTab.url }),
});
const result = await response.json();
if (result.success && result.archiveUrl) {
// Also save as a note
await createNote({
title: currentTab.title || 'Unlocked Article',
content: `<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');
// Open archive in new tab
chrome.tabs.create({ url: result.archiveUrl });
} else {
showStatus(result.error || 'No archived version found', 'error');
}
} catch (err) {
showStatus(`Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
}
});
document.getElementById('voiceBtn').addEventListener('click', async () => {
// Open rVoice PWA page in a popup window (supports PiP pop-out)
const settings = await getSettings();
chrome.windows.create({
url: `${settings.host}/voice`,
type: 'popup',
width: 400,
height: 600,
focused: true,
});
// Close the current popup
window.close();
});
document.getElementById('optionsLink').addEventListener('click', (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
document.getElementById('openSettings')?.addEventListener('click', (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
// Init on load
document.addEventListener('DOMContentLoaded', init);

View File

@ -1,414 +0,0 @@
<!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: #ef4444;
}
.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: #451a03;
border-bottom: 1px solid #78350f;
text-align: center;
font-size: 12px;
color: #fbbf24;
}
.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: #ef4444;
}
.rec-btn .inner {
width: 32px;
height: 32px;
background: #ef4444;
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: #f59e0b; }
.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: #f59e0b;
}
.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: #f59e0b;
}
.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: #f59e0b;
color: #0a0a0a;
}
.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: #f59e0b;
border-radius: 3px;
transition: width 0.3s;
width: 0%;
}
/* Audio preview */
.audio-preview {
width: 100%;
padding: 0 14px 8px;
display: none;
}
.audio-preview.visible {
display: block;
}
.audio-preview audio {
width: 100%;
height: 32px;
}
/* Keyboard hint */
.kbd-hint {
padding: 4px 14px 8px;
text-align: center;
font-size: 10px;
color: #404040;
}
.kbd-hint kbd {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 3px;
padding: 1px 5px;
font-family: inherit;
font-size: 10px;
}
</style>
</head>
<body>
<div class="header">
<span>
<span class="brand">rVoice</span>
<span class="brand-sub">voice notes</span>
</span>
<button class="close-btn" id="closeBtn" title="Close">&times;</button>
</div>
<div id="authWarning" class="auth-warning" style="display: none;">
Sign in via rNotes 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 rNotes</button>
</div>
<div class="status-bar" id="statusBar"></div>
<div class="kbd-hint">
<kbd>Space</kbd> to record &middot; <kbd>Esc</kbd> to close &middot; Offline ready
</div>
<script src="parakeet-offline.js" type="module"></script>
<script src="voice.js"></script>
</body>
</html>

View File

@ -1,610 +0,0 @@
const DEFAULT_HOST = 'https://rnotes.online';
// --- State ---
let state = 'idle'; // idle | recording | processing | done
let mediaRecorder = null;
let audioChunks = [];
let timerInterval = null;
let startTime = 0;
let audioBlob = null;
let audioUrl = null;
let transcript = '';
let liveTranscript = ''; // accumulated from Web Speech API
let uploadedFileUrl = '';
let uploadedMimeType = '';
let uploadedFileSize = 0;
let duration = 0;
// Web Speech API
let recognition = null;
let speechSupported = !!(window.SpeechRecognition || window.webkitSpeechRecognition);
// --- DOM refs ---
const recBtn = document.getElementById('recBtn');
const timerEl = document.getElementById('timer');
const statusLabel = document.getElementById('statusLabel');
const transcriptArea = document.getElementById('transcriptArea');
const transcriptText = document.getElementById('transcriptText');
const liveIndicator = document.getElementById('liveIndicator');
const audioPreview = document.getElementById('audioPreview');
const audioPlayer = document.getElementById('audioPlayer');
const notebookSelect = document.getElementById('notebook');
const postActions = document.getElementById('postActions');
const saveBtn = document.getElementById('saveBtn');
const discardBtn = document.getElementById('discardBtn');
const copyBtn = document.getElementById('copyBtn');
const statusBar = document.getElementById('statusBar');
const authWarning = document.getElementById('authWarning');
const closeBtn = document.getElementById('closeBtn');
// --- Helpers ---
async function getSettings() {
const result = await chrome.storage.sync.get(['rnotesHost']);
return { host: result.rnotesHost || DEFAULT_HOST };
}
async function getToken() {
const result = await chrome.storage.local.get(['encryptid_token']);
return result.encryptid_token || null;
}
function decodeToken(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp && payload.exp * 1000 < Date.now()) return null;
return payload;
} catch { return null; }
}
function formatTime(seconds) {
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
function setStatusLabel(text, cls) {
statusLabel.textContent = text;
statusLabel.className = `status-label ${cls}`;
}
function showStatusBar(message, type) {
statusBar.textContent = message;
statusBar.className = `status-bar visible ${type}`;
if (type === 'success') {
setTimeout(() => { statusBar.className = 'status-bar'; }, 3000);
}
}
// --- Parakeet progress UI ---
const progressArea = document.getElementById('progressArea');
const progressLabel = document.getElementById('progressLabel');
const progressFill = document.getElementById('progressFill');
function showParakeetProgress(p) {
if (!progressArea) return;
progressArea.classList.add('visible');
if (p.message) {
progressLabel.textContent = p.message;
}
if (p.status === 'downloading' && p.progress !== undefined) {
progressFill.style.width = `${p.progress}%`;
} else if (p.status === 'transcribing') {
progressFill.style.width = '100%';
} else if (p.status === 'loading') {
progressFill.style.width = '0%';
}
}
function hideParakeetProgress() {
if (progressArea) {
progressArea.classList.remove('visible');
progressFill.style.width = '0%';
}
}
// --- Notebook loader ---
async function loadNotebooks() {
const token = await getToken();
if (!token) return;
const settings = await getSettings();
try {
const res = await fetch(`${settings.host}/api/notebooks`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) return;
const notebooks = await res.json();
for (const nb of notebooks) {
const opt = document.createElement('option');
opt.value = nb.id;
opt.textContent = nb.title;
notebookSelect.appendChild(opt);
}
// Restore last used
const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']);
if (lastNotebookId) notebookSelect.value = lastNotebookId;
} catch (err) {
console.error('Failed to load notebooks:', err);
}
}
notebookSelect.addEventListener('change', (e) => {
chrome.storage.local.set({ lastNotebookId: e.target.value });
});
// --- Live transcription (Web Speech API) ---
function startLiveTranscription() {
if (!speechSupported) return;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
let finalizedText = '';
recognition.onresult = (event) => {
let interimText = '';
// Rebuild finalized text from all final results
finalizedText = '';
for (let i = 0; i < event.results.length; i++) {
const result = event.results[i];
if (result.isFinal) {
finalizedText += result[0].transcript.trim() + ' ';
} else {
interimText += result[0].transcript;
}
}
liveTranscript = finalizedText.trim();
// Update the live transcript display
updateLiveDisplay(finalizedText.trim(), interimText.trim());
};
recognition.onerror = (event) => {
if (event.error !== 'aborted' && event.error !== 'no-speech') {
console.warn('Speech recognition error:', event.error);
}
};
// Auto-restart on end (Chrome stops after ~60s of silence)
recognition.onend = () => {
if (state === 'recording' && recognition) {
try { recognition.start(); } catch {}
}
};
try {
recognition.start();
if (liveIndicator) liveIndicator.classList.add('visible');
} catch (err) {
console.warn('Could not start speech recognition:', err);
speechSupported = false;
}
}
function stopLiveTranscription() {
if (recognition) {
const ref = recognition;
recognition = null;
try { ref.stop(); } catch {}
}
if (liveIndicator) liveIndicator.classList.remove('visible');
}
function updateLiveDisplay(finalText, interimText) {
if (state !== 'recording') return;
// Show transcript area while recording
transcriptArea.classList.add('visible');
let html = '';
if (finalText) {
html += `<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;
// Auto-scroll
transcriptText.scrollTop = transcriptText.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// --- Recording ---
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
mediaRecorder = new MediaRecorder(stream, { mimeType });
audioChunks = [];
liveTranscript = '';
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) audioChunks.push(e.data);
};
mediaRecorder.start(1000);
startTime = Date.now();
state = 'recording';
// UI updates
recBtn.classList.add('recording');
timerEl.classList.add('recording');
setStatusLabel('Recording', 'recording');
postActions.style.display = 'none';
audioPreview.classList.remove('visible');
statusBar.className = 'status-bar';
// Show transcript area with listening placeholder
if (speechSupported) {
transcriptArea.classList.add('visible');
transcriptText.innerHTML = '<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);
// Start live transcription alongside recording
startLiveTranscription();
} catch (err) {
showStatusBar(err.message || 'Microphone access denied', 'error');
}
}
async function stopRecording() {
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
clearInterval(timerInterval);
timerInterval = null;
duration = Math.floor((Date.now() - startTime) / 1000);
// Capture live transcript before stopping recognition
const capturedLiveTranscript = liveTranscript;
// Stop live transcription
stopLiveTranscription();
state = 'processing';
recBtn.classList.remove('recording');
timerEl.classList.remove('recording');
setStatusLabel('Processing...', 'processing');
// Stop recorder and collect blob
audioBlob = await new Promise((resolve) => {
mediaRecorder.onstop = () => {
mediaRecorder.stream.getTracks().forEach(t => t.stop());
resolve(new Blob(audioChunks, { type: mediaRecorder.mimeType }));
};
mediaRecorder.stop();
});
// Show audio preview
if (audioUrl) URL.revokeObjectURL(audioUrl);
audioUrl = URL.createObjectURL(audioBlob);
audioPlayer.src = audioUrl;
audioPreview.classList.add('visible');
// Show live transcript while we process (if we have one)
transcriptArea.classList.add('visible');
if (capturedLiveTranscript) {
transcriptText.textContent = capturedLiveTranscript;
showStatusBar('Improving transcript...', 'loading');
} else {
transcriptText.innerHTML = '<span class="placeholder">Transcribing...</span>';
showStatusBar('Uploading & transcribing...', 'loading');
}
// Upload audio file
const token = await getToken();
const settings = await getSettings();
try {
const uploadForm = new FormData();
uploadForm.append('file', audioBlob, 'voice-note.webm');
const uploadRes = await fetch(`${settings.host}/api/uploads`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: uploadForm,
});
if (!uploadRes.ok) throw new Error('Upload failed');
const uploadResult = await uploadRes.json();
uploadedFileUrl = uploadResult.url;
uploadedMimeType = uploadResult.mimeType;
uploadedFileSize = uploadResult.size;
// --- Three-tier transcription cascade ---
// Tier 1: Batch API (Whisper on server — highest quality)
let bestTranscript = '';
try {
showStatusBar('Transcribing via server...', 'loading');
const transcribeForm = new FormData();
transcribeForm.append('audio', audioBlob, 'voice-note.webm');
const transcribeRes = await fetch(`${settings.host}/api/voice/transcribe`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: transcribeForm,
});
if (transcribeRes.ok) {
const transcribeResult = await transcribeRes.json();
bestTranscript = transcribeResult.text || '';
}
} catch {
console.warn('Tier 1 (batch API) unavailable');
}
// Tier 2: Live transcript from Web Speech API (already captured)
if (!bestTranscript && capturedLiveTranscript) {
bestTranscript = capturedLiveTranscript;
}
// Tier 3: Offline Parakeet.js (NVIDIA, runs in browser)
if (!bestTranscript && window.ParakeetOffline) {
try {
showStatusBar('Transcribing offline (Parakeet)...', 'loading');
bestTranscript = await window.ParakeetOffline.transcribeOffline(audioBlob, (p) => {
showParakeetProgress(p);
});
hideParakeetProgress();
} catch (offlineErr) {
console.warn('Tier 3 (Parakeet offline) failed:', offlineErr);
hideParakeetProgress();
}
}
transcript = bestTranscript;
// Show transcript (editable)
if (transcript) {
transcriptText.textContent = transcript;
} else {
transcriptText.innerHTML = '<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) {
// On upload error, try offline transcription directly
let fallbackTranscript = capturedLiveTranscript || '';
if (!fallbackTranscript && window.ParakeetOffline) {
try {
showStatusBar('Upload failed, transcribing offline...', 'loading');
fallbackTranscript = await window.ParakeetOffline.transcribeOffline(audioBlob, (p) => {
showParakeetProgress(p);
});
hideParakeetProgress();
} catch {
hideParakeetProgress();
}
}
transcript = fallbackTranscript;
if (transcript) {
transcriptText.textContent = transcript;
}
showStatusBar(`Error: ${err.message}`, 'error');
state = 'done';
setStatusLabel('Error', 'idle');
postActions.style.display = 'flex';
}
}
function toggleRecording() {
if (state === 'idle' || state === 'done') {
startRecording();
} else if (state === 'recording') {
stopRecording();
}
// Ignore clicks while processing
}
// --- Save to rNotes ---
async function saveToRNotes() {
saveBtn.disabled = true;
showStatusBar('Saving to rNotes...', 'loading');
const token = await getToken();
const settings = await getSettings();
// Get current transcript text (user may have edited it)
const editedTranscript = transcriptText.textContent.trim();
const isPlaceholder = transcriptText.querySelector('.placeholder') !== null;
const finalTranscript = isPlaceholder ? '' : editedTranscript;
const now = new Date();
const timeStr = now.toLocaleString('en-US', {
month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
hour12: true
});
const body = {
title: `Voice note - ${timeStr}`,
content: finalTranscript
? `<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.notebookId = notebookId;
try {
const res = await fetch(`${settings.host}/api/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text}`);
}
showStatusBar('Saved to rNotes!', 'success');
// Notify
chrome.runtime.sendMessage({
type: 'notify',
title: 'Voice Note Saved',
message: `${formatTime(duration)} recording saved to rNotes`,
});
// Reset after short delay
setTimeout(resetState, 1500);
} catch (err) {
showStatusBar(`Save failed: ${err.message}`, 'error');
} finally {
saveBtn.disabled = false;
}
}
// --- Copy to clipboard ---
async function copyTranscript() {
const text = transcriptText.textContent.trim();
if (!text || transcriptText.querySelector('.placeholder')) {
showStatusBar('No transcript to copy', 'error');
return;
}
try {
await navigator.clipboard.writeText(text);
showStatusBar('Copied to clipboard', 'success');
} catch {
showStatusBar('Copy failed', 'error');
}
}
// --- Discard ---
function resetState() {
state = 'idle';
mediaRecorder = null;
audioChunks = [];
audioBlob = null;
transcript = '';
liveTranscript = '';
uploadedFileUrl = '';
uploadedMimeType = '';
uploadedFileSize = 0;
duration = 0;
stopLiveTranscription();
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
audioUrl = null;
}
timerEl.textContent = '00:00';
timerEl.classList.remove('recording');
recBtn.classList.remove('recording');
setStatusLabel('Ready', 'idle');
postActions.style.display = 'none';
audioPreview.classList.remove('visible');
transcriptArea.classList.remove('visible');
hideParakeetProgress();
statusBar.className = 'status-bar';
}
// --- Keyboard shortcuts ---
document.addEventListener('keydown', (e) => {
// Space bar: toggle recording (unless editing transcript)
if (e.code === 'Space' && document.activeElement !== transcriptText) {
e.preventDefault();
toggleRecording();
}
// Escape: close window
if (e.code === 'Escape') {
window.close();
}
// Ctrl+Enter: save (when in done state)
if ((e.ctrlKey || e.metaKey) && e.code === 'Enter' && state === 'done') {
e.preventDefault();
saveToRNotes();
}
});
// Clear placeholder on focus
transcriptText.addEventListener('focus', () => {
const ph = transcriptText.querySelector('.placeholder');
if (ph) transcriptText.textContent = '';
});
// --- Event listeners ---
recBtn.addEventListener('click', toggleRecording);
saveBtn.addEventListener('click', saveToRNotes);
discardBtn.addEventListener('click', resetState);
copyBtn.addEventListener('click', copyTranscript);
closeBtn.addEventListener('click', () => window.close());
// --- Init ---
async function init() {
const token = await getToken();
const claims = token ? decodeToken(token) : null;
if (!claims) {
authWarning.style.display = 'block';
recBtn.style.opacity = '0.3';
recBtn.style.pointerEvents = 'none';
return;
}
authWarning.style.display = 'none';
await loadNotebooks();
}
document.addEventListener('DOMContentLoaded', init);

View File

@ -1,49 +0,0 @@
/**
* TipTap mark extension for inline comments.
*
* Applies a highlight to selected text and associates it with a comment thread
* stored in Automerge. The mark position is synced via Yjs (as part of the doc content),
* while the thread data (messages, resolved state) lives in Automerge.
*/
import { Mark, mergeAttributes } from '@tiptap/core';
export const CommentMark = Mark.create({
name: 'comment',
addAttributes() {
return {
threadId: { default: null },
resolved: { default: false },
};
},
parseHTML() {
return [
{
tag: 'span[data-thread-id]',
getAttrs: (el) => {
const element = el as HTMLElement;
return {
threadId: element.getAttribute('data-thread-id'),
resolved: element.getAttribute('data-resolved') === 'true',
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes(
{
class: `comment-highlight${HTMLAttributes.resolved ? ' resolved' : ''}`,
'data-thread-id': HTMLAttributes.threadId,
'data-resolved': HTMLAttributes.resolved ? 'true' : 'false',
},
),
0,
];
},
});

View File

@ -1,918 +0,0 @@
/**
* <notes-comment-panel> Right sidebar panel for viewing/managing inline comments.
*
* Shows threaded comments anchored to highlighted text in the editor.
* Comment thread data is stored in Automerge, while the highlight mark
* position is stored in Yjs (part of the document content).
*
* Supports: demo mode (in-memory), emoji reactions, date reminders.
*/
import type { Editor } from '@tiptap/core';
import type { DocumentId } from '../../../shared/local-first/document';
import { getModuleApiBase } from "../../../shared/url-helpers";
interface CommentMessage {
id: string;
authorId: string;
authorName: string;
text: string;
createdAt: number;
}
interface CommentThread {
id: string;
anchor: string;
resolved: boolean;
messages: CommentMessage[];
createdAt: number;
reactions?: Record<string, string[]>;
reminderAt?: number;
reminderId?: string;
}
interface NotebookDoc {
items: Record<string, {
comments?: Record<string, CommentThread>;
[key: string]: any;
}>;
[key: string]: any;
}
const REACTION_EMOJIS = ['👍', '👎', '❤️', '🎉', '😂', '😮', '🔥'];
interface SuggestionEntry {
id: string;
type: 'insert' | 'delete';
text: string;
authorId: string;
authorName: string;
createdAt: number;
}
class NotesCommentPanel extends HTMLElement {
private shadow: ShadowRoot;
private _noteId: string | null = null;
private _doc: any = null;
private _subscribedDocId: string | null = null;
private _activeThreadId: string | null = null;
private _editor: Editor | null = null;
private _demoThreads: Record<string, CommentThread> | null = null;
private _space = '';
private _suggestions: SuggestionEntry[] = [];
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
set noteId(v: string | null) { this._noteId = v; this.render(); }
set doc(v: any) { this._doc = v; this.render(); }
set subscribedDocId(v: string | null) { this._subscribedDocId = v; }
set activeThreadId(v: string | null) { this._activeThreadId = v; this.render(); }
set editor(v: Editor | null) { this._editor = v; }
set space(v: string) { this._space = v; }
set demoThreads(v: Record<string, CommentThread> | null) {
this._demoThreads = v;
this.render();
}
set suggestions(v: SuggestionEntry[]) {
this._suggestions = v;
this.render();
}
private get isDemo(): boolean {
return this._space === 'demo';
}
private getSessionInfo(): { authorName: string; authorId: string } {
try {
const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
const c = sess?.claims;
return {
authorName: c?.username || c?.displayName || sess?.username || 'Anonymous',
authorId: c?.sub || sess?.userId || 'anon',
};
} catch {
return { authorName: 'Anonymous', authorId: 'anon' };
}
}
private getThreads(): CommentThread[] {
// Demo threads take priority
if (this._demoThreads) {
return Object.values(this._demoThreads).sort((a, b) => a.createdAt - b.createdAt);
}
if (!this._doc || !this._noteId) return [];
const item = this._doc.items?.[this._noteId];
if (!item?.comments) return [];
return Object.values(item.comments as Record<string, CommentThread>)
.sort((a, b) => a.createdAt - b.createdAt);
}
private dispatchDemoMutation() {
if (!this._demoThreads || !this._noteId) return;
this.dispatchEvent(new CustomEvent('comment-demo-mutation', {
detail: { noteId: this._noteId, threads: { ...this._demoThreads } },
bubbles: true,
composed: true,
}));
}
private render() {
const threads = this.getThreads();
const suggestions = this._suggestions || [];
if (threads.length === 0 && suggestions.length === 0 && !this._activeThreadId) {
this.shadow.innerHTML = '';
return;
}
const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
const timeAgo = (ts: number) => {
const diff = Date.now() - ts;
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return `${Math.floor(diff / 86400000)}d ago`;
};
const formatDate = (ts: number) => new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
const { authorId: currentUserId, authorName: currentUserName } = this.getSessionInfo();
const initials = (name: string) => name.split(/\s+/).map(w => w[0] || '').join('').slice(0, 2).toUpperCase() || '?';
const avatarColor = (id: string) => {
let h = 0;
for (let i = 0; i < id.length; i++) h = id.charCodeAt(i) + ((h << 5) - h);
return `hsl(${Math.abs(h) % 360}, 55%, 55%)`;
};
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; }
*, *::before, *::after { box-sizing: border-box; }
.panel { padding: 8px 10px; overflow-y: auto; max-height: calc(100vh - 180px); }
.panel.collapsed .thread, .panel.collapsed .panel-empty { display: none; }
.panel-title {
font-weight: 600; font-size: 13px; padding: 8px 0;
color: var(--rs-text-secondary, #666);
display: flex; justify-content: space-between; align-items: center;
border-bottom: 1px solid var(--rs-border-subtle, #f0f0f0);
margin-bottom: 8px;
cursor: pointer; user-select: none;
}
.panel-title:hover { color: var(--rs-text-primary, #111); }
.collapse-btn {
border: none; background: none; cursor: pointer; padding: 2px 4px;
color: var(--rs-text-muted, #999); font-size: 12px; line-height: 1;
border-radius: 4px; transition: transform 0.15s;
}
.collapse-btn:hover { background: var(--rs-bg-hover, #f5f5f5); }
.panel.collapsed .collapse-btn { transform: rotate(-90deg); }
.thread {
margin-bottom: 8px;
padding: 12px;
border-radius: 8px;
background: var(--rs-bg-surface, #fff);
border: 1px solid var(--rs-border-subtle, #e8e8e8);
cursor: pointer;
transition: all 0.15s;
border-left: 3px solid transparent;
}
.thread:hover { box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.thread.active {
border-left-color: #fbbc04;
box-shadow: 0 1px 6px rgba(251, 188, 4, 0.2);
background: color-mix(in srgb, #fbbc04 4%, var(--rs-bg-surface, #fff));
}
.thread.resolved { opacity: 0.5; }
.thread.resolved:hover { opacity: 0.7; }
/* Author row with avatar */
.thread-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.avatar {
width: 26px; height: 26px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; color: #fff; flex-shrink: 0;
}
.header-info { flex: 1; min-width: 0; }
.thread-author { font-weight: 600; font-size: 13px; color: var(--rs-text-primary, #111); }
.thread-time { color: var(--rs-text-muted, #999); font-size: 11px; margin-left: 6px; }
/* Messages */
.message { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); }
.message-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.message-avatar { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 600; color: #fff; flex-shrink: 0; }
.message-author { font-weight: 500; font-size: 12px; color: var(--rs-text-secondary, #666); }
.message-time { font-size: 10px; color: var(--rs-text-muted, #aaa); }
.message-text { margin-top: 2px; color: var(--rs-text-primary, #111); line-height: 1.5; padding-left: 26px; }
.first-message-text { color: var(--rs-text-primary, #111); line-height: 1.5; }
/* Reply form — Google Docs style */
.reply-form { margin-top: 10px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); padding-top: 10px; }
.reply-input {
width: 100%; padding: 6px 8px; border: 1px solid var(--rs-input-border, #ddd);
border-radius: 6px; font-size: 12px; font-family: inherit;
background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111);
}
.reply-input:focus { border-color: #1a73e8; outline: none; box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15); }
.reply-input::placeholder { color: var(--rs-text-muted, #999); }
.reply-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
.reply-btn {
padding: 6px 14px; border: none; background: #1a73e8; color: #fff;
border-radius: 6px; font-size: 12px; cursor: pointer; font-weight: 500;
}
.reply-btn:hover { background: #1557b0; }
.reply-cancel-btn {
padding: 6px 14px; border: none; background: transparent; color: var(--rs-text-secondary, #666);
border-radius: 6px; font-size: 12px; cursor: pointer;
}
.reply-cancel-btn:hover { background: var(--rs-bg-hover, #f5f5f5); }
/* Thread actions */
.thread-actions { display: flex; gap: 2px; margin-top: 8px; justify-content: flex-end; }
.thread-action {
padding: 4px 8px; border: none; background: none;
color: var(--rs-text-muted, #999); cursor: pointer;
font-size: 11px; border-radius: 4px;
}
.thread-action:hover { background: var(--rs-bg-hover, #f5f5f5); color: var(--rs-text-primary, #111); }
.thread-action.resolve-btn { color: #1a73e8; }
.thread-action.resolve-btn:hover { background: color-mix(in srgb, #1a73e8 8%, transparent); }
/* Reactions */
.reactions-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; align-items: center; }
.reaction-pill { display: inline-flex; align-items: center; gap: 2px; padding: 2px 6px; border-radius: 12px; border: 1px solid var(--rs-border-subtle, #e0e0e0); background: var(--rs-bg-surface, #fff); font-size: 12px; cursor: pointer; transition: all 0.15s; user-select: none; }
.reaction-pill:hover { border-color: #1a73e8; }
.reaction-pill.active { border-color: #1a73e8; background: color-mix(in srgb, #1a73e8 10%, transparent); }
.reaction-pill .count { font-size: 11px; color: var(--rs-text-secondary, #666); }
.reaction-add { padding: 2px 6px; border-radius: 12px; border: 1px dashed var(--rs-border-subtle, #ddd); background: none; font-size: 12px; cursor: pointer; color: var(--rs-text-muted, #999); }
.reaction-add:hover { border-color: #1a73e8; color: var(--rs-text-primary, #111); }
.emoji-picker { display: none; flex-wrap: wrap; gap: 2px; padding: 4px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border, #e5e7eb); border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); margin-top: 4px; }
.emoji-picker.open { display: flex; }
.emoji-pick { padding: 4px 6px; border: none; background: none; font-size: 16px; cursor: pointer; border-radius: 4px; }
.emoji-pick:hover { background: var(--rs-bg-hover, #f5f5f5); }
/* Reminders */
.reminder-row { margin-top: 6px; display: flex; align-items: center; gap: 6px; font-size: 12px; }
.reminder-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 12px; background: color-mix(in srgb, var(--rs-warning, #f59e0b) 15%, transparent); color: var(--rs-text-primary, #111); font-size: 11px; }
.reminder-btn { padding: 2px 8px; border: 1px solid var(--rs-border-subtle, #ddd); border-radius: 12px; background: none; font-size: 11px; cursor: pointer; color: var(--rs-text-secondary, #666); }
.reminder-btn:hover { border-color: #1a73e8; }
.reminder-clear { padding: 1px 4px; border: none; background: none; font-size: 10px; cursor: pointer; color: var(--rs-text-muted, #999); }
.reminder-date-input { padding: 2px 6px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 11px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
/* ── Suggestion Cards ── */
.suggestion-section-title {
font-weight: 600; font-size: 12px; color: #b45309;
padding: 6px 0 4px; margin-bottom: 4px;
border-bottom: 1px solid color-mix(in srgb, #f59e0b 20%, var(--rs-border-subtle, #f0f0f0));
}
.suggestion-card {
margin-bottom: 8px; padding: 10px 12px;
border-radius: 8px; border: 1px solid color-mix(in srgb, #f59e0b 25%, var(--rs-border-subtle, #e8e8e8));
background: color-mix(in srgb, #f59e0b 4%, var(--rs-bg-surface, #fff));
border-left: 3px solid #f59e0b;
}
.suggestion-card .sg-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.suggestion-card .sg-avatar {
width: 22px; height: 22px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 600; color: #fff; flex-shrink: 0;
}
.suggestion-card .sg-author { font-weight: 600; font-size: 12px; color: var(--rs-text-primary, #111); }
.suggestion-card .sg-time { font-size: 10px; color: var(--rs-text-muted, #aaa); margin-left: auto; }
.suggestion-card .sg-type {
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px;
}
.sg-type-insert { background: rgba(22, 163, 74, 0.1); color: #137333; }
.sg-type-delete { background: rgba(220, 38, 38, 0.1); color: #c5221f; }
.suggestion-card .sg-text {
font-size: 13px; line-height: 1.5; padding: 4px 6px;
border-radius: 4px; margin-bottom: 8px;
word-break: break-word; overflow-wrap: anywhere;
}
.sg-text-insert { background: rgba(22, 163, 74, 0.08); color: var(--rs-text-primary, #111); }
.sg-text-delete { background: rgba(220, 38, 38, 0.06); color: var(--rs-text-muted, #666); text-decoration: line-through; }
.suggestion-card .sg-actions { display: flex; gap: 6px; justify-content: flex-end; }
.sg-accept, .sg-reject {
padding: 4px 12px; border-radius: 6px; font-size: 12px; font-weight: 500;
cursor: pointer; border: 1px solid; transition: all 0.15s;
}
.sg-accept { color: #137333; border-color: #137333; background: rgba(22, 163, 74, 0.06); }
.sg-accept:hover { background: rgba(22, 163, 74, 0.15); }
.sg-reject { color: #c5221f; border-color: #c5221f; background: rgba(220, 38, 38, 0.04); }
.sg-reject:hover { background: rgba(220, 38, 38, 0.12); }
/* New comment input — shown when thread has no messages */
.new-comment-form { margin-top: 4px; }
.new-comment-input {
width: 100%; padding: 8px 10px; border: 1px solid var(--rs-input-border, #ddd);
border-radius: 8px; font-size: 13px; font-family: inherit;
background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111);
resize: vertical; min-height: 52px; max-height: 150px;
}
.new-comment-input:focus { border-color: #1a73e8; outline: none; box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15); }
.new-comment-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
.first-message-text { word-break: break-word; overflow-wrap: anywhere; }
.message-text { word-break: break-word; overflow-wrap: anywhere; }
@media (max-width: 480px) {
.panel { max-height: none; height: 100%; }
.thread-action { padding: 8px 10px; font-size: 12px; }
.reply-btn, .reply-cancel-btn { padding: 8px 16px; }
.reply-input { padding: 8px 10px; font-size: 14px; }
.emoji-pick { padding: 6px 8px; font-size: 18px; }
.new-comment-input { min-height: 44px; max-height: 100px; font-size: 14px; }
}
</style>
<div class="panel" id="comment-panel">
<div class="panel-title" data-action="toggle-collapse">
<span>${suggestions.length > 0 ? `Suggestions (${suggestions.length})` : ''} ${threads.length > 0 ? `Comments (${threads.filter(t => !t.resolved).length})` : ''}</span>
<button class="collapse-btn" title="Minimize">&#9660;</button>
</div>
${suggestions.length > 0 ? `
${suggestions.map(s => `
<div class="suggestion-card" data-suggestion-id="${s.id}">
<div class="sg-header">
<div class="sg-avatar" style="background: ${avatarColor(s.authorId)}">${initials(s.authorName)}</div>
<span class="sg-author">${esc(s.authorName)}</span>
<span class="sg-type ${s.type === 'insert' ? 'sg-type-insert' : 'sg-type-delete'}">${s.type === 'insert' ? 'Added' : 'Deleted'}</span>
<span class="sg-time">${timeAgo(s.createdAt)}</span>
</div>
<div class="sg-text ${s.type === 'insert' ? 'sg-text-insert' : 'sg-text-delete'}">${esc(s.text)}</div>
<div class="sg-actions">
<button class="sg-accept" data-accept-suggestion="${s.id}">Accept</button>
<button class="sg-reject" data-reject-suggestion="${s.id}">Reject</button>
</div>
</div>
`).join('')}
` : ''}
${threads.map(thread => {
const reactions = thread.reactions || {};
const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0);
const isActive = thread.id === this._activeThreadId;
const hasMessages = thread.messages.length > 0;
const firstMsg = thread.messages[0];
const authorName = firstMsg?.authorName || currentUserName;
const authorId = firstMsg?.authorId || currentUserId;
return `
<div class="thread ${isActive ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}">
<div class="thread-header">
<div class="avatar" style="background: ${avatarColor(authorId)}">${initials(authorName)}</div>
<div class="header-info">
<span class="thread-author">${esc(authorName)}</span>
<span class="thread-time">${timeAgo(thread.createdAt)}</span>
</div>
</div>
${hasMessages ? `
<div class="first-message-text">${esc(firstMsg.text)}</div>
${thread.messages.slice(1).map(msg => `
<div class="message">
<div class="message-header">
<div class="message-avatar" style="background: ${avatarColor(msg.authorId)}">${initials(msg.authorName)}</div>
<span class="message-author">${esc(msg.authorName)}</span>
<span class="message-time">${timeAgo(msg.createdAt)}</span>
</div>
<div class="message-text">${esc(msg.text)}</div>
</div>
`).join('')}
` : `
<div class="new-comment-form">
<textarea class="new-comment-input" placeholder="Add your comment..." data-new-thread="${thread.id}" autofocus></textarea>
<div class="new-comment-actions">
<button class="reply-cancel-btn" data-cancel-new="${thread.id}">Cancel</button>
<button class="reply-btn" data-submit-new="${thread.id}">Comment</button>
</div>
</div>
`}
${hasMessages && reactionEntries.length > 0 ? `
<div class="reactions-row">
${reactionEntries.map(([emoji, users]) => `
<button class="reaction-pill ${users.includes(currentUserId) ? 'active' : ''}" data-react-thread="${thread.id}" data-react-emoji="${emoji}">${emoji} <span class="count">${users.length}</span></button>
`).join('')}
<button class="reaction-add" data-react-add="${thread.id}">+</button>
</div>
<div class="emoji-picker" data-picker="${thread.id}">
${REACTION_EMOJIS.map(e => `<button class="emoji-pick" data-pick-thread="${thread.id}" data-pick-emoji="${e}">${e}</button>`).join('')}
</div>
` : ''}
${hasMessages && thread.reminderAt ? `
<div class="reminder-row">
<span class="reminder-badge">&#9200; ${formatDate(thread.reminderAt)}</span>
<button class="reminder-clear" data-remind-clear="${thread.id}">&#10005;</button>
</div>
` : ''}
${hasMessages ? `
<div class="reply-form">
<input class="reply-input" placeholder="Reply..." data-thread="${thread.id}">
<div class="reply-actions">
<button class="reply-btn" data-reply="${thread.id}">Reply</button>
</div>
</div>
` : ''}
<div class="thread-actions">
${hasMessages ? `
<button class="reaction-add" data-react-add="${thread.id}" title="React" style="font-size:13px">+</button>
<button class="thread-action" data-remind-set="${thread.id}" title="Set reminder">&#9200;</button>
<input type="date" class="reminder-date-input" data-remind-input="${thread.id}" style="display:none">
` : ''}
<button class="thread-action resolve-btn" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button>
<button class="thread-action" data-delete="${thread.id}">Delete</button>
</div>
</div>`;
}).join('')}
</div>
`;
this.wireEvents();
// Auto-focus new comment textarea
requestAnimationFrame(() => {
const newInput = this.shadow.querySelector('.new-comment-input') as HTMLTextAreaElement;
if (newInput) newInput.focus();
});
}
private wireEvents() {
// Suggestion accept/reject
this.shadow.querySelectorAll('[data-accept-suggestion]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const id = (btn as HTMLElement).dataset.acceptSuggestion;
if (id) this.dispatchEvent(new CustomEvent('suggestion-accept', { detail: { suggestionId: id }, bubbles: true, composed: true }));
});
});
this.shadow.querySelectorAll('[data-reject-suggestion]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const id = (btn as HTMLElement).dataset.rejectSuggestion;
if (id) this.dispatchEvent(new CustomEvent('suggestion-reject', { detail: { suggestionId: id }, bubbles: true, composed: true }));
});
});
// Click suggestion card to scroll editor to it
this.shadow.querySelectorAll('.suggestion-card[data-suggestion-id]').forEach(el => {
el.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('button')) return;
const id = (el as HTMLElement).dataset.suggestionId;
if (!id || !this._editor) return;
this._editor.state.doc.descendants((node, pos) => {
if (!node.isText) return;
const mark = node.marks.find(m =>
(m.type.name === 'suggestionInsert' || m.type.name === 'suggestionDelete') &&
m.attrs.suggestionId === id
);
if (mark) {
this._editor!.commands.setTextSelection(pos);
this._editor!.commands.scrollIntoView();
return false;
}
});
});
});
// Collapse/expand panel
const collapseBtn = this.shadow.querySelector('[data-action="toggle-collapse"]');
if (collapseBtn) {
collapseBtn.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('.thread, input, textarea, button:not(.collapse-btn)')) return;
const panel = this.shadow.getElementById('comment-panel');
if (panel) panel.classList.toggle('collapsed');
});
}
// Click thread to scroll editor to it
this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => {
el.addEventListener('click', (e) => {
// Don't handle clicks on inputs/buttons/textareas
const target = e.target as HTMLElement;
if (target.closest('input, textarea, button')) return;
const threadId = (el as HTMLElement).dataset.thread;
if (!threadId || !this._editor) return;
this._activeThreadId = threadId;
this._editor.state.doc.descendants((node, pos) => {
if (!node.isText) return;
const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
if (mark) {
this._editor!.commands.setTextSelection(pos);
this._editor!.commands.scrollIntoView();
return false;
}
});
this.render();
});
});
// New comment submit (thread with no messages yet)
this.shadow.querySelectorAll('[data-submit-new]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.submitNew;
if (!threadId) return;
const textarea = this.shadow.querySelector(`textarea[data-new-thread="${threadId}"]`) as HTMLTextAreaElement;
const text = textarea?.value?.trim();
if (!text) return;
this.addReply(threadId, text);
});
});
// New comment cancel — delete the empty thread
this.shadow.querySelectorAll('[data-cancel-new]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.cancelNew;
if (threadId) this.deleteThread(threadId);
});
});
// New comment textarea — Ctrl+Enter to submit, Escape to cancel
this.shadow.querySelectorAll('.new-comment-input').forEach(textarea => {
textarea.addEventListener('keydown', (e) => {
const ke = e as KeyboardEvent;
if (ke.key === 'Enter' && (ke.ctrlKey || ke.metaKey)) {
e.stopPropagation();
const threadId = (textarea as HTMLTextAreaElement).dataset.newThread;
const text = (textarea as HTMLTextAreaElement).value.trim();
if (threadId && text) this.addReply(threadId, text);
} else if (ke.key === 'Escape') {
e.stopPropagation();
const threadId = (textarea as HTMLTextAreaElement).dataset.newThread;
if (threadId) this.deleteThread(threadId);
}
});
textarea.addEventListener('click', (e) => e.stopPropagation());
});
// Reply
this.shadow.querySelectorAll('[data-reply]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.reply;
if (!threadId) return;
const input = this.shadow.querySelector(`input[data-thread="${threadId}"]`) as HTMLInputElement;
const text = input?.value?.trim();
if (!text) return;
this.addReply(threadId, text);
input.value = '';
});
});
// Reply on Enter
this.shadow.querySelectorAll('.reply-input').forEach(input => {
input.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Enter') {
e.stopPropagation();
const threadId = (input as HTMLInputElement).dataset.thread;
const text = (input as HTMLInputElement).value.trim();
if (threadId && text) {
this.addReply(threadId, text);
(input as HTMLInputElement).value = '';
}
}
});
input.addEventListener('click', (e) => e.stopPropagation());
});
// Resolve / re-open
this.shadow.querySelectorAll('[data-resolve]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.resolve;
if (threadId) this.toggleResolve(threadId);
});
});
// Delete
this.shadow.querySelectorAll('[data-delete]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.delete;
if (threadId) this.deleteThread(threadId);
});
});
// Reaction pill toggle (existing reaction)
this.shadow.querySelectorAll('[data-react-thread]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const el = btn as HTMLElement;
this.toggleReaction(el.dataset.reactThread!, el.dataset.reactEmoji!);
});
});
// Reaction add "+" button — toggle emoji picker
this.shadow.querySelectorAll('[data-react-add]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.reactAdd!;
const picker = this.shadow.querySelector(`[data-picker="${threadId}"]`);
if (picker) picker.classList.toggle('open');
});
});
// Emoji picker buttons
this.shadow.querySelectorAll('[data-pick-thread]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const el = btn as HTMLElement;
this.toggleReaction(el.dataset.pickThread!, el.dataset.pickEmoji!);
// Close picker
const picker = this.shadow.querySelector(`[data-picker="${el.dataset.pickThread}"]`);
if (picker) picker.classList.remove('open');
});
});
// Reminder "set" button
this.shadow.querySelectorAll('[data-remind-set]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.remindSet!;
const input = this.shadow.querySelector(`[data-remind-input="${threadId}"]`) as HTMLInputElement;
if (input) {
input.style.display = input.style.display === 'none' ? 'inline-block' : 'none';
if (input.style.display !== 'none') input.focus();
}
});
});
// Reminder date change
this.shadow.querySelectorAll('[data-remind-input]').forEach(input => {
input.addEventListener('click', (e) => e.stopPropagation());
input.addEventListener('change', (e) => {
e.stopPropagation();
const threadId = (input as HTMLInputElement).dataset.remindInput!;
const val = (input as HTMLInputElement).value;
if (val) this.setReminder(threadId, new Date(val + 'T09:00:00').getTime());
});
});
// Reminder clear
this.shadow.querySelectorAll('[data-remind-clear]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.remindClear!;
this.clearReminder(threadId);
});
});
}
private addReply(threadId: string, text: string) {
const { authorName, authorId } = this.getSessionInfo();
const msg: CommentMessage = {
id: `m_${Date.now()}`,
authorId,
authorName,
text,
createdAt: Date.now(),
};
if (this._demoThreads) {
const thread = this._demoThreads[threadId];
if (!thread) return;
if (!thread.messages) thread.messages = [];
thread.messages.push(msg);
this.dispatchDemoMutation();
this.render();
return;
}
if (!this._noteId || !this._subscribedDocId) return;
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
const noteId = this._noteId;
runtime.change(this._subscribedDocId as DocumentId, 'Add comment reply', (d: NotebookDoc) => {
const item = d.items[noteId];
if (!item?.comments?.[threadId]) return;
const thread = item.comments[threadId] as any;
if (!thread.messages) thread.messages = [];
thread.messages.push(msg);
});
this._doc = runtime.get(this._subscribedDocId as DocumentId);
this.render();
}
private toggleReaction(threadId: string, emoji: string) {
const { authorId } = this.getSessionInfo();
if (this._demoThreads) {
const thread = this._demoThreads[threadId];
if (!thread) return;
if (!thread.reactions) thread.reactions = {};
if (!thread.reactions[emoji]) thread.reactions[emoji] = [];
const idx = thread.reactions[emoji].indexOf(authorId);
if (idx >= 0) thread.reactions[emoji].splice(idx, 1);
else thread.reactions[emoji].push(authorId);
this.dispatchDemoMutation();
this.render();
return;
}
if (!this._noteId || !this._subscribedDocId) return;
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
const noteId = this._noteId;
runtime.change(this._subscribedDocId as DocumentId, 'Toggle reaction', (d: NotebookDoc) => {
const item = d.items[noteId];
if (!item?.comments?.[threadId]) return;
const thread = item.comments[threadId] as any;
if (!thread.reactions) thread.reactions = {};
if (!thread.reactions[emoji]) thread.reactions[emoji] = [];
const users: string[] = thread.reactions[emoji];
const idx = users.indexOf(authorId);
if (idx >= 0) users.splice(idx, 1);
else users.push(authorId);
});
this._doc = runtime.get(this._subscribedDocId as DocumentId);
this.render();
}
private async setReminder(threadId: string, reminderAt: number) {
// Set reminder on thread
let reminderId: string | undefined;
// Try creating a reminder via rSchedule API (non-demo only)
if (!this.isDemo && this._space) {
try {
const res = await fetch(`${getModuleApiBase("rschedule")}/api/reminders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
body: JSON.stringify({
title: `Comment reminder`,
remindAt: new Date(reminderAt).toISOString(),
allDay: true,
sourceModule: 'rnotes',
sourceEntityId: threadId,
}),
});
if (res.ok) {
const data = await res.json();
reminderId = data.id;
}
} catch {}
}
if (this._demoThreads) {
const thread = this._demoThreads[threadId];
if (thread) {
thread.reminderAt = reminderAt;
if (reminderId) thread.reminderId = reminderId;
}
this.dispatchDemoMutation();
this.render();
return;
}
if (!this._noteId || !this._subscribedDocId) return;
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
const noteId = this._noteId;
runtime.change(this._subscribedDocId as DocumentId, 'Set comment reminder', (d: NotebookDoc) => {
const item = d.items[noteId];
if (!item?.comments?.[threadId]) return;
const thread = item.comments[threadId] as any;
thread.reminderAt = reminderAt;
if (reminderId) thread.reminderId = reminderId;
});
this._doc = runtime.get(this._subscribedDocId as DocumentId);
this.render();
}
private async clearReminder(threadId: string) {
// Get existing reminderId before clearing
const threads = this.getThreads();
const thread = threads.find(t => t.id === threadId);
const reminderId = thread?.reminderId;
// Delete from rSchedule if exists
if (reminderId && !this.isDemo && this._space) {
try {
await fetch(`${getModuleApiBase("rschedule")}/api/reminders/${reminderId}`, {
method: 'DELETE',
headers: this.authHeaders(),
});
} catch {}
}
if (this._demoThreads) {
const t = this._demoThreads[threadId];
if (t) { delete t.reminderAt; delete t.reminderId; }
this.dispatchDemoMutation();
this.render();
return;
}
if (!this._noteId || !this._subscribedDocId) return;
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
const noteId = this._noteId;
runtime.change(this._subscribedDocId as DocumentId, 'Clear comment reminder', (d: NotebookDoc) => {
const item = d.items[noteId];
if (!item?.comments?.[threadId]) return;
const t = item.comments[threadId] as any;
delete t.reminderAt;
delete t.reminderId;
});
this._doc = runtime.get(this._subscribedDocId as DocumentId);
this.render();
}
private authHeaders(): Record<string, string> {
try {
const s = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
if (s?.accessToken) return { 'Authorization': 'Bearer ' + s.accessToken };
} catch {}
return {};
}
private toggleResolve(threadId: string) {
if (this._demoThreads) {
const thread = this._demoThreads[threadId];
if (thread) thread.resolved = !thread.resolved;
this.dispatchDemoMutation();
// Update editor mark
this.updateEditorResolveMark(threadId, this._demoThreads[threadId]?.resolved ?? false);
this.render();
return;
}
if (!this._noteId || !this._subscribedDocId) return;
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
const noteId = this._noteId;
runtime.change(this._subscribedDocId as DocumentId, 'Toggle comment resolve', (d: NotebookDoc) => {
const item = d.items[noteId];
if (!item?.comments?.[threadId]) return;
(item.comments[threadId] as any).resolved = !(item.comments[threadId] as any).resolved;
});
this._doc = runtime.get(this._subscribedDocId as DocumentId);
const thread = this._doc?.items?.[this._noteId]?.comments?.[threadId];
if (thread) this.updateEditorResolveMark(threadId, thread.resolved);
this.render();
}
private updateEditorResolveMark(threadId: string, resolved: boolean) {
if (!this._editor) return;
this._editor.state.doc.descendants((node, pos) => {
if (!node.isText) return;
const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
if (mark) {
const { tr } = this._editor!.state;
tr.removeMark(pos, pos + node.nodeSize, mark);
tr.addMark(pos, pos + node.nodeSize,
this._editor!.schema.marks.comment.create({ threadId, resolved })
);
this._editor!.view.dispatch(tr);
return false;
}
});
}
private deleteThread(threadId: string) {
if (this._demoThreads) {
delete this._demoThreads[threadId];
this.dispatchDemoMutation();
this.removeEditorCommentMark(threadId);
if (this._activeThreadId === threadId) this._activeThreadId = null;
this.render();
return;
}
if (!this._noteId || !this._subscribedDocId) return;
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
const noteId = this._noteId;
runtime.change(this._subscribedDocId as DocumentId, 'Delete comment thread', (d: NotebookDoc) => {
const item = d.items[noteId];
if (item?.comments?.[threadId]) {
delete (item.comments as any)[threadId];
}
});
this._doc = runtime.get(this._subscribedDocId as DocumentId);
this.removeEditorCommentMark(threadId);
if (this._activeThreadId === threadId) this._activeThreadId = null;
this.render();
}
private removeEditorCommentMark(threadId: string) {
if (!this._editor) return;
const { state } = this._editor;
const { tr } = state;
state.doc.descendants((node, pos) => {
if (!node.isText) return;
const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
if (mark) {
tr.removeMark(pos, pos + node.nodeSize, mark);
}
});
if (tr.docChanged) {
this._editor.view.dispatch(tr);
}
}
}
customElements.define('notes-comment-panel', NotesCommentPanel);

File diff suppressed because it is too large Load Diff

View File

@ -1,578 +0,0 @@
/**
* <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 with Tiptap-JSON formatted
* timestamped transcript segments.
*/
import { SpeechDictation } from '../../../lib/speech-dictation';
import { transcribeOffline, isModelCached } from '../../../lib/parakeet-offline';
import type { TranscriptionProgress } from '../../../lib/parakeet-offline';
import type { TranscriptSegment } from '../../../lib/folk-transcription';
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 segments: TranscriptSegment[] = [];
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.segments = [];
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 with segment tracking
this.liveTranscript = '';
if (SpeechDictation.isSupported()) {
this.dictation = new SpeechDictation({
onInterim: (text) => {
const interimIdx = this.segments.findIndex(s => !s.isFinal);
if (interimIdx >= 0) {
this.segments[interimIdx].text = text;
} else {
this.segments.push({
id: crypto.randomUUID(),
text,
timestamp: this.elapsedSeconds,
isFinal: false,
});
}
this.renderTranscriptSegments();
},
onFinal: (text) => {
const interimIdx = this.segments.findIndex(s => !s.isFinal);
if (interimIdx >= 0) {
this.segments[interimIdx] = { ...this.segments[interimIdx], text, isFinal: true };
} else {
this.segments.push({
id: crypto.randomUUID(),
text,
timestamp: this.elapsedSeconds,
isFinal: true,
});
}
this.liveTranscript = this.segments.filter(s => s.isFinal).map(s => s.text).join(' ');
this.renderTranscriptSegments();
},
});
this.dictation.start();
}
// Start timer
this.recordingStartTime = Date.now();
this.elapsedSeconds = 0;
this.durationTimer = setInterval(() => {
this.elapsedSeconds = Math.floor((Date.now() - this.recordingStartTime) / 1000);
const timerEl = this.shadow.querySelector('.recording-timer');
if (timerEl) timerEl.textContent = this.formatTime(this.elapsedSeconds);
}, 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;
}
}
/** Targeted DOM update of transcript segments container (avoids full re-render). */
private renderTranscriptSegments() {
const container = this.shadow.querySelector('.live-transcript-segments');
if (!container) return;
const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
container.innerHTML = this.segments.map(seg => `
<div class="transcript-segment${seg.isFinal ? '' : ' interim'}">
<span class="segment-time">[${this.formatTime(seg.timestamp)}]</span>
<span class="segment-text">${esc(seg.text)}</span>
</div>
`).join('');
// Auto-scroll to bottom
container.scrollTop = container.scrollHeight;
}
/** Convert final segments to Tiptap JSON document with timestamped paragraphs. */
private segmentsToTiptapJSON(): object {
const finalSegments = this.segments.filter(s => s.isFinal);
if (finalSegments.length === 0) return { type: 'doc', content: [{ type: 'paragraph' }] };
return {
type: 'doc',
content: finalSegments.map(seg => ({
type: 'paragraph',
content: [
{ type: 'text', marks: [{ type: 'code' }], text: `[${this.formatTime(seg.timestamp)}]` },
{ type: 'text', text: ` ${seg.text}` },
],
})),
};
}
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 segments
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 */ }
// Build content: use Tiptap JSON with segments if available, else raw text
const hasFinalSegments = this.segments.some(s => s.isFinal);
const content = hasFinalSegments
? JSON.stringify(this.segmentsToTiptapJSON())
: (this.finalTranscript || '');
const contentFormat = hasFinalSegments ? 'tiptap-json' : undefined;
// 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,
content_format: contentFormat,
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.segments = [];
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.segments = [];
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>
<div class="live-transcript-segments"></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();
// Re-render segments after DOM is in place (recording state)
if (this.state === 'recording' && this.segments.length > 0) {
this.renderTranscriptSegments();
}
}
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 segments */
.live-transcript-segments {
width: 100%; max-width: 500px; max-height: 250px; overflow-y: auto;
text-align: left; padding: 8px 0;
}
.transcript-segment {
display: flex; gap: 8px; padding: 4px 12px; border-radius: 4px;
font-size: 14px; line-height: 1.6;
}
.transcript-segment.interim {
font-style: italic; color: var(--rs-text-muted);
background: var(--rs-bg-surface-raised);
}
.segment-time {
flex-shrink: 0; font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px; color: var(--rs-text-muted); padding-top: 2px;
}
.segment-text { flex: 1; }
.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);

File diff suppressed because it is too large Load Diff

View File

@ -1,353 +0,0 @@
/**
* rNotes demo client-side WebSocket controller.
*
* Connects to rSpace via DemoSync, populates note cards,
* packing list checkboxes, sidebar, and notebook header.
*/
import { DemoSync, type DemoShape } from "../../../lib/demo-sync-vanilla";
// ── Helpers ──
function shapesByType(shapes: Record<string, DemoShape>, type: string): DemoShape[] {
return Object.values(shapes).filter((s) => s.type === type);
}
function shapeByType(shapes: Record<string, DemoShape>, type: string): DemoShape | undefined {
return Object.values(shapes).find((s) => s.type === type);
}
function $(id: string): HTMLElement | null {
return document.getElementById(id);
}
// ── Simple markdown renderer ──
function renderMarkdown(text: string): string {
if (!text) return "";
const lines = text.split("\n");
const out: string[] = [];
let inCodeBlock = false;
let codeLang = "";
let codeLines: string[] = [];
let inList: "ul" | "ol" | null = null;
function flushList() {
if (inList) { out.push(inList === "ul" ? "</ul>" : "</ol>"); inList = null; }
}
function flushCode() {
if (inCodeBlock) {
const escaped = codeLines.join("\n").replace(/</g, "&lt;").replace(/>/g, "&gt;");
out.push(`<div class="rd-md-codeblock">${codeLang ? `<div class="rd-md-codeblock-lang"><span>${codeLang}</span></div>` : ""}<pre>${escaped}</pre></div>`);
inCodeBlock = false;
codeLines = [];
codeLang = "";
}
}
for (const raw of lines) {
const line = raw;
// Code fence
if (line.startsWith("```")) {
if (inCodeBlock) { flushCode(); } else { flushList(); inCodeBlock = true; codeLang = line.slice(3).trim(); }
continue;
}
if (inCodeBlock) { codeLines.push(line); continue; }
// Blank line
if (!line.trim()) { flushList(); continue; }
// Headings
if (line.startsWith("### ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(4))}</h3>`); continue; }
if (line.startsWith("## ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(3))}</h3>`); continue; }
if (line.startsWith("# ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(2))}</h3>`); continue; }
if (line.startsWith("#### ")) { flushList(); out.push(`<h4>${inlineFormat(line.slice(5))}</h4>`); continue; }
if (line.startsWith("##### ")) { flushList(); out.push(`<h5>${inlineFormat(line.slice(6))}</h5>`); continue; }
// Blockquote
if (line.startsWith("> ")) { flushList(); out.push(`<div class="rd-md-quote"><p>${inlineFormat(line.slice(2))}</p></div>`); continue; }
// Unordered list
const ulMatch = line.match(/^[-*]\s+(.+)/);
if (ulMatch) {
if (inList !== "ul") { flushList(); out.push("<ul>"); inList = "ul"; }
out.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
continue;
}
// Ordered list
const olMatch = line.match(/^(\d+)\.\s+(.+)/);
if (olMatch) {
if (inList !== "ol") { flushList(); out.push("<ol>"); inList = "ol"; }
out.push(`<li><span class="rd-md-num">${olMatch[1]}.</span>${inlineFormat(olMatch[2])}</li>`);
continue;
}
// Paragraph
flushList();
out.push(`<p>${inlineFormat(line)}</p>`);
}
flushCode();
flushList();
return out.join("\n");
}
function inlineFormat(text: string): string {
return text
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
}
// ── Note card rendering ──
const TAG_COLORS: Record<string, string> = {
planning: "rgba(245,158,11,0.15)",
travel: "rgba(20,184,166,0.15)",
food: "rgba(251,146,60,0.15)",
gear: "rgba(168,85,247,0.15)",
safety: "rgba(239,68,68,0.15)",
accommodation: "rgba(59,130,246,0.15)",
};
function renderNoteCard(note: DemoShape, expanded: boolean): string {
const title = (note.title as string) || "Untitled";
const content = (note.content as string) || "";
const tags = (note.tags as string[]) || [];
const lastEdited = note.lastEdited as string;
const synced = note.synced !== false;
const preview = content.split("\n").slice(0, 3).join(" ").slice(0, 120);
const previewText = preview.replace(/[#*>`\-]/g, "").trim();
return `
<div class="rd-card rd-note-card ${expanded ? "rd-note-card--expanded" : ""}" data-note-id="${note.id}" style="cursor:pointer;">
<div style="padding:1rem 1.25rem;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:0.5rem;">
<h3 style="font-size:0.9375rem;font-weight:600;color:white;margin:0;">${escHtml(title)}</h3>
${synced ? `<span class="rd-synced-badge"><span style="width:6px;height:6px;border-radius:50%;background:#2dd4bf;"></span>synced</span>` : ""}
</div>
${expanded
? `<div class="rd-md" style="margin-top:0.75rem;">${renderMarkdown(content)}</div>`
: `<p style="font-size:0.8125rem;color:#94a3b8;margin:0 0 0.75rem;line-height:1.5;">${escHtml(previewText)}${content.length > 120 ? "..." : ""}</p>`
}
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:0.75rem;">
<div style="display:flex;flex-wrap:wrap;gap:0.375rem;">
${tags.map((t) => `<span class="rd-note-tag" style="background:${TAG_COLORS[t] || "rgba(51,65,85,0.5)"}">${escHtml(t)}</span>`).join("")}
</div>
${lastEdited ? `<span style="font-size:0.6875rem;color:#64748b;">${formatRelative(lastEdited)}</span>` : ""}
</div>
</div>
</div>`;
}
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function formatRelative(iso: string): string {
try {
const d = new Date(iso);
const now = Date.now();
const diff = now - d.getTime();
if (diff < 60_000) return "just now";
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`;
return d.toLocaleDateString("en", { month: "short", day: "numeric" });
} catch { return ""; }
}
// ── Packing list rendering ──
function renderPackingList(packingList: DemoShape): string {
const items = (packingList.items as Array<{ name: string; packed: boolean; category: string }>) || [];
if (items.length === 0) return "";
// Group by category
const groups: Record<string, typeof items> = {};
for (const item of items) {
const cat = item.category || "General";
if (!groups[cat]) groups[cat] = [];
groups[cat].push(item);
}
const checked = items.filter((i) => i.packed).length;
const pct = Math.round((checked / items.length) * 100);
let html = `
<div class="rd-card" style="overflow:hidden;">
<div style="padding:0.75rem 1rem;border-bottom:1px solid rgba(51,65,85,0.5);display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:0.8125rem;font-weight:600;color:white;">Packing Checklist</span>
<span style="font-size:0.75rem;color:#94a3b8;">${checked}/${items.length} packed (${pct}%)</span>
</div>
<div style="padding:0.75rem 1rem 0.25rem;">
<div style="height:0.375rem;background:rgba(51,65,85,0.5);border-radius:9999px;overflow:hidden;margin-bottom:0.75rem;">
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#f59e0b,#fb923c);border-radius:9999px;transition:width 0.3s;"></div>
</div>
</div>`;
for (const [cat, catItems] of Object.entries(groups)) {
html += `<div style="padding:0 0.75rem 0.75rem;">
<h4 style="font-size:0.6875rem;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:0.05em;margin:0.5rem 0 0.25rem;">${escHtml(cat)}</h4>`;
for (let i = 0; i < catItems.length; i++) {
const item = catItems[i];
const globalIdx = items.indexOf(item);
html += `
<div class="rd-pack-item" data-pack-idx="${globalIdx}">
<div class="rd-pack-check ${item.packed ? "rd-pack-check--checked" : ""}">
${item.packed ? `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg>` : ""}
</div>
<span style="font-size:0.8125rem;${item.packed ? "color:#64748b;text-decoration:line-through;" : "color:#e2e8f0;"}">${escHtml(item.name)}</span>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
return html;
}
// ── Avatars ──
const AVATAR_COLORS = ["#14b8a6", "#06b6d4", "#8b5cf6", "#f59e0b", "#f43f5e"];
function renderAvatars(members: string[]): string {
if (!members.length) return "";
return members.map((name, i) =>
`<div class="rd-avatar" style="background:${AVATAR_COLORS[i % AVATAR_COLORS.length]}" title="${escHtml(name)}">${name[0]}</div>`
).join("") + `<span style="font-size:0.75rem;color:#94a3b8;margin-left:0.25rem;">${members.length} collaborators</span>`;
}
// ── Main ──
const expandedNotes = new Set<string>();
const sync = new DemoSync({ filter: ["folk-notebook", "folk-note", "folk-packing-list"] });
function render(shapes: Record<string, DemoShape>) {
const notebook = shapeByType(shapes, "folk-notebook");
const notes = shapesByType(shapes, "folk-note").sort((a, b) => {
const aTime = a.lastEdited ? new Date(a.lastEdited as string).getTime() : 0;
const bTime = b.lastEdited ? new Date(b.lastEdited as string).getTime() : 0;
return bTime - aTime;
});
const packingList = shapeByType(shapes, "folk-packing-list");
// Hide loading skeleton
const loading = $("rd-loading");
if (loading) loading.style.display = "none";
// Notebook header
if (notebook) {
const nbTitle = $("rd-nb-title");
const nbCount = $("rd-nb-count");
const nbDesc = $("rd-nb-desc");
const sbTitle = $("rd-sb-nb-title");
const sbCount = $("rd-sb-note-count");
const sbNum = $("rd-sb-notes-num");
if (nbTitle) nbTitle.textContent = (notebook.name as string) || "Trip Notebook";
if (nbCount) nbCount.textContent = `${notes.length} notes`;
if (nbDesc) nbDesc.textContent = (notebook.description as string) || "";
if (sbTitle) sbTitle.textContent = (notebook.name as string) || "Trip Notebook";
if (sbCount) sbCount.textContent = `${notes.length} note${notes.length !== 1 ? "s" : ""}`;
if (sbNum) sbNum.textContent = String(notes.length);
}
// Notes count
const notesCount = $("rd-notes-count");
if (notesCount) notesCount.textContent = `${notes.length} note${notes.length !== 1 ? "s" : ""}`;
// Notes container
const container = $("rd-notes-container");
const empty = $("rd-notes-empty");
if (container) {
if (notes.length === 0) {
container.innerHTML = "";
if (empty) empty.style.display = "block";
} else {
if (empty) empty.style.display = "none";
container.innerHTML = notes.map((n) => renderNoteCard(n, expandedNotes.has(n.id))).join("");
}
}
// Packing list
const packSection = $("rd-packing-section");
const packContainer = $("rd-packing-container");
if (packingList && packSection && packContainer) {
packSection.style.display = "block";
packContainer.innerHTML = renderPackingList(packingList);
}
// Avatars — extract from notebook members or note authors
const members = (notebook?.members as string[]) || [];
const avatarsEl = $("rd-avatars");
if (avatarsEl && members.length > 0) {
avatarsEl.innerHTML = renderAvatars(members);
}
}
// ── Event listeners ──
sync.addEventListener("snapshot", ((e: CustomEvent) => {
render(e.detail.shapes);
}) as EventListener);
sync.addEventListener("connected", () => {
const dot = $("rd-hero-dot");
const label = $("rd-hero-label");
if (dot) dot.style.background = "#10b981";
if (label) label.textContent = "Live — Connected to rSpace";
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
if (resetBtn) resetBtn.disabled = false;
});
sync.addEventListener("disconnected", () => {
const dot = $("rd-hero-dot");
const label = $("rd-hero-label");
if (dot) dot.style.background = "#64748b";
if (label) label.textContent = "Reconnecting...";
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
if (resetBtn) resetBtn.disabled = true;
});
// ── Event delegation ──
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
// Note card expand/collapse
const noteCard = target.closest<HTMLElement>("[data-note-id]");
if (noteCard) {
const id = noteCard.dataset.noteId!;
if (expandedNotes.has(id)) expandedNotes.delete(id);
else expandedNotes.add(id);
render(sync.shapes);
return;
}
// Packing checkbox toggle
const packItem = target.closest<HTMLElement>("[data-pack-idx]");
if (packItem) {
const idx = parseInt(packItem.dataset.packIdx!, 10);
const packingList = shapeByType(sync.shapes, "folk-packing-list");
if (packingList) {
const items = [...(packingList.items as Array<{ name: string; packed: boolean; category: string }>)];
items[idx] = { ...items[idx], packed: !items[idx].packed };
sync.updateShape(packingList.id, { items });
}
return;
}
// Reset button
if (target.closest("#rd-reset-btn")) {
sync.resetDemo().catch((err) => console.error("[Notes] Reset failed:", err));
}
});
// ── Start ──
sync.connect();

View File

@ -1,7 +0,0 @@
/* Notes module — dark theme (host-level styles) */
folk-notes-app {
display: block;
min-height: 400px;
padding: 0;
position: relative;
}

View File

@ -1,308 +0,0 @@
/**
* Slash command ProseMirror plugin for Tiptap.
*
* Detects '/' typed at the start of an empty block and shows a floating menu
* with block type options. Keyboard navigation: arrow keys + Enter + Escape.
*/
import { Plugin, PluginKey } from '@tiptap/pm/state';
import type { EditorView } from '@tiptap/pm/view';
import type { Editor } from '@tiptap/core';
/** Inline SVG icons for slash menu items (16×16, stroke-based, currentColor) */
const SLASH_ICONS: Record<string, string> = {
text: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="4" y1="3" x2="12" y2="3"/><line x1="8" y1="3" x2="8" y2="13"/><line x1="6" y1="13" x2="10" y2="13"/></svg>',
heading1: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 3v10M8 3v10M2 8h6"/><text x="10.5" y="13" font-size="7" fill="currentColor" stroke="none" font-family="system-ui" font-weight="700">1</text></svg>',
heading2: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 3v10M8 3v10M2 8h6"/><text x="10.5" y="13" font-size="7" fill="currentColor" stroke="none" font-family="system-ui" font-weight="700">2</text></svg>',
heading3: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 3v10M8 3v10M2 8h6"/><text x="10.5" y="13" font-size="7" fill="currentColor" stroke="none" font-family="system-ui" font-weight="700">3</text></svg>',
bulletList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="6" y1="4" x2="14" y2="4"/><line x1="6" y1="8" x2="14" y2="8"/><line x1="6" y1="12" x2="14" y2="12"/><circle cx="3" cy="4" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="8" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="12" r="1" fill="currentColor" stroke="none"/></svg>',
orderedList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="7" y1="4" x2="14" y2="4"/><line x1="7" y1="8" x2="14" y2="8"/><line x1="7" y1="12" x2="14" y2="12"/><text x="1.5" y="5.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">1</text><text x="1.5" y="9.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">2</text><text x="1.5" y="13.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">3</text></svg>',
taskList: '<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="2" y="2" width="5" height="5" rx="1"/><polyline points="3.5 4.5 4.5 5.5 6 3.5"/><line x1="9" y1="4.5" x2="14" y2="4.5"/><rect x="2" y="9" width="5" height="5" rx="1"/><line x1="9" y1="11.5" x2="14" y2="11.5"/></svg>',
codeBlock: '<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="1.5" width="13" height="13" rx="2"/><polyline points="5 6 3.5 8 5 10"/><polyline points="11 6 12.5 8 11 10"/><line x1="9" y1="5" x2="7" y2="11"/></svg>',
blockquote: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="2" x2="3" y2="14"/><line x1="7" y1="4" x2="14" y2="4"/><line x1="7" y1="8" x2="14" y2="8"/><line x1="7" y1="12" x2="12" y2="12"/></svg>',
horizontalRule: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="2" y1="8" x2="14" y2="8"/><circle cx="4" cy="8" r="0.5" fill="currentColor"/><circle cx="8" cy="8" r="0.5" fill="currentColor"/><circle cx="12" cy="8" r="0.5" fill="currentColor"/></svg>',
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>',
};
export interface SlashMenuItem {
title: string;
icon: string;
description: string;
command: (editor: Editor) => void;
}
export const SLASH_ITEMS: SlashMenuItem[] = [
{
title: 'Text',
icon: 'text',
description: 'Plain paragraph text',
command: (e) => e.chain().focus().setParagraph().run(),
},
{
title: 'Heading 1',
icon: 'heading1',
description: 'Large section heading',
command: (e) => e.chain().focus().setHeading({ level: 1 }).run(),
},
{
title: 'Heading 2',
icon: 'heading2',
description: 'Medium section heading',
command: (e) => e.chain().focus().setHeading({ level: 2 }).run(),
},
{
title: 'Heading 3',
icon: 'heading3',
description: 'Small section heading',
command: (e) => e.chain().focus().setHeading({ level: 3 }).run(),
},
{
title: 'Bullet List',
icon: 'bulletList',
description: 'Unordered bullet list',
command: (e) => e.chain().focus().toggleBulletList().run(),
},
{
title: 'Numbered List',
icon: 'orderedList',
description: 'Ordered numbered list',
command: (e) => e.chain().focus().toggleOrderedList().run(),
},
{
title: 'Task List',
icon: 'taskList',
description: 'Checklist with checkboxes',
command: (e) => e.chain().focus().toggleTaskList().run(),
},
{
title: 'Code Block',
icon: 'codeBlock',
description: 'Syntax-highlighted code block',
command: (e) => e.chain().focus().toggleCodeBlock().run(),
},
{
title: 'Blockquote',
icon: 'blockquote',
description: 'Indented quote block',
command: (e) => e.chain().focus().toggleBlockquote().run(),
},
{
title: 'Horizontal Rule',
icon: 'horizontalRule',
description: 'Visual divider line',
command: (e) => e.chain().focus().setHorizontalRule().run(),
},
{
title: 'Image',
icon: 'image',
description: 'Insert an image from URL',
command: (e) => {
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');
export function createSlashCommandPlugin(editor: Editor, shadowRoot: ShadowRoot): Plugin {
let menuEl: HTMLDivElement | null = null;
let selectedIndex = 0;
let filteredItems: SlashMenuItem[] = [];
let query = '';
let active = false;
let triggerPos = -1;
function show(view: EditorView) {
if (!menuEl) {
menuEl = document.createElement('div');
menuEl.className = 'slash-menu';
shadowRoot.appendChild(menuEl);
}
active = true;
selectedIndex = 0;
query = '';
filteredItems = SLASH_ITEMS;
updateMenuContent();
positionMenu(view);
menuEl.style.display = 'block';
}
function hide() {
active = false;
query = '';
triggerPos = -1;
if (menuEl) menuEl.style.display = 'none';
}
function updateMenuContent() {
if (!menuEl) return;
menuEl.innerHTML = `<div class="slash-menu__header">Insert block</div>` +
filteredItems
.map(
(item, i) =>
`<div class="slash-menu-item${i === selectedIndex ? ' selected' : ''}" data-index="${i}">
<span class="slash-menu-icon">${SLASH_ICONS[item.icon] || item.icon}</span>
<div class="slash-menu-text">
<div class="slash-menu-title">${item.title}</div>
<div class="slash-menu-desc">${item.description}</div>
</div>
${i === selectedIndex ? '<span class="slash-menu-hint">Enter</span>' : ''}
</div>`,
)
.join('');
// Click handlers
menuEl.querySelectorAll('.slash-menu-item').forEach((el) => {
el.addEventListener('pointerdown', (e) => {
e.preventDefault();
const idx = parseInt((el as HTMLElement).dataset.index || '0');
executeItem(idx);
});
el.addEventListener('pointerenter', () => {
selectedIndex = parseInt((el as HTMLElement).dataset.index || '0');
updateMenuContent();
});
});
}
function positionMenu(view: EditorView) {
if (!menuEl) return;
const { from } = view.state.selection;
const coords = view.coordsAtPos(from);
const shadowHost = shadowRoot.host as HTMLElement;
const hostRect = shadowHost.getBoundingClientRect();
let left = coords.left - hostRect.left;
const menuWidth = 240;
const maxLeft = window.innerWidth - menuWidth - 8 - hostRect.left;
left = Math.max(4, Math.min(left, maxLeft));
menuEl.style.left = `${left}px`;
menuEl.style.top = `${coords.bottom - hostRect.top + 4}px`;
}
function filterItems() {
const q = query.toLowerCase();
filteredItems = q
? SLASH_ITEMS.filter(
(item) =>
item.title.toLowerCase().includes(q) || item.description.toLowerCase().includes(q),
)
: SLASH_ITEMS;
selectedIndex = Math.min(selectedIndex, Math.max(0, filteredItems.length - 1));
updateMenuContent();
}
function executeItem(index: number) {
const item = filteredItems[index];
if (!item) return;
// Delete the slash + query text
const { state } = editor.view;
const tr = state.tr.delete(triggerPos, state.selection.from);
editor.view.dispatch(tr);
item.command(editor);
hide();
}
return new Plugin({
key: pluginKey,
props: {
handleKeyDown(view, event) {
if (active) {
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex = (selectedIndex + 1) % filteredItems.length;
updateMenuContent();
return true;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex = (selectedIndex - 1 + filteredItems.length) % filteredItems.length;
updateMenuContent();
return true;
}
if (event.key === 'Enter') {
event.preventDefault();
executeItem(selectedIndex);
return true;
}
if (event.key === 'Escape') {
event.preventDefault();
hide();
return true;
}
if (event.key === 'Backspace') {
if (query.length === 0) {
// Backspace deletes the '/', close menu
hide();
return false; // let ProseMirror handle the deletion
}
query = query.slice(0, -1);
filterItems();
return false; // let ProseMirror handle the deletion
}
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
query += event.key;
filterItems();
if (filteredItems.length === 0) {
hide();
}
return false; // let ProseMirror insert the character
}
}
return false;
},
handleTextInput(view, from, to, text) {
if (text === '/' && !active) {
// Check if cursor is at start of an empty block
const { $from } = view.state.selection;
const isAtStart = $from.parentOffset === 0;
const isEmpty = $from.parent.textContent === '';
if (isAtStart && isEmpty) {
triggerPos = from;
// Defer show to after the '/' is inserted
setTimeout(() => show(view), 0);
}
}
return false;
},
},
view() {
return {
update(view) {
if (active && menuEl) {
positionMenu(view);
}
},
destroy() {
if (menuEl) {
menuEl.remove();
menuEl = null;
}
},
};
},
});
}

View File

@ -1,101 +0,0 @@
/**
* TipTap mark extensions for track-changes suggestions.
*
* SuggestionInsert: wraps text that was inserted in suggesting mode (green underline).
* SuggestionDelete: wraps text that was marked for deletion in suggesting mode (red strikethrough).
*
* Both marks are stored in the Yjs document and sync in real-time.
* Accept/reject logic is handled by the suggestion-plugin.
*/
import { Mark, mergeAttributes } from '@tiptap/core';
export const SuggestionInsertMark = Mark.create({
name: 'suggestionInsert',
addAttributes() {
return {
suggestionId: { default: null },
authorId: { default: null },
authorName: { default: null },
createdAt: { default: null },
};
},
parseHTML() {
return [
{
tag: 'span[data-suggestion-insert]',
getAttrs: (el) => {
const element = el as HTMLElement;
return {
suggestionId: element.getAttribute('data-suggestion-id'),
authorId: element.getAttribute('data-author-id'),
authorName: element.getAttribute('data-author-name'),
createdAt: Number(element.getAttribute('data-created-at')) || null,
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes({
class: 'suggestion-insert',
'data-suggestion-insert': '',
'data-suggestion-id': HTMLAttributes.suggestionId,
'data-author-id': HTMLAttributes.authorId,
'data-author-name': HTMLAttributes.authorName,
'data-created-at': HTMLAttributes.createdAt,
}),
0,
];
},
});
export const SuggestionDeleteMark = Mark.create({
name: 'suggestionDelete',
addAttributes() {
return {
suggestionId: { default: null },
authorId: { default: null },
authorName: { default: null },
createdAt: { default: null },
};
},
parseHTML() {
return [
{
tag: 'span[data-suggestion-delete]',
getAttrs: (el) => {
const element = el as HTMLElement;
return {
suggestionId: element.getAttribute('data-suggestion-id'),
authorId: element.getAttribute('data-author-id'),
authorName: element.getAttribute('data-author-name'),
createdAt: Number(element.getAttribute('data-created-at')) || null,
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes({
class: 'suggestion-delete',
'data-suggestion-delete': '',
'data-suggestion-id': HTMLAttributes.suggestionId,
'data-author-id': HTMLAttributes.authorId,
'data-author-name': HTMLAttributes.authorName,
'data-created-at': HTMLAttributes.createdAt,
}),
0,
];
},
});

View File

@ -1,366 +0,0 @@
/**
* ProseMirror plugin that intercepts user input in "suggesting" mode
* and converts edits into track-changes marks instead of direct mutations.
*
* In suggesting mode:
* - Typed text inserted with `suggestionInsert` mark (green underline)
* - Backspace/Delete text NOT deleted, marked with `suggestionDelete` (red strikethrough)
* - Select + type old text gets `suggestionDelete`, new text gets `suggestionInsert`
* - Paste same as select + type
*
* Uses ProseMirror props (handleTextInput, handleKeyDown, handlePaste) rather
* than filterTransaction for reliability.
*/
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import type { EditorView } from '@tiptap/pm/view';
import type { Slice } from '@tiptap/pm/model';
import type { Editor } from '@tiptap/core';
const pluginKey = new PluginKey('suggestion-plugin');
function makeSuggestionId(): string {
return `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
// ── Typing session tracker ──
// Reuses the same suggestionId while the user types consecutively,
// so an entire typed word/phrase becomes ONE suggestion in the sidebar.
let _sessionSuggestionId: string | null = null;
let _sessionNextPos: number = -1; // the position where the next char is expected
function getOrCreateSessionId(insertPos: number): string {
if (_sessionSuggestionId && insertPos === _sessionNextPos) {
return _sessionSuggestionId;
}
_sessionSuggestionId = makeSuggestionId();
return _sessionSuggestionId;
}
function advanceSession(id: string, nextPos: number): void {
_sessionSuggestionId = id;
_sessionNextPos = nextPos;
}
function resetSession(): void {
_sessionSuggestionId = null;
_sessionNextPos = -1;
}
/**
* Create the suggestion mode ProseMirror plugin.
* @param getSuggesting - callback that returns current suggesting mode state
* @param getAuthor - callback that returns { authorId, authorName }
*/
export function createSuggestionPlugin(
getSuggesting: () => boolean,
getAuthor: () => { authorId: string; authorName: string },
): Plugin {
return new Plugin({
key: pluginKey,
props: {
/** Intercept typed text — insert with suggestionInsert mark. */
handleTextInput(view: EditorView, from: number, to: number, text: string): boolean {
if (!getSuggesting()) return false;
const { state } = view;
const { authorId, authorName } = getAuthor();
// Reuse session ID for consecutive typing at the same position
const suggestionId = (from !== to)
? makeSuggestionId() // replacement → new suggestion
: getOrCreateSessionId(from); // plain insert → batch with session
const tr = state.tr;
// If there's a selection (replacement), mark the selected text as deleted
if (from !== to) {
// Check if selected text is all suggestionInsert from the same author
// → if so, just replace it (editing your own suggestion)
const ownInsert = isOwnSuggestionInsert(state, from, to, authorId);
if (ownInsert) {
tr.replaceWith(from, to, state.schema.text(text, [
state.schema.marks.suggestionInsert.create({
suggestionId: ownInsert, authorId, authorName, createdAt: Date.now(),
}),
]));
tr.setMeta('suggestion-applied', true);
view.dispatch(tr);
return true;
}
const deleteMark = state.schema.marks.suggestionDelete.create({
suggestionId, authorId, authorName, createdAt: Date.now(),
});
tr.addMark(from, to, deleteMark);
}
// Insert the new text with insert mark after the (marked-for-deletion) text
const insertPos = to;
const insertMark = state.schema.marks.suggestionInsert.create({
suggestionId, authorId, authorName, createdAt: Date.now(),
});
tr.insert(insertPos, state.schema.text(text, [insertMark]));
tr.setMeta('suggestion-applied', true);
// Place cursor after the inserted text
const newCursorPos = insertPos + text.length;
tr.setSelection(TextSelection.create(tr.doc, newCursorPos));
view.dispatch(tr);
advanceSession(suggestionId, newCursorPos);
return true;
},
/** Intercept Backspace/Delete — mark text as deleted instead of removing. */
handleKeyDown(view: EditorView, event: KeyboardEvent): boolean {
if (!getSuggesting()) return false;
if (event.key !== 'Backspace' && event.key !== 'Delete') return false;
resetSession(); // break typing session on delete actions
const { state } = view;
const { from, to, empty } = state.selection;
const { authorId, authorName } = getAuthor();
let deleteFrom = from;
let deleteTo = to;
if (empty) {
if (event.key === 'Backspace') {
if (from === 0) return true;
deleteFrom = from - 1;
deleteTo = from;
} else {
if (from >= state.doc.content.size) return true;
deleteFrom = from;
deleteTo = from + 1;
}
// Don't cross block boundaries
const $from = state.doc.resolve(deleteFrom);
const $to = state.doc.resolve(deleteTo);
if ($from.parent !== $to.parent) return true;
}
// Backspace/delete on own suggestionInsert → actually remove it
const ownInsert = isOwnSuggestionInsert(state, deleteFrom, deleteTo, authorId);
if (ownInsert) {
const tr = state.tr;
tr.delete(deleteFrom, deleteTo);
tr.setMeta('suggestion-applied', true);
view.dispatch(tr);
return true;
}
// Already marked as suggestionDelete → skip past it
if (isAlreadySuggestionDelete(state, deleteFrom, deleteTo)) {
const tr = state.tr;
const newPos = event.key === 'Backspace' ? deleteFrom : deleteTo;
tr.setSelection(TextSelection.create(state.doc, newPos));
view.dispatch(tr);
return true;
}
// Mark the text as deleted
const suggestionId = makeSuggestionId();
const tr = state.tr;
const deleteMark = state.schema.marks.suggestionDelete.create({
suggestionId, authorId, authorName, createdAt: Date.now(),
});
tr.addMark(deleteFrom, deleteTo, deleteMark);
tr.setMeta('suggestion-applied', true);
if (event.key === 'Backspace') {
tr.setSelection(TextSelection.create(tr.doc, deleteFrom));
} else {
tr.setSelection(TextSelection.create(tr.doc, deleteTo));
}
view.dispatch(tr);
return true;
},
/** Intercept paste — insert pasted text as a suggestion. */
handlePaste(view: EditorView, _event: ClipboardEvent, slice: Slice): boolean {
if (!getSuggesting()) return false;
resetSession(); // paste is a discrete action, break typing session
const { state } = view;
const { from, to } = state.selection;
const { authorId, authorName } = getAuthor();
const suggestionId = makeSuggestionId();
const tr = state.tr;
// Mark selected text as deleted
if (from !== to) {
const deleteMark = state.schema.marks.suggestionDelete.create({
suggestionId, authorId, authorName, createdAt: Date.now(),
});
tr.addMark(from, to, deleteMark);
}
// Extract text from slice and insert with mark
let pastedText = '';
slice.content.forEach((node: any) => {
if (pastedText) pastedText += '\n';
pastedText += node.textContent;
});
if (pastedText) {
const insertPos = to;
const insertMark = state.schema.marks.suggestionInsert.create({
suggestionId, authorId, authorName, createdAt: Date.now(),
});
tr.insert(insertPos, state.schema.text(pastedText, [insertMark]));
tr.setMeta('suggestion-applied', true);
tr.setSelection(TextSelection.create(tr.doc, insertPos + pastedText.length));
}
view.dispatch(tr);
return true;
},
},
});
}
/** Check if the range is entirely covered by suggestionInsert marks from the same author. */
function isOwnSuggestionInsert(
state: { doc: any; schema: any },
from: number,
to: number,
authorId: string,
): string | null {
let allOwn = true;
let foundId: string | null = null;
state.doc.nodesBetween(from, to, (node: any) => {
if (!node.isText) return;
const mark = node.marks.find(
(m: any) => m.type.name === 'suggestionInsert' && m.attrs.authorId === authorId
);
if (!mark) {
allOwn = false;
} else if (!foundId) {
foundId = mark.attrs.suggestionId;
}
});
return allOwn && foundId ? foundId : null;
}
/** Check if the range is already entirely covered by suggestionDelete marks. */
function isAlreadySuggestionDelete(state: { doc: any }, from: number, to: number): boolean {
let allDeleted = true;
state.doc.nodesBetween(from, to, (node: any) => {
if (!node.isText) return;
if (!node.marks.find((m: any) => m.type.name === 'suggestionDelete')) allDeleted = false;
});
return allDeleted;
}
/**
* Accept a suggestion: insertions stay, deletions are removed.
*/
export function acceptSuggestion(editor: Editor, suggestionId: string) {
const { state } = editor;
const { tr } = state;
// Collect ranges first, apply from end→start to preserve positions
const deleteRanges: [number, number][] = [];
const insertRanges: [number, number, any][] = [];
state.doc.descendants((node: any, pos: number) => {
if (!node.isText) return;
const deleteMark = node.marks.find(
(m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
);
if (deleteMark) {
deleteRanges.push([pos, pos + node.nodeSize]);
return;
}
const insertMark = node.marks.find(
(m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
);
if (insertMark) {
insertRanges.push([pos, pos + node.nodeSize, insertMark]);
}
});
for (const [from, to] of deleteRanges.sort((a, b) => b[0] - a[0])) {
tr.delete(from, to);
}
for (const [from, to, mark] of insertRanges.sort((a, b) => b[0] - a[0])) {
tr.removeMark(from, to, mark);
}
if (tr.docChanged) {
tr.setMeta('suggestion-accept', true);
editor.view.dispatch(tr);
}
}
/**
* Reject a suggestion: insertions are removed, deletions stay.
*/
export function rejectSuggestion(editor: Editor, suggestionId: string) {
const { state } = editor;
const { tr } = state;
const insertRanges: [number, number][] = [];
const deleteRanges: [number, number, any][] = [];
state.doc.descendants((node: any, pos: number) => {
if (!node.isText) return;
const insertMark = node.marks.find(
(m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
);
if (insertMark) {
insertRanges.push([pos, pos + node.nodeSize]);
return;
}
const deleteMark = node.marks.find(
(m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
);
if (deleteMark) {
deleteRanges.push([pos, pos + node.nodeSize, deleteMark]);
}
});
for (const [from, to] of insertRanges.sort((a, b) => b[0] - a[0])) {
tr.delete(from, to);
}
for (const [from, to, mark] of deleteRanges.sort((a, b) => b[0] - a[0])) {
tr.removeMark(from, to, mark);
}
if (tr.docChanged) {
tr.setMeta('suggestion-reject', true);
editor.view.dispatch(tr);
}
}
/** Accept all suggestions in the document. */
export function acceptAllSuggestions(editor: Editor) {
const ids = new Set<string>();
editor.state.doc.descendants((node: any) => {
if (!node.isText) return;
for (const mark of node.marks) {
if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') {
ids.add(mark.attrs.suggestionId);
}
}
});
for (const id of ids) acceptSuggestion(editor, id);
}
/** Reject all suggestions in the document. */
export function rejectAllSuggestions(editor: Editor) {
const ids = new Set<string>();
editor.state.doc.descendants((node: any) => {
if (!node.isText) return;
for (const mark of node.marks) {
if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') {
ids.add(mark.attrs.suggestionId);
}
}
});
for (const id of ids) rejectSuggestion(editor, id);
}

View File

@ -1,236 +0,0 @@
/**
* Evernote ENEX rNotes converter.
*
* Import: Parse .enex XML (ENML strict HTML subset inside <en-note>)
* Convert ENML markdown via Turndown.
* Extract <resource> base64 attachments, save to /data/files/uploads/.
* File-based import (.enex), no auth needed.
*/
import TurndownService from 'turndown';
import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap';
import { registerConverter, hashContent } from './index';
import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index';
import type { NoteItem } from '../schemas';
const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
// Custom Turndown rules for ENML-specific elements
turndown.addRule('enMedia', {
filter: (node) => node.nodeName === 'EN-MEDIA',
replacement: (_content, node) => {
const el = node as Element;
const hash = el.getAttribute('hash') || '';
const type = el.getAttribute('type') || '';
if (type.startsWith('image/')) {
return `![image](resource:${hash})`;
}
return `[attachment](resource:${hash})`;
},
});
turndown.addRule('enTodo', {
filter: (node) => node.nodeName === 'EN-TODO',
replacement: (_content, node) => {
const el = node as Element;
const checked = el.getAttribute('checked') === 'true';
return checked ? '[x] ' : '[ ] ';
},
});
/** Simple XML tag content extractor (avoids needing a full DOM parser on server). */
function extractTagContent(xml: string, tagName: string): string[] {
const results: string[] = [];
const openTag = `<${tagName}`;
const closeTag = `</${tagName}>`;
let pos = 0;
while (true) {
const start = xml.indexOf(openTag, pos);
if (start === -1) break;
// Find end of opening tag (handles attributes)
const tagEnd = xml.indexOf('>', start);
if (tagEnd === -1) break;
const end = xml.indexOf(closeTag, tagEnd);
if (end === -1) break;
results.push(xml.substring(tagEnd + 1, end));
pos = end + closeTag.length;
}
return results;
}
/** Extract a single tag's text content. */
function extractSingleTag(xml: string, tagName: string): string {
const results = extractTagContent(xml, tagName);
return results[0]?.trim() || '';
}
/** Extract attribute value from a tag. */
function extractAttribute(xml: string, attrName: string): string {
const match = xml.match(new RegExp(`${attrName}="([^"]*)"`, 'i'));
return match?.[1] || '';
}
/** Parse a single <note> element from ENEX. */
function parseNote(noteXml: string): {
title: string;
content: string;
tags: string[];
created?: string;
updated?: string;
resources: { hash: string; mime: string; data: Uint8Array; filename?: string }[];
} {
const title = extractSingleTag(noteXml, 'title') || 'Untitled';
// Extract ENML content (inside <content> CDATA)
let enml = extractSingleTag(noteXml, 'content');
// Strip CDATA wrapper if present
enml = enml.replace(/^\s*<!\[CDATA\[/, '').replace(/\]\]>\s*$/, '');
const tags: string[] = [];
const tagMatches = extractTagContent(noteXml, 'tag');
for (const t of tagMatches) {
tags.push(t.trim().toLowerCase().replace(/\s+/g, '-'));
}
const created = extractSingleTag(noteXml, 'created');
const updated = extractSingleTag(noteXml, 'updated');
// Extract resources (attachments)
const resources: { hash: string; mime: string; data: Uint8Array; filename?: string }[] = [];
const resourceBlocks = extractTagContent(noteXml, 'resource');
for (const resXml of resourceBlocks) {
const mime = extractSingleTag(resXml, 'mime');
const b64Data = extractSingleTag(resXml, 'data');
const encoding = extractAttribute(resXml, 'encoding') || 'base64';
// Extract recognition hash or compute from data
let hash = '';
const recognition = extractSingleTag(resXml, 'recognition');
if (recognition) {
// Try to get hash from recognition XML
const hashMatch = recognition.match(/objID="([^"]+)"/);
if (hashMatch) hash = hashMatch[1];
}
// Extract resource attributes
const resAttrs = extractSingleTag(resXml, 'resource-attributes');
const filename = resAttrs ? extractSingleTag(resAttrs, 'file-name') : undefined;
if (b64Data && encoding === 'base64') {
try {
// Decode base64
const cleaned = b64Data.replace(/\s/g, '');
const binary = atob(cleaned);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
// Compute MD5-like hash for matching en-media tags
if (!hash) {
hash = simpleHash(bytes);
}
resources.push({ hash, mime, data: bytes, filename });
} catch { /* skip malformed base64 */ }
}
}
return { title, content: enml, tags, created, updated, resources };
}
/** Simple hash for resource matching when recognition hash is missing. */
function simpleHash(data: Uint8Array): string {
let h = 0;
for (let i = 0; i < Math.min(data.length, 1024); i++) {
h = ((h << 5) - h) + data[i];
h |= 0;
}
return Math.abs(h).toString(16);
}
const evernoteConverter: NoteConverter = {
id: 'evernote',
name: 'Evernote',
requiresAuth: false,
async import(input: ImportInput): Promise<ImportResult> {
if (!input.fileData) {
throw new Error('Evernote import requires an .enex file');
}
const enexXml = new TextDecoder().decode(input.fileData);
const noteBlocks = extractTagContent(enexXml, 'note');
if (noteBlocks.length === 0) {
return { notes: [], notebookTitle: 'Evernote Import', warnings: ['No notes found in ENEX file'] };
}
const notes: ConvertedNote[] = [];
const warnings: string[] = [];
for (const noteXml of noteBlocks) {
try {
const parsed = parseNote(noteXml);
// Build resource hash→filename map for en-media replacement
const resourceMap = new Map<string, { filename: string; data: Uint8Array; mimeType: string }>();
for (const res of parsed.resources) {
const ext = res.mime.includes('jpeg') || res.mime.includes('jpg') ? 'jpg'
: res.mime.includes('png') ? 'png'
: res.mime.includes('gif') ? 'gif'
: res.mime.includes('webp') ? 'webp'
: res.mime.includes('pdf') ? 'pdf'
: 'bin';
const fname = res.filename || `evernote-${res.hash}.${ext}`;
resourceMap.set(res.hash, { filename: fname, data: res.data, mimeType: res.mime });
}
// Convert ENML to markdown
let markdown = turndown.turndown(parsed.content);
// Resolve resource: references to actual file paths
const attachments: { filename: string; data: Uint8Array; mimeType: string }[] = [];
markdown = markdown.replace(/resource:([a-f0-9]+)/g, (_match, hash) => {
const res = resourceMap.get(hash);
if (res) {
attachments.push(res);
return `/data/files/uploads/${res.filename}`;
}
return `resource:${hash}`;
});
const tiptapJson = markdownToTiptap(markdown);
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
notes.push({
title: parsed.title,
content: tiptapJson,
contentPlain,
markdown,
tags: parsed.tags,
attachments: attachments.length > 0 ? attachments : undefined,
sourceRef: {
source: 'evernote',
externalId: `enex:${parsed.title}`,
lastSyncedAt: Date.now(),
contentHash: hashContent(markdown),
},
});
} catch (err) {
warnings.push(`Failed to parse note: ${(err as Error).message}`);
}
}
return { notes, notebookTitle: 'Evernote Import', warnings };
},
async export(): Promise<ExportResult> {
throw new Error('Evernote export is not supported — use Evernote\'s native import');
},
};
registerConverter(evernoteConverter);

View File

@ -1,171 +0,0 @@
/**
* Generic file import for rNotes.
*
* Handles direct import of individual files:
* - .md / .txt parse as markdown/text
* - .html convert via Turndown
* - .jpg / .png / .webp / .gif create IMAGE note with stored file
*
* All produce ConvertedNote with sourceRef.source = 'manual'.
*/
import TurndownService from 'turndown';
import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap';
import { hashContent } from './index';
import type { ConvertedNote } from './index';
const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
/** Dispatch file import by extension / MIME type. */
export function importFile(
filename: string,
data: Uint8Array,
mimeType?: string,
): ConvertedNote {
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
const textContent = () => new TextDecoder().decode(data);
if (ext === '.md' || ext === '.markdown') {
return importMarkdownFile(filename, textContent());
}
if (ext === '.txt') {
return importTextFile(filename, textContent());
}
if (ext === '.html' || ext === '.htm') {
return importHtmlFile(filename, textContent());
}
if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'].includes(ext)) {
return importImageFile(filename, data, mimeType || guessMime(ext));
}
// Default: treat as text
try {
return importTextFile(filename, textContent());
} catch {
// Binary file — store as FILE note
return importBinaryFile(filename, data, mimeType || 'application/octet-stream');
}
}
/** Import a markdown file. */
export function importMarkdownFile(filename: string, content: string): ConvertedNote {
const title = titleFromFilename(filename);
const tiptapJson = markdownToTiptap(content);
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
return {
title,
content: tiptapJson,
contentPlain,
markdown: content,
tags: [],
sourceRef: {
source: 'manual',
externalId: `file:${filename}`,
lastSyncedAt: Date.now(),
contentHash: hashContent(content),
},
};
}
/** Import a plain text file — wrap as simple note. */
export function importTextFile(filename: string, content: string): ConvertedNote {
const title = titleFromFilename(filename);
const tiptapJson = markdownToTiptap(content);
const contentPlain = content;
return {
title,
content: tiptapJson,
contentPlain,
markdown: content,
tags: [],
sourceRef: {
source: 'manual',
externalId: `file:${filename}`,
lastSyncedAt: Date.now(),
contentHash: hashContent(content),
},
};
}
/** Import an HTML file — convert via Turndown. */
export function importHtmlFile(filename: string, html: string): ConvertedNote {
const title = titleFromFilename(filename);
const markdown = turndown.turndown(html);
const tiptapJson = markdownToTiptap(markdown);
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
return {
title,
content: tiptapJson,
contentPlain,
markdown,
tags: [],
sourceRef: {
source: 'manual',
externalId: `file:${filename}`,
lastSyncedAt: Date.now(),
contentHash: hashContent(markdown),
},
};
}
/** Import an image file — create IMAGE note with stored file reference. */
export function importImageFile(filename: string, data: Uint8Array, mimeType: string): ConvertedNote {
const title = titleFromFilename(filename);
const md = `![${title}](/data/files/uploads/${filename})`;
const tiptapJson = markdownToTiptap(md);
return {
title,
content: tiptapJson,
contentPlain: title,
markdown: md,
tags: [],
type: 'IMAGE',
attachments: [{ filename, data, mimeType }],
sourceRef: {
source: 'manual',
externalId: `file:${filename}`,
lastSyncedAt: Date.now(),
contentHash: hashContent(String(data.length)),
},
};
}
/** Import a binary/unknown file as a FILE note. */
function importBinaryFile(filename: string, data: Uint8Array, mimeType: string): ConvertedNote {
const title = titleFromFilename(filename);
const md = `[${filename}](/data/files/uploads/${filename})`;
const tiptapJson = markdownToTiptap(md);
return {
title,
content: tiptapJson,
contentPlain: title,
markdown: md,
tags: [],
type: 'FILE',
attachments: [{ filename, data, mimeType }],
sourceRef: {
source: 'manual',
externalId: `file:${filename}`,
lastSyncedAt: Date.now(),
contentHash: hashContent(String(data.length)),
},
};
}
function titleFromFilename(filename: string): string {
return filename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
}
function guessMime(ext: string): string {
const mimes: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
};
return mimes[ext] || 'application/octet-stream';
}

View File

@ -1,329 +0,0 @@
/**
* Google Docs rNotes converter.
*
* Import: Google Docs API structural JSON markdown TipTap JSON
* Export: TipTap JSON Google Docs batch update requests
*/
import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap';
import { registerConverter, hashContent } from './index';
import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index';
import type { NoteItem } from '../schemas';
const DOCS_API_BASE = 'https://docs.googleapis.com/v1';
const DRIVE_API_BASE = 'https://www.googleapis.com/drive/v3';
/** Fetch from Google APIs with auth. */
async function googleFetch(url: string, token: string, opts: RequestInit = {}): Promise<any> {
const res = await fetch(url, {
...opts,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...opts.headers,
},
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Google API error ${res.status}: ${body}`);
}
return res.json();
}
/** Convert Google Docs structural elements to markdown. */
function structuralElementToMarkdown(element: any, inlineObjects?: Record<string, any>): string {
if (element.paragraph) {
return paragraphToMarkdown(element.paragraph, inlineObjects);
}
if (element.table) {
return tableToMarkdown(element.table);
}
if (element.sectionBreak) {
return '\n---\n';
}
return '';
}
/** Convert a Google Docs paragraph to markdown (with inline image resolution context). */
function paragraphToMarkdown(paragraph: any, inlineObjects?: Record<string, any>): string {
const style = paragraph.paragraphStyle?.namedStyleType || 'NORMAL_TEXT';
const elements = paragraph.elements || [];
let text = '';
for (const el of elements) {
if (el.textRun) {
text += textRunToMarkdown(el.textRun);
} else if (el.inlineObjectElement) {
const objectId = el.inlineObjectElement.inlineObjectId;
const obj = inlineObjects?.[objectId];
if (obj) {
const imageProps = obj.inlineObjectProperties?.embeddedObject?.imageProperties;
const contentUri = imageProps?.contentUri;
if (contentUri) {
text += `![image](${contentUri})`;
} else {
text += `![image](inline-object-${objectId})`;
}
} else {
text += `![image](inline-object)`;
}
}
}
// Remove trailing newline that Google Docs adds to every paragraph
text = text.replace(/\n$/, '');
// Apply heading styles
switch (style) {
case 'HEADING_1': return `# ${text}`;
case 'HEADING_2': return `## ${text}`;
case 'HEADING_3': return `### ${text}`;
case 'HEADING_4': return `#### ${text}`;
case 'HEADING_5': return `##### ${text}`;
case 'HEADING_6': return `###### ${text}`;
default: return text;
}
}
/** Convert a Google Docs TextRun to markdown with formatting. */
function textRunToMarkdown(textRun: any): string {
let text = textRun.content || '';
const style = textRun.textStyle || {};
// Don't apply formatting to whitespace-only text
if (!text.trim()) return text;
if (style.bold) text = `**${text.trim()}** `;
if (style.italic) text = `*${text.trim()}* `;
if (style.strikethrough) text = `~~${text.trim()}~~ `;
if (style.link?.url) text = `[${text.trim()}](${style.link.url})`;
return text;
}
/** Convert a Google Docs table to markdown. */
function tableToMarkdown(table: any): string {
const rows = table.tableRows || [];
if (rows.length === 0) return '';
const mdRows: string[] = [];
for (let r = 0; r < rows.length; r++) {
const cells = rows[r].tableCells || [];
const cellTexts = cells.map((cell: any) => {
const content = (cell.content || [])
.map((el: any) => structuralElementToMarkdown(el))
.join('')
.trim();
return content || ' ';
});
mdRows.push(`| ${cellTexts.join(' | ')} |`);
// Separator after header
if (r === 0) {
mdRows.push(`| ${cellTexts.map(() => '---').join(' | ')} |`);
}
}
return mdRows.join('\n');
}
/** Convert TipTap markdown to Google Docs batchUpdate requests. */
function markdownToGoogleDocsRequests(md: string): any[] {
const requests: any[] = [];
const lines = md.split('\n');
let index = 1; // Google Docs indexes start at 1
for (const line of lines) {
if (!line && lines.indexOf(line) < lines.length - 1) {
// Empty line → insert newline
requests.push({
insertText: { location: { index }, text: '\n' },
});
index += 1;
continue;
}
// Headings
const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
if (headingMatch) {
const level = headingMatch[1].length;
const text = headingMatch[2] + '\n';
requests.push({
insertText: { location: { index }, text },
});
requests.push({
updateParagraphStyle: {
range: { startIndex: index, endIndex: index + text.length },
paragraphStyle: { namedStyleType: `HEADING_${level}` },
fields: 'namedStyleType',
},
});
index += text.length;
continue;
}
// Regular text
const text = line.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '') + '\n';
requests.push({
insertText: { location: { index }, text },
});
// Apply bullet/list styles
if (line.match(/^[-*]\s+/)) {
requests.push({
createParagraphBullets: {
range: { startIndex: index, endIndex: index + text.length },
bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE',
},
});
} else if (line.match(/^\d+\.\s+/)) {
requests.push({
createParagraphBullets: {
range: { startIndex: index, endIndex: index + text.length },
bulletPreset: 'NUMBERED_DECIMAL_ALPHA_ROMAN',
},
});
}
index += text.length;
}
return requests;
}
const googleDocsConverter: NoteConverter = {
id: 'google-docs',
name: 'Google Docs',
requiresAuth: true,
async import(input: ImportInput): Promise<ImportResult> {
const token = input.accessToken;
if (!token) throw new Error('Google Docs import requires an access token. Connect your Google account first.');
if (!input.pageIds || input.pageIds.length === 0) {
throw new Error('No Google Docs selected for import');
}
const notes: ConvertedNote[] = [];
const warnings: string[] = [];
for (const docId of input.pageIds) {
try {
// Fetch document
const doc = await googleFetch(`${DOCS_API_BASE}/documents/${docId}`, token);
const title = doc.title || 'Untitled';
// Convert structural elements to markdown, passing inlineObjects for image resolution
const body = doc.body?.content || [];
const inlineObjects = doc.inlineObjects || {};
const mdParts: string[] = [];
for (const element of body) {
const md = structuralElementToMarkdown(element, inlineObjects);
if (md) mdParts.push(md);
}
const markdown = mdParts.join('\n\n');
const tiptapJson = markdownToTiptap(markdown);
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
// Download inline images as attachments
const attachments: { filename: string; data: Uint8Array; mimeType: string }[] = [];
for (const [objectId, obj] of Object.entries(inlineObjects) as [string, any][]) {
const imageProps = obj.inlineObjectProperties?.embeddedObject?.imageProperties;
const contentUri = imageProps?.contentUri;
if (contentUri) {
try {
const res = await fetch(contentUri, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (res.ok) {
const data = new Uint8Array(await res.arrayBuffer());
const ct = res.headers.get('content-type') || 'image/png';
const ext = ct.includes('jpeg') || ct.includes('jpg') ? 'jpg' : ct.includes('gif') ? 'gif' : ct.includes('webp') ? 'webp' : 'png';
attachments.push({ filename: `gdocs-${objectId}.${ext}`, data, mimeType: ct });
}
} catch { /* skip failed image downloads */ }
}
}
notes.push({
title,
content: tiptapJson,
contentPlain,
markdown,
tags: [],
attachments: attachments.length > 0 ? attachments : undefined,
sourceRef: {
source: 'google-docs',
externalId: docId,
lastSyncedAt: Date.now(),
contentHash: hashContent(markdown),
},
});
} catch (err) {
warnings.push(`Failed to import doc ${docId}: ${(err as Error).message}`);
}
}
return { notes, notebookTitle: 'Google Docs Import', warnings };
},
async export(notes: NoteItem[], opts: ExportOptions): Promise<ExportResult> {
const token = opts.accessToken;
if (!token) throw new Error('Google Docs export requires an access token. Connect your Google account first.');
const warnings: string[] = [];
const results: any[] = [];
for (const note of notes) {
try {
// Create a new Google Doc
const doc = await googleFetch(`${DOCS_API_BASE}/documents`, token, {
method: 'POST',
body: JSON.stringify({ title: note.title }),
});
// Convert to markdown
let md: string;
if (note.contentFormat === 'tiptap-json' && note.content) {
md = tiptapToMarkdown(note.content);
} else {
md = note.content?.replace(/<[^>]*>/g, '').trim() || '';
}
// Build batch update requests
const requests = markdownToGoogleDocsRequests(md);
if (requests.length > 0) {
await googleFetch(`${DOCS_API_BASE}/documents/${doc.documentId}:batchUpdate`, token, {
method: 'POST',
body: JSON.stringify({ requests }),
});
}
// Move to folder if parentId specified
if (opts.parentId) {
await googleFetch(
`${DRIVE_API_BASE}/files/${doc.documentId}?addParents=${opts.parentId}`,
token,
{ method: 'PATCH', body: JSON.stringify({}) }
);
}
results.push({ noteId: note.id, googleDocId: doc.documentId });
} catch (err) {
warnings.push(`Failed to export "${note.title}": ${(err as Error).message}`);
}
}
const data = new TextEncoder().encode(JSON.stringify({ exported: results, warnings }));
return {
data,
filename: 'google-docs-export-results.json',
mimeType: 'application/json',
};
},
};
registerConverter(googleDocsConverter);

View File

@ -1,115 +0,0 @@
/**
* Converter registry and shared types for rNotes import/export.
*
* All source-specific converters implement NoteConverter.
* ConvertedNote is the intermediate format between external sources and NoteItem.
*/
import type { NoteItem, SourceRef } from '../schemas';
// ── Shared utilities ──
/** Hash content for conflict detection (shared across all converters). */
export function hashContent(content: string): string {
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0;
}
return Math.abs(hash).toString(36);
}
// ── Shared types ──
export interface ConvertedNote {
title: string;
content: string; // TipTap JSON string
contentPlain: string; // Plain text for search
markdown: string; // Original/generated markdown (for canvas shapes)
tags: string[];
sourceRef: SourceRef;
/** Optional note type override */
type?: NoteItem['type'];
/** Extracted attachments (images, etc.) — saved to /data/files/uploads/ */
attachments?: { filename: string; data: Uint8Array; mimeType: string }[];
}
export interface ImportResult {
notes: ConvertedNote[];
notebookTitle: string;
warnings: string[];
}
export interface ExportResult {
data: Uint8Array;
filename: string;
mimeType: string;
}
export interface NoteConverter {
id: string;
name: string;
requiresAuth: boolean;
/** Import from external source into ConvertedNote[] */
import(input: ImportInput): Promise<ImportResult>;
/** Export NoteItems to external format */
export(notes: NoteItem[], opts: ExportOptions): Promise<ExportResult>;
}
export interface ImportInput {
/** ZIP file data for file-based sources (Logseq, Obsidian) */
fileData?: Uint8Array;
/** Page/doc IDs for API-based sources (Notion, Google Docs) */
pageIds?: string[];
/** Whether to import recursively (sub-pages) */
recursive?: boolean;
/** Access token for authenticated sources */
accessToken?: string;
}
export interface ExportOptions {
/** Notebook title for the export */
notebookTitle?: string;
/** Access token for authenticated sources */
accessToken?: string;
/** Target parent page/folder ID for API-based exports */
parentId?: string;
}
// ── Converter registry ──
const converters = new Map<string, NoteConverter>();
export function registerConverter(converter: NoteConverter): void {
converters.set(converter.id, converter);
}
export function getConverter(id: string): NoteConverter | undefined {
ensureConvertersLoaded();
return converters.get(id);
}
export function getAllConverters(): NoteConverter[] {
ensureConvertersLoaded();
return Array.from(converters.values());
}
// ── Lazy-load converters to avoid circular init ──
// Each converter imports registerConverter from this file; importing them
// synchronously at the module level causes a "Cannot access before
// initialization" error in Bun because the converters Map hasn't been
// assigned yet when the circular import triggers registerConverter().
let _loaded = false;
export function ensureConvertersLoaded(): void {
if (_loaded) return;
_loaded = true;
require('./obsidian');
require('./logseq');
require('./notion');
require('./google-docs');
require('./evernote');
require('./roam');
}

View File

@ -1,9 +0,0 @@
/**
* Logseq converter re-exports from shared and registers with rNotes converter system.
*/
import { logseqConverter } from '../../../shared/converters/logseq';
import { registerConverter } from './index';
export { logseqConverter };
registerConverter(logseqConverter);

View File

@ -1,9 +0,0 @@
/**
* Re-export from shared location.
* Markdown TipTap conversion is now shared across modules.
*/
export {
markdownToTiptap,
tiptapToMarkdown,
extractPlainTextFromTiptap,
} from '../../../shared/markdown-tiptap';

View File

@ -1,461 +0,0 @@
/**
* Notion rNotes converter.
*
* Import: Notion API block types markdown TipTap JSON
* Export: TipTap JSON Notion block format, creates pages via API
*/
import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap';
import { registerConverter, hashContent } from './index';
import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index';
import type { NoteItem } from '../schemas';
const NOTION_API_VERSION = '2022-06-28';
const NOTION_API_BASE = 'https://api.notion.com/v1';
/** Rate-limited fetch for Notion API (3 req/s). */
let lastRequestTime = 0;
async function notionFetch(url: string, opts: RequestInit & { token: string }): Promise<any> {
const now = Date.now();
const elapsed = now - lastRequestTime;
if (elapsed < 334) { // ~3 req/s
await new Promise(r => setTimeout(r, 334 - elapsed));
}
lastRequestTime = Date.now();
const res = await fetch(url, {
...opts,
headers: {
'Authorization': `Bearer ${opts.token}`,
'Notion-Version': NOTION_API_VERSION,
'Content-Type': 'application/json',
...opts.headers,
},
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Notion API error ${res.status}: ${body}`);
}
return res.json();
}
/** Convert a Notion rich text array to markdown. */
function richTextToMarkdown(richText: any[]): string {
if (!richText) return '';
return richText.map((rt: any) => {
let text = rt.plain_text || '';
const ann = rt.annotations || {};
if (ann.code) text = `\`${text}\``;
if (ann.bold) text = `**${text}**`;
if (ann.italic) text = `*${text}*`;
if (ann.strikethrough) text = `~~${text}~~`;
if (rt.href) text = `[${text}](${rt.href})`;
return text;
}).join('');
}
/** Convert a Notion block to markdown. */
function blockToMarkdown(block: any, indent = ''): string {
const type = block.type;
const data = block[type];
if (!data) return '';
switch (type) {
case 'paragraph':
return `${indent}${richTextToMarkdown(data.rich_text)}`;
case 'heading_1':
return `# ${richTextToMarkdown(data.rich_text)}`;
case 'heading_2':
return `## ${richTextToMarkdown(data.rich_text)}`;
case 'heading_3':
return `### ${richTextToMarkdown(data.rich_text)}`;
case 'bulleted_list_item':
return `${indent}- ${richTextToMarkdown(data.rich_text)}`;
case 'numbered_list_item':
return `${indent}1. ${richTextToMarkdown(data.rich_text)}`;
case 'to_do': {
const checked = data.checked ? 'x' : ' ';
return `${indent}- [${checked}] ${richTextToMarkdown(data.rich_text)}`;
}
case 'toggle':
return `${indent}- ${richTextToMarkdown(data.rich_text)}`;
case 'code': {
const lang = data.language || '';
const code = richTextToMarkdown(data.rich_text);
return `\`\`\`${lang}\n${code}\n\`\`\``;
}
case 'quote':
return `> ${richTextToMarkdown(data.rich_text)}`;
case 'callout': {
const icon = data.icon?.emoji || '';
return `> ${icon} ${richTextToMarkdown(data.rich_text)}`;
}
case 'divider':
return '---';
case 'image': {
const url = data.file?.url || data.external?.url || '';
const caption = data.caption ? richTextToMarkdown(data.caption) : '';
return `![${caption}](${url})`;
}
case 'bookmark':
return `[${data.url}](${data.url})`;
case 'table': {
// Tables are handled via children blocks
return '';
}
case 'table_row': {
const cells = (data.cells || []).map((cell: any[]) => richTextToMarkdown(cell));
return `| ${cells.join(' | ')} |`;
}
case 'child_page':
return `**${data.title}** (sub-page)`;
case 'child_database':
return `**${data.title}** (database)`;
default:
// Try to extract rich_text if available
if (data.rich_text) {
return richTextToMarkdown(data.rich_text);
}
return '';
}
}
/** Convert TipTap markdown content to Notion blocks. */
function markdownToNotionBlocks(md: string): any[] {
const lines = md.split('\n');
const blocks: any[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Empty line
if (!line.trim()) {
i++;
continue;
}
// Headings
const headingMatch = line.match(/^(#{1,3})\s+(.+)/);
if (headingMatch) {
const level = headingMatch[1].length;
const text = headingMatch[2];
const type = `heading_${level}` as string;
blocks.push({
type,
[type]: {
rich_text: [{ type: 'text', text: { content: text } }],
},
});
i++;
continue;
}
// Code blocks
if (line.startsWith('```')) {
const lang = line.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
blocks.push({
type: 'code',
code: {
rich_text: [{ type: 'text', text: { content: codeLines.join('\n') } }],
language: lang || 'plain text',
},
});
i++; // skip closing ```
continue;
}
// Blockquotes
if (line.startsWith('> ')) {
blocks.push({
type: 'quote',
quote: {
rich_text: [{ type: 'text', text: { content: line.slice(2) } }],
},
});
i++;
continue;
}
// Task list items
const taskMatch = line.match(/^- \[([ x])\]\s+(.+)/);
if (taskMatch) {
blocks.push({
type: 'to_do',
to_do: {
rich_text: [{ type: 'text', text: { content: taskMatch[2] } }],
checked: taskMatch[1] === 'x',
},
});
i++;
continue;
}
// Bullet list items
if (line.match(/^[-*]\s+/)) {
blocks.push({
type: 'bulleted_list_item',
bulleted_list_item: {
rich_text: [{ type: 'text', text: { content: line.replace(/^[-*]\s+/, '') } }],
},
});
i++;
continue;
}
// Numbered list items
if (line.match(/^\d+\.\s+/)) {
blocks.push({
type: 'numbered_list_item',
numbered_list_item: {
rich_text: [{ type: 'text', text: { content: line.replace(/^\d+\.\s+/, '') } }],
},
});
i++;
continue;
}
// Horizontal rule
if (line.match(/^---+$/)) {
blocks.push({ type: 'divider', divider: {} });
i++;
continue;
}
// Default: paragraph
blocks.push({
type: 'paragraph',
paragraph: {
rich_text: [{ type: 'text', text: { content: line } }],
},
});
i++;
}
return blocks;
}
const notionConverter: NoteConverter = {
id: 'notion',
name: 'Notion',
requiresAuth: true,
async import(input: ImportInput): Promise<ImportResult> {
const token = input.accessToken;
if (!token) throw new Error('Notion import requires an access token. Connect your Notion account first.');
if (!input.pageIds || input.pageIds.length === 0) {
throw new Error('No Notion pages selected for import');
}
const notes: ConvertedNote[] = [];
const warnings: string[] = [];
for (const pageId of input.pageIds) {
try {
// Fetch page metadata
const page = await notionFetch(`${NOTION_API_BASE}/pages/${pageId}`, {
method: 'GET',
token,
});
// Extract title
const titleProp = page.properties?.title || page.properties?.Name;
const title = titleProp?.title?.[0]?.plain_text || 'Untitled';
// Fetch all blocks (paginated)
const allBlocks: any[] = [];
let cursor: string | undefined;
do {
const url = `${NOTION_API_BASE}/blocks/${pageId}/children?page_size=100${cursor ? `&start_cursor=${cursor}` : ''}`;
const result = await notionFetch(url, { method: 'GET', token });
allBlocks.push(...(result.results || []));
cursor = result.has_more ? result.next_cursor : undefined;
} while (cursor);
// Handle table rows specially
const mdParts: string[] = [];
let inTable = false;
let tableRowIndex = 0;
for (const block of allBlocks) {
if (block.type === 'table') {
inTable = true;
tableRowIndex = 0;
// Fetch table children
const tableChildren = await notionFetch(
`${NOTION_API_BASE}/blocks/${block.id}/children?page_size=100`,
{ method: 'GET', token }
);
for (const child of tableChildren.results || []) {
const rowMd = blockToMarkdown(child);
mdParts.push(rowMd);
if (tableRowIndex === 0) {
// Add separator after header
const cellCount = (child.table_row?.cells || []).length;
mdParts.push(`| ${Array(cellCount).fill('---').join(' | ')} |`);
}
tableRowIndex++;
}
inTable = false;
} else {
const md = blockToMarkdown(block);
if (md) mdParts.push(md);
}
}
const markdown = mdParts.join('\n\n');
const tiptapJson = markdownToTiptap(markdown);
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
// Extract tags from Notion properties
const tags: string[] = [];
if (page.properties) {
for (const [key, value] of Object.entries(page.properties) as [string, any][]) {
if (value.type === 'multi_select') {
tags.push(...(value.multi_select || []).map((s: any) => s.name.toLowerCase()));
} else if (value.type === 'select' && value.select) {
tags.push(value.select.name.toLowerCase());
}
}
}
notes.push({
title,
content: tiptapJson,
contentPlain,
markdown,
tags: [...new Set(tags)],
sourceRef: {
source: 'notion',
externalId: pageId,
lastSyncedAt: Date.now(),
contentHash: hashContent(markdown),
},
});
// Recursively import child pages if requested
if (input.recursive) {
for (const block of allBlocks) {
if (block.type === 'child_page') {
try {
const childResult = await this.import({
...input,
pageIds: [block.id],
recursive: true,
});
notes.push(...childResult.notes);
warnings.push(...childResult.warnings);
} catch (err) {
warnings.push(`Failed to import child page "${block.child_page?.title}": ${(err as Error).message}`);
}
}
}
}
} catch (err) {
warnings.push(`Failed to import page ${pageId}: ${(err as Error).message}`);
}
}
return { notes, notebookTitle: 'Notion Import', warnings };
},
async export(notes: NoteItem[], opts: ExportOptions): Promise<ExportResult> {
const token = opts.accessToken;
if (!token) throw new Error('Notion export requires an access token. Connect your Notion account first.');
const warnings: string[] = [];
const results: any[] = [];
for (const note of notes) {
try {
// Convert to markdown first
let md: string;
if (note.contentFormat === 'tiptap-json' && note.content) {
md = tiptapToMarkdown(note.content);
} else {
md = note.content?.replace(/<[^>]*>/g, '').trim() || '';
}
// Convert markdown to Notion blocks
const blocks = markdownToNotionBlocks(md);
// Create page in Notion
// If parentId is provided, create as child page; otherwise create in workspace root
const parent = opts.parentId
? { page_id: opts.parentId }
: { type: 'page_id' as const, page_id: opts.parentId || '' };
// For workspace-level pages, we need a database or page parent
// Default to creating standalone pages
const createBody: any = {
parent: opts.parentId
? { page_id: opts.parentId }
: { type: 'workspace', workspace: true },
properties: {
title: {
title: [{ type: 'text', text: { content: note.title } }],
},
},
children: blocks.slice(0, 100), // Notion limit: 100 blocks per request
};
const page = await notionFetch(`${NOTION_API_BASE}/pages`, {
method: 'POST',
token,
body: JSON.stringify(createBody),
});
results.push({ noteId: note.id, notionPageId: page.id });
// If more than 100 blocks, append in batches
if (blocks.length > 100) {
for (let i = 100; i < blocks.length; i += 100) {
const batch = blocks.slice(i, i + 100);
await notionFetch(`${NOTION_API_BASE}/blocks/${page.id}/children`, {
method: 'PATCH',
token,
body: JSON.stringify({ children: batch }),
});
}
}
} catch (err) {
warnings.push(`Failed to export "${note.title}": ${(err as Error).message}`);
}
}
// Return results as JSON since we don't produce a file
const data = new TextEncoder().encode(JSON.stringify({ exported: results, warnings }));
return {
data,
filename: 'notion-export-results.json',
mimeType: 'application/json',
};
},
};
registerConverter(notionConverter);

View File

@ -1,9 +0,0 @@
/**
* Obsidian converter re-exports from shared and registers with rNotes converter system.
*/
import { obsidianConverter } from '../../../shared/converters/obsidian';
import { registerConverter } from './index';
export { obsidianConverter };
registerConverter(obsidianConverter);

View File

@ -1,171 +0,0 @@
/**
* Roam Research JSON rNotes converter.
*
* Import: Roam JSON export ([{ title, children: [{ string, children }] }])
* Converts recursive tree indented markdown bullets.
* Handles Roam syntax: ((block-refs)), {{embed}}, ^^highlight^^, [[page refs]]
*/
import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap';
import { registerConverter, hashContent } from './index';
import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index';
import type { NoteItem } from '../schemas';
interface RoamBlock {
string?: string;
uid?: string;
children?: RoamBlock[];
'create-time'?: number;
'edit-time'?: number;
}
interface RoamPage {
title: string;
uid?: string;
children?: RoamBlock[];
'create-time'?: number;
'edit-time'?: number;
}
/** Convert Roam block tree to indented markdown. */
function blocksToMarkdown(blocks: RoamBlock[], depth = 0): string {
const lines: string[] = [];
for (const block of blocks) {
if (!block.string && (!block.children || block.children.length === 0)) continue;
if (block.string) {
const indent = ' '.repeat(depth);
const text = convertRoamSyntax(block.string);
lines.push(`${indent}- ${text}`);
}
if (block.children && block.children.length > 0) {
lines.push(blocksToMarkdown(block.children, depth + 1));
}
}
return lines.join('\n');
}
/** Convert Roam-specific syntax to standard markdown. */
function convertRoamSyntax(text: string): string {
// [[page references]] → [page references](page references)
text = text.replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)');
// ((block refs)) → (ref)
text = text.replace(/\(\(([a-zA-Z0-9_-]+)\)\)/g, '(ref:$1)');
// {{embed: ((ref))}} → (embedded ref)
text = text.replace(/\{\{embed:\s*\(\(([^)]+)\)\)\}\}/g, '> (embedded: $1)');
// {{[[TODO]]}} and {{[[DONE]]}}
text = text.replace(/\{\{\[\[TODO\]\]\}\}/g, '- [ ]');
text = text.replace(/\{\{\[\[DONE\]\]\}\}/g, '- [x]');
// ^^highlight^^ → ==highlight== (or just **highlight**)
text = text.replace(/\^\^([^^]+)\^\^/g, '**$1**');
// **bold** already valid markdown
// __italic__ → *italic*
text = text.replace(/__([^_]+)__/g, '*$1*');
return text;
}
/** Extract tags from Roam page content (inline [[refs]] and #tags). */
function extractRoamTags(blocks: RoamBlock[]): string[] {
const tags = new Set<string>();
function walk(items: RoamBlock[]) {
for (const block of items) {
if (block.string) {
// [[page refs]]
const pageRefs = block.string.match(/\[\[([^\]]+)\]\]/g);
if (pageRefs) {
for (const ref of pageRefs) {
const tag = ref.slice(2, -2).toLowerCase().replace(/\s+/g, '-');
if (tag.length <= 30) tags.add(tag); // Skip very long refs
}
}
// #tags
const hashTags = block.string.match(/#([a-zA-Z0-9_-]+)/g);
if (hashTags) {
for (const t of hashTags) tags.add(t.slice(1).toLowerCase());
}
}
if (block.children) walk(block.children);
}
}
walk(blocks);
return Array.from(tags).slice(0, 20); // Cap tags
}
const roamConverter: NoteConverter = {
id: 'roam',
name: 'Roam Research',
requiresAuth: false,
async import(input: ImportInput): Promise<ImportResult> {
if (!input.fileData) {
throw new Error('Roam import requires a JSON file');
}
const jsonStr = new TextDecoder().decode(input.fileData);
let pages: RoamPage[];
try {
pages = JSON.parse(jsonStr);
} catch {
throw new Error('Invalid Roam Research JSON format');
}
if (!Array.isArray(pages)) {
throw new Error('Expected a JSON array of Roam pages');
}
const notes: ConvertedNote[] = [];
const warnings: string[] = [];
for (const page of pages) {
try {
if (!page.title) continue;
const children = page.children || [];
const markdown = children.length > 0
? blocksToMarkdown(children)
: '';
if (!markdown.trim() && children.length === 0) continue; // Skip empty pages
const tiptapJson = markdownToTiptap(markdown);
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
const tags = extractRoamTags(children);
notes.push({
title: page.title,
content: tiptapJson,
contentPlain,
markdown,
tags,
sourceRef: {
source: 'roam',
externalId: page.uid || page.title,
lastSyncedAt: Date.now(),
contentHash: hashContent(markdown),
},
});
} catch (err) {
warnings.push(`Failed to parse page "${page.title}": ${(err as Error).message}`);
}
}
return { notes, notebookTitle: 'Roam Research Import', warnings };
},
async export(): Promise<ExportResult> {
throw new Error('Roam Research export is not supported — use Roam\'s native import');
},
};
registerConverter(roamConverter);

View File

@ -1,207 +0,0 @@
/**
* Sync service for rNotes handles re-fetching, conflict detection,
* and merging for imported notes.
*
* Conflict policy:
* - Remote-only-changed auto-update
* - Local-only-changed keep local
* - Both changed mark conflict (stores remote version in conflictContent)
*/
import type { NoteItem, SourceRef } from '../schemas';
import { getConverter, hashContent } from './index';
import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap';
export interface SyncResult {
action: 'unchanged' | 'updated' | 'conflict' | 'error';
remoteHash?: string;
error?: string;
updatedContent?: string; // TipTap JSON of remote content
updatedPlain?: string;
updatedMarkdown?: string;
}
/** Sync a single Notion note by re-fetching from API. */
export async function syncNotionNote(note: NoteItem, token: string): Promise<SyncResult> {
if (!note.sourceRef || note.sourceRef.source !== 'notion') {
return { action: 'error', error: 'Note is not from Notion' };
}
try {
const converter = getConverter('notion');
if (!converter) return { action: 'error', error: 'Notion converter not available' };
const result = await converter.import({
pageIds: [note.sourceRef.externalId],
accessToken: token,
});
if (result.notes.length === 0) {
return { action: 'error', error: 'Could not fetch page from Notion' };
}
const remote = result.notes[0];
const remoteHash = remote.sourceRef.contentHash || '';
const localHash = note.sourceRef.contentHash || '';
// Compare hashes
if (remoteHash === localHash) {
return { action: 'unchanged' };
}
// Check if local was modified since last sync
const currentLocalHash = hashContent(
note.contentFormat === 'tiptap-json' ? tiptapToMarkdown(note.content) : note.content
);
const localModified = currentLocalHash !== localHash;
if (!localModified) {
// Only remote changed — auto-update
return {
action: 'updated',
remoteHash,
updatedContent: remote.content,
updatedPlain: remote.contentPlain,
updatedMarkdown: remote.markdown,
};
}
// Both changed — conflict
return {
action: 'conflict',
remoteHash,
updatedContent: remote.content,
updatedPlain: remote.contentPlain,
updatedMarkdown: remote.markdown,
};
} catch (err) {
return { action: 'error', error: (err as Error).message };
}
}
/** Sync a single Google Docs note by re-fetching from API. */
export async function syncGoogleDocsNote(note: NoteItem, token: string): Promise<SyncResult> {
if (!note.sourceRef || note.sourceRef.source !== 'google-docs') {
return { action: 'error', error: 'Note is not from Google Docs' };
}
try {
const converter = getConverter('google-docs');
if (!converter) return { action: 'error', error: 'Google Docs converter not available' };
const result = await converter.import({
pageIds: [note.sourceRef.externalId],
accessToken: token,
});
if (result.notes.length === 0) {
return { action: 'error', error: 'Could not fetch doc from Google Docs' };
}
const remote = result.notes[0];
const remoteHash = remote.sourceRef.contentHash || '';
const localHash = note.sourceRef.contentHash || '';
if (remoteHash === localHash) {
return { action: 'unchanged' };
}
const currentLocalHash = hashContent(
note.contentFormat === 'tiptap-json' ? tiptapToMarkdown(note.content) : note.content
);
const localModified = currentLocalHash !== localHash;
if (!localModified) {
return {
action: 'updated',
remoteHash,
updatedContent: remote.content,
updatedPlain: remote.contentPlain,
updatedMarkdown: remote.markdown,
};
}
return {
action: 'conflict',
remoteHash,
updatedContent: remote.content,
updatedPlain: remote.contentPlain,
updatedMarkdown: remote.markdown,
};
} catch (err) {
return { action: 'error', error: (err as Error).message };
}
}
/** Sync file-based notes by re-parsing a ZIP and matching by externalId. */
export async function syncFileBasedNotes(
notes: NoteItem[],
zipData: Uint8Array,
source: 'obsidian' | 'logseq',
): Promise<Map<string, SyncResult>> {
const results = new Map<string, SyncResult>();
try {
const converter = getConverter(source);
if (!converter) {
for (const n of notes) results.set(n.id, { action: 'error', error: `${source} converter not available` });
return results;
}
const importResult = await converter.import({ fileData: zipData });
const remoteMap = new Map<string, typeof importResult.notes[0]>();
for (const rn of importResult.notes) {
remoteMap.set(rn.sourceRef.externalId, rn);
}
for (const note of notes) {
if (!note.sourceRef) {
results.set(note.id, { action: 'error', error: 'No sourceRef' });
continue;
}
const remote = remoteMap.get(note.sourceRef.externalId);
if (!remote) {
results.set(note.id, { action: 'unchanged' }); // Not found in ZIP — keep as-is
continue;
}
const remoteHash = remote.sourceRef.contentHash || '';
const localHash = note.sourceRef.contentHash || '';
if (remoteHash === localHash) {
results.set(note.id, { action: 'unchanged' });
continue;
}
const currentLocalHash = hashContent(
note.contentFormat === 'tiptap-json' ? tiptapToMarkdown(note.content) : note.content
);
const localModified = currentLocalHash !== localHash;
if (!localModified) {
results.set(note.id, {
action: 'updated',
remoteHash,
updatedContent: remote.content,
updatedPlain: remote.contentPlain,
updatedMarkdown: remote.markdown,
});
} else {
results.set(note.id, {
action: 'conflict',
remoteHash,
updatedContent: remote.content,
updatedPlain: remote.contentPlain,
updatedMarkdown: remote.markdown,
});
}
}
} catch (err) {
for (const n of notes) {
results.set(n.id, { action: 'error', error: (err as Error).message });
}
}
return results;
}

View File

@ -1,239 +1,62 @@
/**
* Notes module landing page static HTML, no React.
* rNotes module landing page vault browser for Obsidian & Logseq.
*/
export function renderLanding(): string {
return `
<!-- Hero -->
<div class="rl-hero">
<span class="rl-tagline">rNotes</span>
<h1 class="rl-heading" style="background:linear-gradient(135deg,#f59e0b,#f97316);-webkit-background-clip:text;background-clip:text">(You)rNotes, your thoughts unbound.</h1>
<p class="rl-subtitle" style="background:linear-gradient(135deg,#f59e0b,#f97316);-webkit-background-clip:text;background-clip:text">Capture Everything, Find Anything, and Share your Insights</p>
<h1 class="rl-heading" style="background:linear-gradient(135deg,#f59e0b,#f97316);-webkit-background-clip:text;background-clip:text">Your vaults, synced and browsable.</h1>
<p class="rl-subtitle" style="background:linear-gradient(135deg,#f59e0b,#f97316);-webkit-background-clip:text;background-clip:text">Obsidian &amp; Logseq Vault Sync</p>
<p class="rl-subtext">
Notes, clips, voice recordings, and live transcription &mdash; all in one place.
Speak and watch your words appear in real time, or drop in audio and video files to transcribe offline.
Upload your Obsidian or Logseq vault, browse files, search across notes,
and visualize wikilink graphs &mdash; all from your rSpace.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary" id="ml-primary">Open Notebook</a>
<a href="#transcription-demo" class="rl-cta-secondary">Transcribe</a>
<a href="#extension-download" class="rl-cta-secondary">Get Extension</a>
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary" id="ml-primary">Open Vault Browser</a>
<a href="#features" class="rl-cta-secondary">Features</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-notes-app')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Live Transcription Demo -->
<section class="rl-section" id="transcription-demo">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Live Transcription Demo</h2>
<p class="rl-subtext" style="text-align:center">Try it right here &mdash; click the mic and start speaking.</p>
<div style="max-width:640px;margin:2rem auto;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:1rem;padding:1.5rem;position:relative">
<!-- Unsupported fallback (hidden by default, shown via JS) -->
<div id="transcription-unsupported" style="display:none;text-align:center;padding:2rem 1rem;color:#94a3b8">
<div style="font-size:2rem;margin-bottom:0.75rem">&#9888;&#65039;</div>
<p style="margin:0 0 0.5rem">Live transcription requires <strong>Chrome</strong> or <strong>Edge</strong> (Web Speech API).</p>
<p style="margin:0;font-size:0.85rem;color:#64748b">Try opening this page in a Chromium-based browser to test the demo.</p>
</div>
<!-- Demo UI (hidden if unsupported) -->
<div id="transcription-ui">
<!-- Controls -->
<div style="display:flex;align-items:center;justify-content:center;gap:1rem;margin-bottom:1.25rem">
<button id="mic-btn" style="width:56px;height:56px;border-radius:50%;border:2px solid rgba(245,158,11,0.4);background:rgba(245,158,11,0.1);color:#f59e0b;font-size:1.5rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all 0.2s" title="Start transcription">
&#127908;
</button>
<div style="text-align:left">
<div id="mic-status" style="font-size:0.9rem;color:#94a3b8">Click mic to start</div>
<div id="mic-timer" style="font-size:0.75rem;color:#64748b;font-variant-numeric:tabular-nums">00:00</div>
</div>
<div id="live-indicator" style="display:none;background:rgba(239,68,68,0.15);color:#ef4444;font-size:0.7rem;font-weight:600;padding:0.2rem 0.6rem;border-radius:9999px;text-transform:uppercase;letter-spacing:0.05em">
&#9679; Live
</div>
</div>
<!-- Transcript area -->
<div id="transcript-area" style="min-height:120px;max-height:240px;overflow-y:auto;background:rgba(0,0,0,0.2);border-radius:0.5rem;padding:1rem;font-size:0.9rem;line-height:1.6;color:#e2e8f0">
<span style="color:#64748b;font-style:italic">Your transcript will appear here&hellip;</span>
</div>
</div>
<!-- Capability badges -->
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;justify-content:center;margin-top:1.25rem">
<span class="rl-badge" style="background:rgba(34,197,94,0.15);color:#22c55e">&#9679; Live streaming</span>
<span class="rl-badge" style="background:rgba(59,130,246,0.15);color:#3b82f6">&#127925; Audio file upload</span>
<span class="rl-badge" style="background:rgba(168,85,247,0.15);color:#a855f7">&#127909; Video transcription</span>
<span class="rl-badge" style="background:rgba(245,158,11,0.15);color:#f59e0b">&#128268; Offline (Parakeet.js)</span>
</div>
</div>
</div>
</section>
<!-- Transcription Demo Script -->
<script>
(function() {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
var ui = document.getElementById('transcription-ui');
var unsupported = document.getElementById('transcription-unsupported');
if (!SpeechRecognition) {
if (ui) ui.style.display = 'none';
if (unsupported) unsupported.style.display = 'block';
return;
}
var recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
var micBtn = document.getElementById('mic-btn');
var micStatus = document.getElementById('mic-status');
var micTimer = document.getElementById('mic-timer');
var liveIndicator = document.getElementById('live-indicator');
var transcriptArea = document.getElementById('transcript-area');
var isListening = false;
var timerInterval = null;
var seconds = 0;
var finalTranscript = '';
function formatTime(s) {
var m = Math.floor(s / 60);
var sec = s % 60;
return (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
}
function startTimer() {
seconds = 0;
micTimer.textContent = '00:00';
timerInterval = setInterval(function() {
seconds++;
micTimer.textContent = formatTime(seconds);
}, 1000);
}
function stopTimer() {
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
}
micBtn.addEventListener('click', function() {
if (!isListening) {
finalTranscript = '';
transcriptArea.innerHTML = '';
recognition.start();
} else {
recognition.stop();
}
});
recognition.onstart = function() {
isListening = true;
micBtn.style.background = 'rgba(239,68,68,0.2)';
micBtn.style.borderColor = '#ef4444';
micBtn.style.color = '#ef4444';
micBtn.title = 'Stop transcription';
micStatus.textContent = 'Listening...';
micStatus.style.color = '#ef4444';
liveIndicator.style.display = 'block';
startTimer();
};
recognition.onend = function() {
isListening = false;
micBtn.style.background = 'rgba(245,158,11,0.1)';
micBtn.style.borderColor = 'rgba(245,158,11,0.4)';
micBtn.style.color = '#f59e0b';
micBtn.title = 'Start transcription';
micStatus.textContent = 'Click mic to start';
micStatus.style.color = '#94a3b8';
liveIndicator.style.display = 'none';
stopTimer();
};
recognition.onresult = function(event) {
var interim = '';
for (var i = event.resultIndex; i < event.results.length; i++) {
var transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalTranscript += transcript + ' ';
} else {
interim += transcript;
}
}
transcriptArea.innerHTML = finalTranscript +
(interim ? '<span style="color:#94a3b8">' + interim + '</span>' : '');
transcriptArea.scrollTop = transcriptArea.scrollHeight;
};
recognition.onerror = function(event) {
if (event.error === 'not-allowed') {
micStatus.textContent = 'Microphone access denied';
micStatus.style.color = '#ef4444';
}
};
})();
</script>
<!-- Features -->
<section class="rl-section">
<section class="rl-section" id="features">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">What rNotes Handles</h2>
<h2 class="rl-heading" style="text-align:center">What rNotes Does</h2>
<div class="rl-grid-3">
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128221;</div>
<h3>Rich Text Notes</h3>
<p>Write with a full TipTap editor &mdash; formatting, code blocks, checklists, and embeds. Dual-format storage keeps Markdown portable.</p>
<div class="rl-icon-box">&#128193;</div>
<h3>Vault Upload</h3>
<p>Upload your Obsidian or Logseq vault as a ZIP. Metadata is indexed &mdash; titles, tags, frontmatter, and wikilinks.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#127908;</div>
<h3>Voice &amp; Transcription</h3>
<p>Record voice notes with live transcription via Web Speech API. Drop in audio or video files and get full transcripts &mdash; all in the browser.</p>
<div class="rl-icon-box">&#128270;</div>
<h3>Search &amp; Browse</h3>
<p>Full file tree with folder grouping, search by title or tags, and read-only markdown preview.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#127991;</div>
<h3>Tagging &amp; Organization</h3>
<p>Tag freely, organize into notebooks, and search everything. Filtered views surface the right cards at the right time.</p>
<div class="rl-icon-box">&#128279;</div>
<h3>Wikilink Graph</h3>
<p>Visualize how your notes connect via wikilinks. See the knowledge graph of your vault at a glance.</p>
</div>
</div>
</div>
</section>
<!-- Chrome Extension -->
<section class="rl-section rl-section--alt" id="extension-download">
<!-- Supported Sources -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Chrome Extension</h2>
<p class="rl-subtext" style="text-align:center">Clip pages, record voice notes, and transcribe &mdash; right from the toolbar.</p>
<div class="rl-grid-2" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1.5rem;max-width:860px;margin:2rem auto">
<h2 class="rl-heading" style="text-align:center">Supported Vault Sources</h2>
<div class="rl-grid-2" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1.5rem;max-width:700px;margin:2rem auto">
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128203;</div>
<h3>Web Clipper</h3>
<p>Save any page as a note with one click &mdash; article text, selection, or full HTML.</p>
<div class="rl-icon-box" style="color:#a78bfa">&#9670;</div>
<h3>Obsidian</h3>
<p>ZIP your vault folder and upload. Frontmatter, tags, and wikilinks are fully parsed.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#127908;</div>
<h3>Voice Recording</h3>
<p>Press <kbd style="background:rgba(255,255,255,0.1);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.8rem">Ctrl+Shift+V</kbd> to start recording and transcribing from any tab.</p>
<div class="rl-icon-box" style="color:#60a5fa">&#9679;</div>
<h3>Logseq</h3>
<p>Export your Logseq graph as a ZIP. Outliner structure, properties, and page links are preserved.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128275;</div>
<h3>Article Unlock</h3>
<p>Bypass soft paywalls by fetching archived versions &mdash; read the article, then save it to your notebook.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128268;</div>
<h3>Offline Transcription</h3>
<p>Parakeet.js runs entirely in-browser &mdash; your audio never leaves the device.</p>
</div>
</div>
<div style="text-align:center;margin-top:1.5rem">
<a href="/rnotes/extension/download" class="rl-cta-primary" style="display:inline-flex;align-items:center;gap:0.5rem">
&#11015; Download Extension
</a>
<p style="margin-top:0.75rem;font-size:0.8rem;color:#64748b">
Unzip, then load unpacked at <code style="font-size:0.75rem;color:#94a3b8">chrome://extensions</code>
</p>
</div>
</div>
</section>
@ -242,177 +65,36 @@ export function renderLanding(): string {
<section class="rl-section">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
<div class="rl-grid-4" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem">
<div class="rl-grid-3">
<div class="rl-step">
<div class="rl-step__num">1</div>
<h3>Live Transcribe</h3>
<p>Speak and watch words appear in real time via the Web Speech API. No uploads, no waiting.</p>
<h3>Upload</h3>
<p>ZIP your vault folder and upload it to rNotes. The file stays on the server, only metadata is indexed.</p>
</div>
<div class="rl-step">
<div class="rl-step__num">2</div>
<h3>Audio &amp; Video</h3>
<p>Drop files and get full transcripts via Parakeet, running entirely in-browser. Supports MP3, WAV, MP4, and more.</p>
<h3>Browse</h3>
<p>Explore your vault's file tree, search notes, and preview markdown content &mdash; all read-only.</p>
</div>
<div class="rl-step">
<div class="rl-step__num">3</div>
<h3>Notebooks &amp; Tags</h3>
<p>Organize transcripts alongside notes, clips, code, and files. Tag, search, and filter across everything.</p>
</div>
<div class="rl-step">
<div class="rl-step__num">4</div>
<h3>Private &amp; Offline</h3>
<p>Parakeet.js runs in-browser &mdash; audio never leaves your device. Works offline once the model is cached.</p>
<h3>Connect</h3>
<p>View wikilink graphs, follow backlinks, and discover connections across your knowledge base.</p>
</div>
</div>
</div>
</section>
<!-- Memory Cards -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Memory Cards</h2>
<p class="rl-subtext" style="text-align:center">
Every note is a Memory Card &mdash; a typed, structured unit of knowledge with hierarchy,
properties, and attachments. Designed for round-trip interoperability with Logseq.
</p>
<div class="rl-grid-3">
<!-- 7 Card Types -->
<div class="rl-card">
<div class="rl-icon-box">&#127991;</div>
<h3>7 Card Types</h3>
<div style="display:flex;flex-wrap:wrap;gap:0.35rem;margin-bottom:0.75rem">
<span class="rl-badge" style="background:rgba(245,158,11,0.2);color:#f59e0b">note</span>
<span class="rl-badge" style="background:rgba(59,130,246,0.2);color:#3b82f6">link</span>
<span class="rl-badge" style="background:rgba(34,197,94,0.2);color:#22c55e">task</span>
<span class="rl-badge" style="background:rgba(234,179,8,0.2);color:#eab308">idea</span>
<span class="rl-badge" style="background:rgba(168,85,247,0.2);color:#a855f7">person</span>
<span class="rl-badge" style="background:rgba(236,72,153,0.2);color:#ec4899">reference</span>
<span class="rl-badge" style="background:rgba(100,116,139,0.2);color:#94a3b8">file</span>
</div>
<p>Each card type has distinct styling and behavior. Typed notes surface in filtered views and canvas visualizations.</p>
</div>
<!-- Hierarchy & Properties -->
<div class="rl-card">
<div class="rl-icon-box">&#128194;</div>
<h3>Hierarchy &amp; Properties</h3>
<p>
Nest cards under parents to build knowledge trees. Add structured
<code style="font-size:0.8rem;color:rgba(245,158,11,0.8)">key:: value</code>
properties &mdash; compatible with Logseq's property syntax.
</p>
<div style="margin-top:0.5rem;font-family:monospace;font-size:0.75rem;color:#64748b;line-height:1.7">
<div><span style="color:#94a3b8">type::</span> idea</div>
<div><span style="color:#94a3b8">status::</span> doing</div>
<div><span style="color:#94a3b8">tags::</span> #research, #web3</div>
</div>
</div>
<!-- Data Source Integrations -->
<div class="rl-card">
<div class="rl-icon-box">&#128260;</div>
<h3>Import &amp; Export</h3>
<p>
Bring your notes from <strong>Logseq</strong>, <strong>Obsidian</strong>,
<strong>Notion</strong>, <strong>Google Docs</strong>, <strong>Evernote</strong>,
and <strong>Roam Research</strong>. Drop any .md, .txt, or .html file directly.
Export back to any format anytime &mdash; your data, your choice.
</p>
<div style="display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.5rem">
<span class="rl-badge" style="background:rgba(34,197,94,0.2);color:#22c55e">Logseq</span>
<span class="rl-badge" style="background:rgba(139,92,246,0.2);color:#8b5cf6">Obsidian</span>
<span class="rl-badge" style="background:rgba(59,130,246,0.2);color:#3b82f6">Notion</span>
<span class="rl-badge" style="background:rgba(245,158,11,0.2);color:#f59e0b">Google Docs</span>
<span class="rl-badge" style="background:rgba(16,185,129,0.2);color:#10b981">Evernote</span>
<span class="rl-badge" style="background:rgba(236,72,153,0.2);color:#ec4899">Roam</span>
<span class="rl-badge" style="background:rgba(107,114,128,0.2);color:#9ca3af">Files</span>
</div>
</div>
<!-- Dual Format Storage -->
<div class="rl-card">
<div class="rl-icon-box">&#128196;</div>
<h3>Dual Format Storage</h3>
<p>
Every card stores rich TipTap JSON for editing and portable Markdown for search, export, and interoperability.
Write once, read anywhere.
</p>
</div>
<!-- Structured Attachments -->
<div class="rl-card">
<div class="rl-icon-box">&#128206;</div>
<h3>Structured Attachments</h3>
<p>
Attach images, PDFs, audio, and files to any card with roles (primary, preview, supporting) and captions.
Thumbnails render inline.
</p>
</div>
<!-- FUN Model -->
<div class="rl-card">
<div class="rl-icon-box">&#10084;</div>
<h3>FUN, Not CRUD</h3>
<p>
<span style="color:#f59e0b;font-weight:600">F</span>orget,
<span style="color:#f59e0b;font-weight:600">U</span>pdate,
<span style="color:#f59e0b;font-weight:600">N</span>ew &mdash;
nothing is permanently destroyed. Forgotten cards are archived and can be remembered at any time.
</p>
</div>
</div>
</div>
</section>
<!-- Built on Open Source -->
<section class="rl-section">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rNotes.</p>
<div class="rl-grid-4" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem">
<div class="rl-card rl-card--center">
<h3>Automerge</h3>
<p>Local-first CRDT for conflict-free real-time collaboration. Your notes sync across devices without a central server.</p>
</div>
<div class="rl-card rl-card--center">
<h3>Web Speech API</h3>
<p>Browser-native live transcription &mdash; speak and watch your words appear in real time.</p>
</div>
<div class="rl-card rl-card--center">
<h3>Parakeet.js</h3>
<p>NVIDIA&rsquo;s in-browser speech recognition. Transcribe audio and video files offline &mdash; nothing leaves your device.</p>
</div>
<div class="rl-card rl-card--center">
<h3>Hono</h3>
<p>Ultra-fast, lightweight API framework powering the rNotes backend.</p>
</div>
</div>
</div>
</section>
<!-- Your Data, Protected -->
<!-- Looking for the Editor? -->
<section class="rl-section rl-section--alt">
<div class="rl-container" style="text-align:center">
<h2 class="rl-heading">Your Data, Protected</h2>
<p class="rl-subtext">How rNotes keeps your information safe.</p>
<div class="rl-grid-3">
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128274;</div>
<h3>End-to-End Encryption</h3>
<span class="rl-badge">Coming Soon</span>
<p>All content encrypted before it leaves your device. Not even the server can read it.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128373;</div>
<h3>Zero-Knowledge Architecture</h3>
<span class="rl-badge">Coming Soon</span>
<p>The server processes your requests without ever seeing your data in the clear.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#127968;</div>
<h3>Self-Hosted</h3>
<p>Run on your own infrastructure. Your server, your rules, your data.</p>
</div>
<h2 class="rl-heading">Looking for the Rich Editor?</h2>
<p class="rl-subtext">
The full TipTap editor with notebooks, voice transcription, AI summarization,
and import from 6 sources has moved to <strong>rDocs</strong>.
</p>
<div class="rl-cta-row">
<a href="/rdocs" class="rl-cta-primary">Open rDocs</a>
</div>
</div>
</section>
@ -420,11 +102,10 @@ export function renderLanding(): string {
<!-- CTA -->
<section class="rl-section">
<div class="rl-container" style="text-align:center">
<h2 class="rl-heading">(You)rNotes, your thoughts unbound.</h2>
<p class="rl-subtext">Try the demo or create a space to get started.</p>
<h2 class="rl-heading">Sync Your Vault</h2>
<p class="rl-subtext">Upload your Obsidian or Logseq vault to get started.</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary">Open Notebook</a>
<a href="#transcription-demo" class="rl-cta-secondary">Transcribe</a>
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary">Open Vault Browser</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
</div>

View File

@ -1,210 +0,0 @@
/**
* rNotes Local-First Client
*
* Wraps the shared local-first stack (DocSyncManager + EncryptedDocStore)
* into a notes-specific API. This replaces the manual WebSocket + REST
* approach in folk-notes-app with proper offline support and encryption.
*
* Usage:
* const client = new NotesLocalFirstClient(space);
* await client.init();
* const notebooks = client.listNotebooks();
* client.onChange(docId, (doc) => { ... });
* client.disconnect();
*/
import * as Automerge from '@automerge/automerge';
import { DocumentManager } from '../../shared/local-first/document';
import type { DocumentId } from '../../shared/local-first/document';
import { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto';
import { notebookSchema, notebookDocId } from './schemas';
import type { NotebookDoc, NoteItem, NotebookMeta } from './schemas';
export class NotesLocalFirstClient {
#space: string;
#documents: DocumentManager;
#store: EncryptedDocStore;
#sync: DocSyncManager;
#initialized = false;
constructor(space: string, docCrypto?: DocCrypto) {
this.#space = space;
this.#documents = new DocumentManager();
this.#store = new EncryptedDocStore(space, docCrypto);
this.#sync = new DocSyncManager({
documents: this.#documents,
store: this.#store,
});
// Register the notebook schema
this.#documents.registerSchema(notebookSchema);
}
get isConnected(): boolean { return this.#sync.isConnected; }
get isInitialized(): boolean { return this.#initialized; }
/**
* Initialize: open IndexedDB, load cached docs, connect to sync server.
*/
async init(): Promise<void> {
if (this.#initialized) return;
// Open IndexedDB store
await this.#store.open();
// Load all cached notebook docs from IndexedDB in parallel
const cachedIds = await this.#store.listByModule('notes', 'notebooks');
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<NotebookDoc>(docId, notebookSchema, binary);
}
// Preload sync states in parallel before connecting
await this.#sync.preloadSyncStates(cachedIds);
// Connect to sync server
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try {
await this.#sync.connect(wsUrl, this.#space);
} catch {
console.warn('[NotesClient] WebSocket connection failed, working offline');
}
this.#initialized = true;
}
/**
* Subscribe to a specific notebook doc for real-time sync.
*/
async subscribeNotebook(notebookId: string): Promise<NotebookDoc | null> {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
// Open or get existing doc
let doc = this.#documents.get<NotebookDoc>(docId);
if (!doc) {
// Try loading from IndexedDB
const binary = await this.#store.load(docId);
if (binary) {
doc = this.#documents.open<NotebookDoc>(docId, notebookSchema, binary);
} else {
// Create empty placeholder — server will fill via sync
doc = this.#documents.open<NotebookDoc>(docId, notebookSchema);
}
}
// Subscribe for sync
await this.#sync.subscribe([docId]);
return doc ?? null;
}
/**
* Unsubscribe from a notebook's sync.
*/
unsubscribeNotebook(notebookId: string): void {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
this.#sync.unsubscribe([docId]);
}
/**
* Get a notebook doc (already opened).
*/
getNotebook(notebookId: string): NotebookDoc | undefined {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
return this.#documents.get<NotebookDoc>(docId);
}
/**
* List all notebook IDs for this space.
*/
listNotebookIds(): string[] {
return this.#documents.list(this.#space, 'notes');
}
/**
* Update a note within a notebook (creates if it doesn't exist).
*/
updateNote(notebookId: string, noteId: string, changes: Partial<NoteItem>): void {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
this.#sync.change<NotebookDoc>(docId, `Update note ${noteId}`, (d) => {
if (!d.items[noteId]) {
d.items[noteId] = {
id: noteId,
notebookId,
authorId: null,
title: '',
content: '',
contentPlain: '',
type: 'NOTE',
url: null,
language: null,
fileUrl: null,
mimeType: null,
fileSize: null,
duration: null,
isPinned: false,
sortOrder: 0,
tags: [],
createdAt: Date.now(),
updatedAt: Date.now(),
...changes,
};
} else {
const item = d.items[noteId];
Object.assign(item, changes);
item.updatedAt = Date.now();
}
});
}
/**
* Delete a note from a notebook.
*/
deleteNote(notebookId: string, noteId: string): void {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
this.#sync.change<NotebookDoc>(docId, `Delete note ${noteId}`, (d) => {
delete d.items[noteId];
});
}
/**
* Update notebook metadata.
*/
updateNotebook(notebookId: string, changes: Partial<NotebookMeta>): void {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
this.#sync.change<NotebookDoc>(docId, 'Update notebook', (d) => {
Object.assign(d.notebook, changes);
d.notebook.updatedAt = Date.now();
});
}
/**
* Listen for changes to a notebook doc.
*/
onChange(notebookId: string, cb: (doc: NotebookDoc) => void): () => void {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
return this.#sync.onChange(docId, cb as (doc: any) => void);
}
/**
* Listen for connection/disconnection events.
*/
onConnect(cb: () => void): () => void {
return this.#sync.onConnect(cb);
}
onDisconnect(cb: () => void): () => void {
return this.#sync.onDisconnect(cb);
}
/**
* Flush all pending saves to IndexedDB and disconnect.
*/
async disconnect(): Promise<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,83 +1,43 @@
/**
* rNotes Automerge document schemas.
* rNotes Automerge document schemas Vault Browser.
*
* Granularity: one Automerge document per notebook.
* DocId format: {space}:notes:notebooks:{notebookId}
* rNotes is now a vault sync + browse module for Obsidian/Logseq vaults.
* Rich editing moved to rDocs.
*
* The shape matches the PGAutomerge migration adapter
* (server/local-first/migration/pg-to-automerge.ts:notesMigration)
* and the client-side NotebookDoc type in folk-notes-app.ts.
* DocId format: {space}:rnotes:vaults:{vaultId}
*
* Storage model:
* - Automerge stores metadata (title, tags, hash, sync status)
* - ZIP vault files stored on disk at /data/files/uploads/vaults/
* - Content served on demand from ZIP (not stored in CRDT)
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Document types ──
// ── Vault note metadata (no content — lightweight) ──
export interface SourceRef {
source: 'logseq' | 'obsidian' | 'notion' | 'google-docs' | 'evernote' | 'roam' | 'manual';
externalId: string; // Notion page ID, Google Doc ID, file path, etc.
lastSyncedAt: number;
contentHash?: string; // For conflict detection on re-import
syncStatus?: 'synced' | 'local-modified' | 'remote-modified' | 'conflict';
}
export interface CommentMessage {
id: string;
authorId: string;
authorName: string;
text: string;
createdAt: number;
}
export interface CommentThread {
id: string;
anchor: string; // serialized position info for the comment mark
resolved: boolean;
messages: CommentMessage[];
createdAt: number;
}
export interface NoteItem {
id: string;
notebookId: string;
authorId: string | null;
export interface VaultNoteMeta {
path: string; // relative path within vault (e.g. "daily/2026-04-10.md")
title: string;
content: string;
contentPlain: string;
contentFormat?: 'html' | 'tiptap-json';
type: 'NOTE' | 'CLIP' | 'BOOKMARK' | 'CODE' | 'IMAGE' | 'FILE' | 'AUDIO';
url: string | null;
language: string | null;
fileUrl: string | null;
mimeType: string | null;
fileSize: number | null;
duration: number | null;
isPinned: boolean;
sortOrder: number;
tags: string[];
summary?: string;
summaryModel?: string;
openNotebookSourceId?: string;
sourceRef?: SourceRef;
conflictContent?: string; // Stores remote version on conflict
collabEnabled?: boolean;
comments?: Record<string, CommentThread>;
createdAt: number;
updatedAt: number;
contentHash: string; // SHA-256 of file content for change detection
sizeBytes: number;
lastModifiedAt: number; // file mtime from vault
syncStatus: 'synced' | 'local-modified' | 'conflict';
frontmatter?: Record<string, any>; // parsed YAML frontmatter
}
export interface NotebookMeta {
export interface VaultMeta {
id: string;
title: string;
slug: string;
description: string;
coverColor: string;
isPublic: boolean;
name: string;
source: 'obsidian' | 'logseq';
totalNotes: number;
totalSizeBytes: number;
lastSyncedAt: number;
createdAt: number;
updatedAt: number;
}
export interface NotebookDoc {
export interface VaultDoc {
meta: {
module: string;
collection: string;
@ -85,112 +45,50 @@ export interface NotebookDoc {
spaceSlug: string;
createdAt: number;
};
notebook: NotebookMeta;
items: Record<string, NoteItem>;
vault: VaultMeta;
notes: Record<string, VaultNoteMeta>; // keyed by path
wikilinks: Record<string, string[]>; // outgoing links per path
}
// ── Schema registration ──
export interface ConnectionsDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
notion?: {
accessToken: string;
workspaceId: string;
workspaceName: string;
connectedAt: number;
};
google?: {
refreshToken: string;
accessToken: string;
expiresAt: number;
email: string;
connectedAt: number;
};
}
/** Generate a docId for a space's integration connections. */
export function connectionsDocId(space: string) {
return `${space}:notes:connections` as const;
}
export const notebookSchema: DocSchema<NotebookDoc> = {
module: 'notes',
collection: 'notebooks',
version: 5,
init: (): NotebookDoc => ({
export const vaultSchema: DocSchema<VaultDoc> = {
module: 'rnotes',
collection: 'vaults',
version: 1,
init: (): VaultDoc => ({
meta: {
module: 'notes',
collection: 'notebooks',
version: 5,
module: 'rnotes',
collection: 'vaults',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
notebook: {
vault: {
id: '',
title: 'Untitled Notebook',
slug: '',
description: '',
coverColor: '#3b82f6',
isPublic: false,
name: 'Untitled Vault',
source: 'obsidian',
totalNotes: 0,
totalSizeBytes: 0,
lastSyncedAt: Date.now(),
createdAt: Date.now(),
updatedAt: Date.now(),
},
items: {},
notes: {},
wikilinks: {},
}),
migrate: (doc: NotebookDoc, fromVersion: number): NotebookDoc => {
if (fromVersion < 2) {
for (const item of Object.values(doc.items)) {
if (!(item as any).contentFormat) (item as any).contentFormat = 'html';
}
}
// v2→v3: sourceRef field is optional, no migration needed
// v3→v4: collabEnabled + comments fields are optional, no migration needed
// v4→v5: syncStatus on SourceRef + conflictContent on NoteItem — both optional, no migration needed
return doc;
},
migrate: (doc: VaultDoc, _fromVersion: number): VaultDoc => doc,
};
// ── Helpers ──
/** Generate a docId for a notebook. */
export function notebookDocId(space: string, notebookId: string) {
return `${space}:notes:notebooks:${notebookId}` as const;
/** Generate a docId for a vault. */
export function vaultDocId(space: string, vaultId: string) {
return `${space}:rnotes:vaults:${vaultId}` as const;
}
/** Create a fresh NoteItem with defaults. */
export function createNoteItem(
id: string,
notebookId: string,
title: string,
opts: Partial<NoteItem> = {},
): NoteItem {
const now = Date.now();
return {
id,
notebookId,
authorId: null,
title,
content: '',
contentPlain: '',
contentFormat: 'tiptap-json',
type: 'NOTE',
url: null,
language: null,
fileUrl: null,
mimeType: null,
fileSize: null,
duration: null,
isPinned: false,
sortOrder: 0,
tags: [],
createdAt: now,
updatedAt: now,
...opts,
};
}
// ── Legacy re-exports for backward compat ──
// The old rNotes schemas (NotebookDoc, NoteItem, etc.) are now in rdocs/schemas.
// Converters and MCP tools that still import from here should be updated.
// For now, re-export from rdocs to avoid breaking shared/converters/types.ts.
export type { NoteItem, SourceRef } from '../rdocs/schemas';

View File

@ -1,5 +0,0 @@
/**
* Re-export from shared location.
* The Yjs provider is now shared across modules (rNotes, rInbox, etc.).
*/
export { RSpaceYjsProvider } from '../../shared/yjs-ws-provider';

View File

@ -1,114 +1,106 @@
/**
* MCP tools for rNotes (notebooks & notes).
* MCP tools for rNotes (vault browser Obsidian/Logseq sync).
*
* Tools: rnotes_list_notebooks, rnotes_list_notes, rnotes_get_note,
* rnotes_create_note, rnotes_update_note
* Tools: rnotes_list_vaults, rnotes_browse_vault, rnotes_search_vault,
* rnotes_get_vault_note, rnotes_sync_status
*/
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { SyncServer } from "../local-first/sync-server";
import { notebookDocId, createNoteItem } from "../../modules/rnotes/schemas";
import type { NotebookDoc, NoteItem } from "../../modules/rnotes/schemas";
import { vaultDocId } from "../../modules/rnotes/schemas";
import type { VaultDoc } from "../../modules/rnotes/schemas";
import { resolveAccess, accessDeniedResponse } from "./_auth";
import { readFile } from "fs/promises";
import { join } from "path";
const NOTEBOOK_PREFIX = ":notes:notebooks:";
const VAULT_PREFIX = ":rnotes:vaults:";
const VAULT_DIR = "/data/files/uploads/vaults";
/** Find all notebook docIds for a space. */
function findNotebookDocIds(syncServer: SyncServer, space: string): string[] {
const prefix = `${space}${NOTEBOOK_PREFIX}`;
/** Find all vault docIds for a space. */
function findVaultDocIds(syncServer: SyncServer, space: string): string[] {
const prefix = `${space}${VAULT_PREFIX}`;
return syncServer.getDocIds().filter(id => id.startsWith(prefix));
}
/** Read a single file from a vault ZIP on disk. */
async function readVaultFile(vaultId: string, filePath: string): Promise<string | null> {
try {
const zipPath = join(VAULT_DIR, `${vaultId}.zip`);
const JSZip = (await import("jszip")).default;
const data = await readFile(zipPath);
const zip = await JSZip.loadAsync(data);
const entry = zip.file(filePath);
if (!entry) return null;
return await entry.async("string");
} catch {
return null;
}
}
export function registerNotesTools(server: McpServer, syncServer: SyncServer) {
server.tool(
"rnotes_list_notebooks",
"List all notebooks in a space",
"rnotes_list_vaults",
"List all synced vaults (Obsidian/Logseq) in a space",
{
space: z.string().describe("Space slug"),
token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"),
token: z.string().optional().describe("JWT auth token"),
},
async ({ space, token }) => {
const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const docIds = findNotebookDocIds(syncServer, space);
const notebooks = [];
const docIds = findVaultDocIds(syncServer, space);
const vaults = [];
for (const docId of docIds) {
const doc = syncServer.getDoc<NotebookDoc>(docId);
if (!doc?.notebook) continue;
notebooks.push({
id: doc.notebook.id,
title: doc.notebook.title,
slug: doc.notebook.slug,
description: doc.notebook.description,
noteCount: Object.keys(doc.items || {}).length,
createdAt: doc.notebook.createdAt,
updatedAt: doc.notebook.updatedAt,
const doc = syncServer.getDoc<VaultDoc>(docId);
if (!doc?.vault) continue;
vaults.push({
id: doc.vault.id,
name: doc.vault.name,
source: doc.vault.source,
totalNotes: doc.vault.totalNotes,
lastSyncedAt: doc.vault.lastSyncedAt,
createdAt: doc.vault.createdAt,
});
}
return { content: [{ type: "text", text: JSON.stringify(notebooks, null, 2) }] };
return { content: [{ type: "text", text: JSON.stringify(vaults, null, 2) }] };
},
);
server.tool(
"rnotes_list_notes",
"List notes, optionally filtered by notebook, search text, or tags",
"rnotes_browse_vault",
"Browse notes in a vault, optionally filtered by folder path",
{
space: z.string().describe("Space slug"),
token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"),
notebook_id: z.string().optional().describe("Filter by notebook ID"),
search: z.string().optional().describe("Search in title/content"),
token: z.string().optional().describe("JWT auth token"),
vault_id: z.string().describe("Vault ID"),
folder: z.string().optional().describe("Folder path prefix (e.g. 'daily/')"),
limit: z.number().optional().describe("Max results (default 50)"),
tags: z.array(z.string()).optional().describe("Filter by tags"),
},
async ({ space, token, notebook_id, search, limit, tags }) => {
async ({ space, token, vault_id, folder, limit }) => {
const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const docIds = notebook_id
? [notebookDocId(space, notebook_id)]
: findNotebookDocIds(syncServer, space);
const doc = syncServer.getDoc<VaultDoc>(vaultDocId(space, vault_id));
if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] };
let notes: Array<NoteItem & { notebookTitle: string }> = [];
for (const docId of docIds) {
const doc = syncServer.getDoc<NotebookDoc>(docId);
if (!doc?.items) continue;
const nbTitle = doc.notebook?.title || "Untitled";
for (const note of Object.values(doc.items)) {
notes.push({ ...JSON.parse(JSON.stringify(note)), notebookTitle: nbTitle });
}
let notes = Object.values(doc.notes || {});
if (folder) {
notes = notes.filter(n => n.path.startsWith(folder));
}
if (search) {
const q = search.toLowerCase();
notes = notes.filter(n =>
n.title.toLowerCase().includes(q) ||
(n.contentPlain && n.contentPlain.toLowerCase().includes(q)),
);
}
if (tags && tags.length > 0) {
notes = notes.filter(n =>
n.tags && tags.some(t => n.tags.includes(t)),
);
}
notes.sort((a, b) => b.updatedAt - a.updatedAt);
notes.sort((a, b) => b.lastModifiedAt - a.lastModifiedAt);
const maxResults = limit || 50;
notes = notes.slice(0, maxResults);
const summary = notes.map(n => ({
id: n.id,
notebookId: n.notebookId,
notebookTitle: n.notebookTitle,
path: n.path,
title: n.title,
type: n.type,
tags: n.tags,
isPinned: n.isPinned,
contentPreview: (n.contentPlain || "").slice(0, 200),
createdAt: n.createdAt,
updatedAt: n.updatedAt,
sizeBytes: n.sizeBytes,
lastModifiedAt: n.lastModifiedAt,
syncStatus: n.syncStatus,
}));
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
@ -116,117 +108,114 @@ export function registerNotesTools(server: McpServer, syncServer: SyncServer) {
);
server.tool(
"rnotes_get_note",
"Get the full content of a specific note",
"rnotes_search_vault",
"Search notes across all vaults by title or tags",
{
space: z.string().describe("Space slug"),
token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"),
note_id: z.string().describe("Note ID"),
notebook_id: z.string().optional().describe("Notebook ID (speeds up lookup)"),
token: z.string().optional().describe("JWT auth token"),
search: z.string().describe("Search term (matches title and tags)"),
vault_id: z.string().optional().describe("Limit to specific vault"),
limit: z.number().optional().describe("Max results (default 20)"),
},
async ({ space, token, note_id, notebook_id }) => {
async ({ space, token, search, vault_id, limit }) => {
const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!);
if (notebook_id) {
const doc = syncServer.getDoc<NotebookDoc>(notebookDocId(space, notebook_id));
const note = doc?.items?.[note_id];
if (note) {
return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] };
}
}
const docIds = vault_id
? [vaultDocId(space, vault_id)]
: findVaultDocIds(syncServer, space);
for (const docId of findNotebookDocIds(syncServer, space)) {
const doc = syncServer.getDoc<NotebookDoc>(docId);
const note = doc?.items?.[note_id];
if (note) {
return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] };
}
}
return { content: [{ type: "text", text: JSON.stringify({ error: "Note not found" }) }] };
},
);
server.tool(
"rnotes_create_note",
"Create a new note in a notebook (requires auth token + space membership)",
{
space: z.string().describe("Space slug"),
token: z.string().describe("JWT auth token"),
notebook_id: z.string().describe("Target notebook ID"),
title: z.string().describe("Note title"),
content: z.string().optional().describe("Note content (plain text or HTML)"),
tags: z.array(z.string()).optional().describe("Note tags"),
},
async ({ space, token, notebook_id, title, content, tags }) => {
const access = await resolveAccess(token, space, true);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const docId = notebookDocId(space, notebook_id);
const doc = syncServer.getDoc<NotebookDoc>(docId);
if (!doc) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Notebook not found" }) }], isError: true };
}
const noteId = `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const noteItem = createNoteItem(noteId, notebook_id, title, {
content: content || "",
contentPlain: content || "",
contentFormat: "html",
tags: tags || [],
});
syncServer.changeDoc<NotebookDoc>(docId, `Create note ${title}`, (d) => {
if (!d.items) (d as any).items = {};
d.items[noteId] = noteItem;
});
return { content: [{ type: "text", text: JSON.stringify({ id: noteId, created: true }) }] };
},
);
server.tool(
"rnotes_update_note",
"Update an existing note (requires auth token + space membership)",
{
space: z.string().describe("Space slug"),
token: z.string().describe("JWT auth token"),
note_id: z.string().describe("Note ID"),
notebook_id: z.string().optional().describe("Notebook ID (speeds up lookup)"),
title: z.string().optional().describe("New title"),
content: z.string().optional().describe("New content"),
tags: z.array(z.string()).optional().describe("New tags"),
is_pinned: z.boolean().optional().describe("Pin/unpin note"),
},
async ({ space, token, note_id, notebook_id, ...updates }) => {
const access = await resolveAccess(token, space, true);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const docIds = notebook_id
? [notebookDocId(space, notebook_id)]
: findNotebookDocIds(syncServer, space);
const q = search.toLowerCase();
const results: Array<{ vaultName: string; path: string; title: string; tags: string[] }> = [];
for (const docId of docIds) {
const doc = syncServer.getDoc<NotebookDoc>(docId);
if (!doc?.items?.[note_id]) continue;
syncServer.changeDoc<NotebookDoc>(docId, `Update note ${note_id}`, (d) => {
const n = d.items[note_id];
if (updates.title !== undefined) n.title = updates.title;
if (updates.content !== undefined) {
n.content = updates.content;
n.contentPlain = updates.content;
const doc = syncServer.getDoc<VaultDoc>(docId);
if (!doc?.notes) continue;
const vaultName = doc.vault?.name || "Unknown";
for (const note of Object.values(doc.notes)) {
if (
note.title.toLowerCase().includes(q) ||
note.path.toLowerCase().includes(q) ||
note.tags.some(t => t.toLowerCase().includes(q))
) {
results.push({ vaultName, path: note.path, title: note.title, tags: note.tags });
}
if (updates.tags !== undefined) n.tags = updates.tags;
if (updates.is_pinned !== undefined) n.isPinned = updates.is_pinned;
n.updatedAt = Date.now();
});
return { content: [{ type: "text", text: JSON.stringify({ id: note_id, updated: true }) }] };
}
}
return { content: [{ type: "text", text: JSON.stringify({ error: "Note not found" }) }], isError: true };
const maxResults = limit || 20;
return { content: [{ type: "text", text: JSON.stringify(results.slice(0, maxResults), null, 2) }] };
},
);
server.tool(
"rnotes_get_vault_note",
"Get the full content of a vault note (reads from disk ZIP)",
{
space: z.string().describe("Space slug"),
token: z.string().optional().describe("JWT auth token"),
vault_id: z.string().describe("Vault ID"),
path: z.string().describe("Note file path within vault"),
},
async ({ space, token, vault_id, path: notePath }) => {
const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const doc = syncServer.getDoc<VaultDoc>(vaultDocId(space, vault_id));
if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] };
const meta = doc.notes?.[notePath];
if (!meta) return { content: [{ type: "text", text: JSON.stringify({ error: "Note not found in vault" }) }] };
const content = await readVaultFile(vault_id, notePath);
if (content === null) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Could not read note from vault ZIP" }) }] };
}
return {
content: [{
type: "text",
text: JSON.stringify({ ...meta, content }, null, 2),
}],
};
},
);
server.tool(
"rnotes_sync_status",
"Get sync status summary for a vault",
{
space: z.string().describe("Space slug"),
token: z.string().optional().describe("JWT auth token"),
vault_id: z.string().describe("Vault ID"),
},
async ({ space, token, vault_id }) => {
const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const doc = syncServer.getDoc<VaultDoc>(vaultDocId(space, vault_id));
if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] };
const notes = Object.values(doc.notes || {});
const synced = notes.filter(n => n.syncStatus === 'synced').length;
const modified = notes.filter(n => n.syncStatus === 'local-modified').length;
const conflicts = notes.filter(n => n.syncStatus === 'conflict').length;
return {
content: [{
type: "text",
text: JSON.stringify({
vaultId: vault_id,
vaultName: doc.vault?.name,
source: doc.vault?.source,
totalNotes: notes.length,
synced,
modified,
conflicts,
lastSyncedAt: doc.vault?.lastSyncedAt,
}, null, 2),
}],
};
},
);
}

View File

@ -6,7 +6,7 @@
*/
import { getUpcomingEventsForMI } from "../modules/rcal/mod";
import { getRecentNotesForMI } from "../modules/rnotes/mod";
import { getRecentVaultNotesForMI } from "../modules/rnotes/mod";
import { getRecentTasksForMI } from "../modules/rtasks/mod";
import { getRecentCampaignsForMI } from "../modules/rsocials/mod";
import { getRecentContactsForMI } from "../modules/rnetwork/mod";
@ -61,12 +61,12 @@ export function queryModuleContent(
): MiQueryResult {
switch (module) {
case "rnotes": {
const notes = getRecentNotesForMI(space, limit);
const notes = getRecentVaultNotesForMI(space, limit);
if (queryType === "count") {
return { ok: true, module, queryType, data: { count: notes.length }, summary: `${notes.length} recent notes found.` };
return { ok: true, module, queryType, data: { count: notes.length }, summary: `${notes.length} vault notes found.` };
}
const lines = notes.map((n) => `- "${n.title}" (${n.type}, updated ${new Date(n.updatedAt).toLocaleDateString()})${n.tags.length ? ` [${n.tags.join(", ")}]` : ""}: ${n.contentPlain.slice(0, 100)}...`);
return { ok: true, module, queryType, data: notes, summary: lines.length ? `Recent notes:\n${lines.join("\n")}` : "No notes found." };
const lines = notes.map((n) => `- "${n.title}" in ${n.vaultName} (${n.path})${n.tags.length ? ` [${n.tags.join(", ")}]` : ""}`);
return { ok: true, module, queryType, data: notes, summary: lines.length ? `Vault notes:\n${lines.join("\n")}` : "No vault notes found." };
}
case "rtasks": {

View File

@ -19,7 +19,7 @@ import type { EncryptIDClaims } from "./auth";
import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes";
import type { MiAction } from "../lib/mi-actions";
import { getUpcomingEventsForMI } from "../modules/rcal/mod";
import { getRecentNotesForMI } from "../modules/rnotes/mod";
import { getRecentVaultNotesForMI } from "../modules/rnotes/mod";
import { getRecentTasksForMI } from "../modules/rtasks/mod";
import { getRecentCampaignsForMI } from "../modules/rsocials/mod";
import { getRecentContactsForMI } from "../modules/rnetwork/mod";
@ -234,12 +234,12 @@ mi.post("/ask", async (c) => {
calendarContext = `\n- Upcoming events (next 14 days):\n${lines.join("\n")}`;
}
const recentNotes = getRecentNotesForMI(space, 3);
if (recentNotes.length > 0) {
const lines = recentNotes.map((n) =>
`- "${n.title}" (${n.type}${n.tags.length ? `, tags: ${n.tags.join(", ")}` : ""}): ${n.contentPlain.slice(0, 100)}`
const vaultNotes = getRecentVaultNotesForMI(space, 3);
if (vaultNotes.length > 0) {
const lines = vaultNotes.map((n) =>
`- "${n.title}" in ${n.vaultName} (${n.path})${n.tags.length ? ` [${n.tags.join(", ")}]` : ""}`
);
notesContext = `\n- Recent notes:\n${lines.join("\n")}`;
notesContext = `\n- Recent vault notes:\n${lines.join("\n")}`;
}
const openTasks = getRecentTasksForMI(space, 5);
@ -885,12 +885,12 @@ mi.post("/suggestions", async (c) => {
// Check if current module has zero content — "get started" suggestion
if (currentModule === "rnotes") {
const notes = getRecentNotesForMI(space, 1);
if (notes.length === 0) {
const vaults = getRecentVaultNotesForMI(space, 1);
if (vaults.length === 0) {
suggestions.push({
label: "Create your first note",
icon: "📝",
prompt: "Help me create my first notebook",
label: "Upload your first vault",
icon: "🔗",
prompt: "Help me upload my Obsidian or Logseq vault",
autoSend: true,
});
}
@ -928,12 +928,12 @@ mi.post("/suggestions", async (c) => {
// Recent note/doc to continue editing
if (currentModule === "rnotes") {
const recent = getRecentNotesForMI(space, 1);
const recent = getRecentVaultNotesForMI(space, 1);
if (recent.length > 0) {
suggestions.push({
label: `Continue "${recent[0].title}"`,
icon: "📝",
prompt: `Help me continue working on "${recent[0].title}"`,
label: `Browse "${recent[0].title}"`,
icon: "🔗",
prompt: `Show me the note "${recent[0].title}" from ${recent[0].vaultName}`,
autoSend: true,
});
}

View File

@ -11,6 +11,7 @@ import type { MiAction } from "../../lib/mi-actions";
import { MiActionExecutor } from "../../lib/mi-action-executor";
import { suggestTools, type ToolHint } from "../../lib/mi-tool-schema";
import { SpeechDictation } from "../../lib/speech-dictation";
import { MiVoiceBridge, type VoiceState } from "../../lib/mi-voice-bridge";
import { getContextSuggestions } from "../../lib/mi-suggestions";
import type { MiSuggestion } from "../../lib/mi-suggestions";
@ -45,6 +46,12 @@ export class RStackMi extends HTMLElement {
#dynamicSuggestions: MiSuggestion[] = [];
#placeholderIdx = 0;
#placeholderTimer: ReturnType<typeof setInterval> | null = null;
#voiceMode = false;
#voiceState: VoiceState = "idle";
#voiceBridge: MiVoiceBridge | null = null;
#voiceDictation: SpeechDictation | null = null;
#voiceAccumulated = "";
#voiceSilenceTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
super();
@ -63,6 +70,7 @@ export class RStackMi extends HTMLElement {
disconnectedCallback() {
document.removeEventListener("keydown", this.#keyHandler);
if (this.#placeholderTimer) clearInterval(this.#placeholderTimer);
if (this.#voiceMode) this.#deactivateVoiceMode();
}
#keyHandler = (e: KeyboardEvent) => {
@ -147,6 +155,7 @@ export class RStackMi extends HTMLElement {
<input class="mi-input-bar" id="mi-bar-input" type="text"
placeholder="Ask mi anything..." autocomplete="off" />
${SpeechDictation.isSupported() ? '<button class="mi-mic-btn" id="mi-mic" title="Voice dictation">🎤</button>' : ''}
${SpeechDictation.isSupported() ? '<button class="mi-voice-btn" id="mi-voice-btn" title="Voice conversation">🎙</button>' : ''}
</div>
<div class="mi-panel" id="mi-panel">
<div class="mi-panel-header">
@ -154,6 +163,7 @@ export class RStackMi extends HTMLElement {
<span class="mi-panel-title">mi</span>
<select class="mi-model-select" id="mi-model-select" title="Select AI model"></select>
<div class="mi-panel-spacer"></div>
${SpeechDictation.isSupported() ? '<button class="mi-voice-panel-btn" id="mi-voice-panel-btn" title="Voice mode">🎙</button>' : ''}
<button class="mi-panel-btn" id="mi-minimize" title="Minimize (Escape)">&#8722;</button>
<button class="mi-panel-btn" id="mi-close" title="Close">&times;</button>
</div>
@ -177,6 +187,12 @@ export class RStackMi extends HTMLElement {
<div class="mi-scaffold-bar"><div class="mi-scaffold-fill" id="mi-scaffold-fill"></div></div>
<span class="mi-scaffold-label" id="mi-scaffold-label"></span>
</div>
<div class="mi-voice-strip" id="mi-voice-strip" style="display:none">
<div class="mi-voice-waveform"><span></span><span></span><span></span><span></span><span></span></div>
<span class="mi-voice-label" id="mi-voice-label">Listening...</span>
<span class="mi-voice-interim" id="mi-voice-interim"></span>
<button class="mi-voice-stop" id="mi-voice-stop" title="Stop voice mode">&times;</button>
</div>
<div class="mi-input-area">
<textarea class="mi-input" id="mi-input" rows="1"
placeholder="Ask mi to build, create, or explore..." autocomplete="off"></textarea>
@ -250,6 +266,7 @@ export class RStackMi extends HTMLElement {
// Close panel on outside click — use composedPath to pierce Shadow DOM
document.addEventListener("pointerdown", (e) => {
if (this.#voiceMode) return; // Keep panel open during voice conversation
const path = e.composedPath();
if (!path.includes(this)) {
panel.classList.remove("open");
@ -324,6 +341,7 @@ export class RStackMi extends HTMLElement {
micBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#voiceMode) return; // Bar mic disabled during voice mode
if (!this.#dictation!.isRecording) {
baseText = barInput.value;
}
@ -331,6 +349,24 @@ export class RStackMi extends HTMLElement {
barInput.focus();
});
}
// Voice mode buttons
const voiceBarBtn = this.#shadow.getElementById("mi-voice-btn");
const voicePanelBtn = this.#shadow.getElementById("mi-voice-panel-btn");
const voiceStopBtn = this.#shadow.getElementById("mi-voice-stop");
voiceBarBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#toggleVoiceMode();
});
voicePanelBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#toggleVoiceMode();
});
voiceStopBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#deactivateVoiceMode();
});
}
#loadSuggestions() {
@ -440,6 +476,184 @@ export class RStackMi extends HTMLElement {
}, 8000);
}
// ── Voice conversation mode ──
#toggleVoiceMode() {
if (this.#voiceMode) {
this.#deactivateVoiceMode();
} else {
this.#activateVoiceMode();
}
}
#activateVoiceMode() {
// Stop existing bar dictation if recording
if (this.#dictation?.isRecording) this.#dictation.stop();
this.#voiceMode = true;
this.#voiceAccumulated = "";
// Create TTS bridge
this.#voiceBridge = new MiVoiceBridge({
onStateChange: (s) => this.#voiceSetState(s),
});
// Create dedicated voice dictation instance
this.#voiceDictation = new SpeechDictation({
onInterim: (text) => {
const interimEl = this.#shadow.getElementById("mi-voice-interim");
if (interimEl) interimEl.textContent = text;
// Interrupt TTS if user starts speaking
if (this.#voiceState === "speaking") {
this.#voiceBridge?.stop();
this.#voiceSetState("listening");
}
},
onFinal: (text) => {
this.#voiceAccumulated += (this.#voiceAccumulated ? " " : "") + text;
const interimEl = this.#shadow.getElementById("mi-voice-interim");
if (interimEl) interimEl.textContent = this.#voiceAccumulated;
// Reset silence timer
this.#resetSilenceTimer();
},
onStateChange: () => {},
onError: (err) => console.warn("Voice dictation:", err),
});
// Open panel, show strip, start listening
const panel = this.#shadow.getElementById("mi-panel")!;
const bar = this.#shadow.getElementById("mi-bar")!;
panel.classList.add("open");
bar.classList.add("focused");
this.#shadow.getElementById("mi-voice-strip")!.style.display = "flex";
this.#voiceSetState("listening");
this.#voiceDictation.start();
}
#deactivateVoiceMode() {
this.#voiceMode = false;
// Clear silence timer
if (this.#voiceSilenceTimer) {
clearTimeout(this.#voiceSilenceTimer);
this.#voiceSilenceTimer = null;
}
// Stop dictation and TTS
this.#voiceDictation?.destroy();
this.#voiceDictation = null;
this.#voiceBridge?.stop();
this.#voiceBridge?.destroy();
this.#voiceBridge = null;
this.#voiceAccumulated = "";
// Hide strip, reset UI
this.#shadow.getElementById("mi-voice-strip")!.style.display = "none";
this.#voiceSetState("idle");
}
#voiceSetState(state: VoiceState) {
this.#voiceState = state;
const strip = this.#shadow.getElementById("mi-voice-strip");
const label = this.#shadow.getElementById("mi-voice-label");
const barBtn = this.#shadow.getElementById("mi-voice-btn");
const panelBtn = this.#shadow.getElementById("mi-voice-panel-btn");
// Update strip
if (strip) {
strip.classList.remove("vs-listening", "vs-thinking", "vs-speaking");
if (state !== "idle") strip.classList.add(`vs-${state}`);
}
if (label) {
const labels: Record<VoiceState, string> = {
idle: "",
listening: "Listening...",
thinking: "Thinking...",
speaking: "Speaking...",
};
label.textContent = labels[state];
}
// Update buttons
for (const btn of [barBtn, panelBtn]) {
if (!btn) continue;
btn.classList.remove("v-listening", "v-thinking", "v-speaking", "v-active");
if (this.#voiceMode) {
btn.classList.add("v-active");
if (state !== "idle") btn.classList.add(`v-${state}`);
}
}
}
#resetSilenceTimer() {
if (this.#voiceSilenceTimer) clearTimeout(this.#voiceSilenceTimer);
this.#voiceSilenceTimer = setTimeout(() => {
if (this.#voiceMode && this.#voiceAccumulated.trim()) {
this.#voiceSubmit();
}
}, 1500);
}
async #voiceSubmit() {
const query = this.#voiceAccumulated.trim();
if (!query || !this.#voiceMode) return;
this.#voiceAccumulated = "";
const interimEl = this.#shadow.getElementById("mi-voice-interim");
if (interimEl) interimEl.textContent = "";
// Stop listening while processing (echo prevention)
this.#voiceDictation?.stop();
this.#voiceSetState("thinking");
// Submit query through existing #ask flow
await this.#ask(query);
if (!this.#voiceMode) return; // Deactivated during ask
// Get the last assistant message for TTS
const lastMsg = [...this.#messages].reverse().find((m) => m.role === "assistant");
if (lastMsg?.content && this.#voiceBridge) {
this.#voiceSetState("speaking");
const ttsText = this.#stripForTTS(lastMsg.content);
await this.#voiceBridge.speak(ttsText);
}
// Resume listening
if (this.#voiceMode) {
this.#voiceSetState("listening");
this.#voiceDictation?.start();
}
}
#stripForTTS(text: string): string {
let stripped = text
// Remove code blocks
.replace(/```[\s\S]*?```/g, "... code block omitted ...")
// Remove inline code
.replace(/`[^`]+`/g, "")
// Remove markdown formatting
.replace(/[*_#]+/g, "")
// Remove links, keep label
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
// Remove action indicators
.replace(/\n\*[⏳✓✗].*?\*\n?/g, " ")
// Remove turn indicators
.replace(/\n\*— thinking.*?—\*\n?/g, " ")
// Collapse whitespace
.replace(/\s+/g, " ")
.trim();
// Truncate to ~4 sentences for snappy TTS
const sentences = stripped.match(/[^.!?]+[.!?]+/g) || [stripped];
if (sentences.length > 4) {
stripped = sentences.slice(0, 4).join(" ").trim();
}
return stripped;
}
#minimize() {
this.#minimized = true;
const panel = this.#shadow.getElementById("mi-panel")!;
@ -1099,6 +1313,90 @@ const STYLES = `
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
/* ── Voice mode buttons ── */
.mi-voice-btn, .mi-voice-panel-btn {
background: none; border: none; cursor: pointer; padding: 2px 4px;
font-size: 0.85rem; border-radius: 6px; transition: all 0.2s;
flex-shrink: 0; line-height: 1; opacity: 0.7;
}
.mi-voice-btn:hover, .mi-voice-panel-btn:hover { background: var(--rs-bg-hover); opacity: 1; }
.mi-voice-btn.v-active, .mi-voice-panel-btn.v-active { opacity: 1; }
.mi-voice-btn.v-listening, .mi-voice-panel-btn.v-listening {
animation: voicePulseRed 1.5s infinite;
}
.mi-voice-btn.v-thinking, .mi-voice-panel-btn.v-thinking {
animation: voiceSpinAmber 2s linear infinite;
}
.mi-voice-btn.v-speaking, .mi-voice-panel-btn.v-speaking {
animation: voicePulseCyan 2s infinite;
}
@keyframes voicePulseRed {
0%, 100% { filter: drop-shadow(0 0 2px transparent); }
50% { filter: drop-shadow(0 0 6px rgba(239,68,68,0.7)); }
}
@keyframes voiceSpinAmber {
0% { filter: hue-rotate(0deg) drop-shadow(0 0 4px rgba(234,179,8,0.5)); }
100% { filter: hue-rotate(360deg) drop-shadow(0 0 4px rgba(234,179,8,0.5)); }
}
@keyframes voicePulseCyan {
0%, 100% { filter: drop-shadow(0 0 2px transparent); }
50% { filter: drop-shadow(0 0 6px rgba(6,182,212,0.7)); }
}
/* ── Voice strip ── */
.mi-voice-strip {
display: flex; align-items: center; gap: 8px;
padding: 8px 14px; border-top: 1px solid var(--rs-border);
flex-shrink: 0; min-height: 36px;
background: rgba(239,68,68,0.06);
transition: background 0.3s;
}
.mi-voice-strip.vs-listening { background: rgba(239,68,68,0.06); }
.mi-voice-strip.vs-thinking { background: rgba(234,179,8,0.06); }
.mi-voice-strip.vs-speaking { background: rgba(6,182,212,0.06); }
.mi-voice-waveform {
display: flex; align-items: center; gap: 2px; height: 18px;
}
.mi-voice-waveform span {
width: 3px; border-radius: 2px;
background: #ef4444; animation: voiceBar 1s ease-in-out infinite;
}
.vs-thinking .mi-voice-waveform span { background: #eab308; }
.vs-speaking .mi-voice-waveform span { background: #06b6d4; }
.mi-voice-waveform span:nth-child(1) { height: 6px; animation-delay: 0s; }
.mi-voice-waveform span:nth-child(2) { height: 12px; animation-delay: 0.1s; }
.mi-voice-waveform span:nth-child(3) { height: 18px; animation-delay: 0.2s; }
.mi-voice-waveform span:nth-child(4) { height: 12px; animation-delay: 0.3s; }
.mi-voice-waveform span:nth-child(5) { height: 6px; animation-delay: 0.4s; }
@keyframes voiceBar {
0%, 100% { transform: scaleY(0.4); }
50% { transform: scaleY(1); }
}
.mi-voice-label {
font-size: 0.75rem; font-weight: 600; white-space: nowrap;
color: var(--rs-text-muted);
}
.vs-listening .mi-voice-label { color: #ef4444; }
.vs-thinking .mi-voice-label { color: #eab308; }
.vs-speaking .mi-voice-label { color: #06b6d4; }
.mi-voice-interim {
flex: 1; font-size: 0.78rem; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--rs-text-secondary); font-style: italic;
}
.mi-voice-stop {
background: none; border: none; cursor: pointer;
font-size: 1rem; line-height: 1; padding: 2px 6px; border-radius: 4px;
color: var(--rs-text-muted); transition: all 0.15s; flex-shrink: 0;
}
.mi-voice-stop:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); }
@media (max-width: 640px) {
.mi { max-width: none; width: 100%; }
.mi-panel {