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:
parent
99492cc532
commit
18b61fa5e6
|
|
@ -221,7 +221,7 @@ Flows are typed connections between modules:
|
||||||
|
|
||||||
| Kind | Description | Example |
|
| Kind | Description | Example |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
| `data` | Information flow | rNotes → rPubs (publish) |
|
| `data` | Information flow | rDocs → rPubs (publish) |
|
||||||
| `economic` | Value/payment flow | rFunds → rWallet (treasury) |
|
| `economic` | Value/payment flow | rFunds → rWallet (treasury) |
|
||||||
| `trust` | Reputation/attestation | rVote → rNetwork (delegation) |
|
| `trust` | Reputation/attestation | rVote → rNetwork (delegation) |
|
||||||
| `attention` | Signal/notification | rInbox → rForum (mentions) |
|
| `attention` | Signal/notification | rInbox → rForum (mentions) |
|
||||||
|
|
@ -251,10 +251,10 @@ redirects to the unified server with subdomain-based space routing.
|
||||||
|
|
||||||
| Module | Domain | Purpose |
|
| 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) |
|
| **rPubs** | rpubs.online | Long-form publishing (Typst PDF) |
|
||||||
| **rBooks** | rbooks.online | PDF library with flipbook reader |
|
| **rBooks** | rbooks.online | PDF library with flipbook reader |
|
||||||
| **rDocs** | rdocs.online | Document management |
|
|
||||||
| **rData** | rdata.online | Data visualization & analysis |
|
| **rData** | rdata.online | Data visualization & analysis |
|
||||||
|
|
||||||
### Planning & Spatial
|
### Planning & Spatial
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
---
|
---
|
||||||
id: TASK-29
|
id: TASK-29
|
||||||
title: Port folk-drawfast shape (collaborative drawing/gesture recognition)
|
title: Port folk-drawfast shape (collaborative drawing/gesture recognition)
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-02-18 19:50'
|
created_date: '2026-02-18 19:50'
|
||||||
|
updated_date: '2026-04-10 21:28'
|
||||||
labels:
|
labels:
|
||||||
- shape-port
|
- shape-port
|
||||||
- phase-2
|
- phase-2
|
||||||
|
|
@ -34,8 +35,16 @@ Features to implement:
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Freehand drawing works with pointer/touch input
|
- [x] #1 Freehand drawing works with pointer/touch input
|
||||||
- [ ] #2 Gesture recognition detects basic shapes
|
- [x] #2 Gesture recognition detects basic shapes
|
||||||
- [ ] #3 Drawing state syncs across clients
|
- [x] #3 Drawing state syncs across clients
|
||||||
- [ ] #4 Toolbar button added to canvas.html
|
- [x] #4 Toolbar button added to canvas.html
|
||||||
<!-- AC:END -->
|
<!-- 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 -->
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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">×</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 · <kbd>Esc</kbd> to close · Offline ready
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="parakeet-offline.js" type="module"></script>
|
|
||||||
<script src="voice.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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">▼</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">⏰ ${formatDate(thread.reminderAt)}</span>
|
|
||||||
<button class="reminder-clear" data-remind-clear="${thread.id}">✕</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">⏰</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
|
|
@ -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
|
|
@ -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, "<").replace(/>/g, ">");
|
|
||||||
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
/* Notes module — dark theme (host-level styles) */
|
|
||||||
folk-notes-app {
|
|
||||||
display: block;
|
|
||||||
min-height: 400px;
|
|
||||||
padding: 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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 ``;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
|
|
@ -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 = ``;
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
@ -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 += ``;
|
|
||||||
} else {
|
|
||||||
text += ``;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
text += ``;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
@ -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 ``;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
export function renderLanding(): string {
|
||||||
return `
|
return `
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<div class="rl-hero">
|
<div class="rl-hero">
|
||||||
<span class="rl-tagline">rNotes</span>
|
<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>
|
<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">Capture Everything, Find Anything, and Share your Insights</p>
|
<p class="rl-subtitle" style="background:linear-gradient(135deg,#f59e0b,#f97316);-webkit-background-clip:text;background-clip:text">Obsidian & Logseq Vault Sync</p>
|
||||||
<p class="rl-subtext">
|
<p class="rl-subtext">
|
||||||
Notes, clips, voice recordings, and live transcription — all in one place.
|
Upload your Obsidian or Logseq vault, browse files, search across notes,
|
||||||
Speak and watch your words appear in real time, or drop in audio and video files to transcribe offline.
|
and visualize wikilink graphs — all from your rSpace.
|
||||||
</p>
|
</p>
|
||||||
<div class="rl-cta-row">
|
<div class="rl-cta-row">
|
||||||
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary" id="ml-primary">Open Notebook</a>
|
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary" id="ml-primary">Open Vault Browser</a>
|
||||||
<a href="#transcription-demo" class="rl-cta-secondary">Transcribe</a>
|
<a href="#features" class="rl-cta-secondary">Features</a>
|
||||||
<a href="#extension-download" class="rl-cta-secondary">Get Extension</a>
|
|
||||||
</div>
|
</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 →
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</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 — 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">⚠️</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">
|
|
||||||
🎤
|
|
||||||
</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">
|
|
||||||
● 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…</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">● Live streaming</span>
|
|
||||||
<span class="rl-badge" style="background:rgba(59,130,246,0.15);color:#3b82f6">🎵 Audio file upload</span>
|
|
||||||
<span class="rl-badge" style="background:rgba(168,85,247,0.15);color:#a855f7">🎥 Video transcription</span>
|
|
||||||
<span class="rl-badge" style="background:rgba(245,158,11,0.15);color:#f59e0b">🔌 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 -->
|
<!-- Features -->
|
||||||
<section class="rl-section">
|
<section class="rl-section" id="features">
|
||||||
<div class="rl-container">
|
<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-grid-3">
|
||||||
<div class="rl-card rl-card--center">
|
<div class="rl-card rl-card--center">
|
||||||
<div class="rl-icon-box">📝</div>
|
<div class="rl-icon-box">📁</div>
|
||||||
<h3>Rich Text Notes</h3>
|
<h3>Vault Upload</h3>
|
||||||
<p>Write with a full TipTap editor — formatting, code blocks, checklists, and embeds. Dual-format storage keeps Markdown portable.</p>
|
<p>Upload your Obsidian or Logseq vault as a ZIP. Metadata is indexed — titles, tags, frontmatter, and wikilinks.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rl-card rl-card--center">
|
<div class="rl-card rl-card--center">
|
||||||
<div class="rl-icon-box">🎤</div>
|
<div class="rl-icon-box">🔎</div>
|
||||||
<h3>Voice & Transcription</h3>
|
<h3>Search & Browse</h3>
|
||||||
<p>Record voice notes with live transcription via Web Speech API. Drop in audio or video files and get full transcripts — all in the browser.</p>
|
<p>Full file tree with folder grouping, search by title or tags, and read-only markdown preview.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rl-card rl-card--center">
|
<div class="rl-card rl-card--center">
|
||||||
<div class="rl-icon-box">🏷</div>
|
<div class="rl-icon-box">🔗</div>
|
||||||
<h3>Tagging & Organization</h3>
|
<h3>Wikilink Graph</h3>
|
||||||
<p>Tag freely, organize into notebooks, and search everything. Filtered views surface the right cards at the right time.</p>
|
<p>Visualize how your notes connect via wikilinks. See the knowledge graph of your vault at a glance.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Chrome Extension -->
|
<!-- Supported Sources -->
|
||||||
<section class="rl-section rl-section--alt" id="extension-download">
|
<section class="rl-section rl-section--alt">
|
||||||
<div class="rl-container">
|
<div class="rl-container">
|
||||||
<h2 class="rl-heading" style="text-align:center">Chrome Extension</h2>
|
<h2 class="rl-heading" style="text-align:center">Supported Vault Sources</h2>
|
||||||
<p class="rl-subtext" style="text-align:center">Clip pages, record voice notes, and transcribe — right from the toolbar.</p>
|
<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-grid-2" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1.5rem;max-width:860px;margin:2rem auto">
|
|
||||||
<div class="rl-card rl-card--center">
|
<div class="rl-card rl-card--center">
|
||||||
<div class="rl-icon-box">📋</div>
|
<div class="rl-icon-box" style="color:#a78bfa">◆</div>
|
||||||
<h3>Web Clipper</h3>
|
<h3>Obsidian</h3>
|
||||||
<p>Save any page as a note with one click — article text, selection, or full HTML.</p>
|
<p>ZIP your vault folder and upload. Frontmatter, tags, and wikilinks are fully parsed.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rl-card rl-card--center">
|
<div class="rl-card rl-card--center">
|
||||||
<div class="rl-icon-box">🎤</div>
|
<div class="rl-icon-box" style="color:#60a5fa">●</div>
|
||||||
<h3>Voice Recording</h3>
|
<h3>Logseq</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>
|
<p>Export your Logseq graph as a ZIP. Outliner structure, properties, and page links are preserved.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rl-card rl-card--center">
|
|
||||||
<div class="rl-icon-box">🔓</div>
|
|
||||||
<h3>Article Unlock</h3>
|
|
||||||
<p>Bypass soft paywalls by fetching archived versions — read the article, then save it to your notebook.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rl-card rl-card--center">
|
|
||||||
<div class="rl-icon-box">🔌</div>
|
|
||||||
<h3>Offline Transcription</h3>
|
|
||||||
<p>Parakeet.js runs entirely in-browser — 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">
|
|
||||||
⬇ 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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -242,177 +65,36 @@ export function renderLanding(): string {
|
||||||
<section class="rl-section">
|
<section class="rl-section">
|
||||||
<div class="rl-container">
|
<div class="rl-container">
|
||||||
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
|
<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">
|
||||||
<div class="rl-step__num">1</div>
|
<div class="rl-step__num">1</div>
|
||||||
<h3>Live Transcribe</h3>
|
<h3>Upload</h3>
|
||||||
<p>Speak and watch words appear in real time via the Web Speech API. No uploads, no waiting.</p>
|
<p>ZIP your vault folder and upload it to rNotes. The file stays on the server, only metadata is indexed.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rl-step">
|
<div class="rl-step">
|
||||||
<div class="rl-step__num">2</div>
|
<div class="rl-step__num">2</div>
|
||||||
<h3>Audio & Video</h3>
|
<h3>Browse</h3>
|
||||||
<p>Drop files and get full transcripts via Parakeet, running entirely in-browser. Supports MP3, WAV, MP4, and more.</p>
|
<p>Explore your vault's file tree, search notes, and preview markdown content — all read-only.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rl-step">
|
<div class="rl-step">
|
||||||
<div class="rl-step__num">3</div>
|
<div class="rl-step__num">3</div>
|
||||||
<h3>Notebooks & Tags</h3>
|
<h3>Connect</h3>
|
||||||
<p>Organize transcripts alongside notes, clips, code, and files. Tag, search, and filter across everything.</p>
|
<p>View wikilink graphs, follow backlinks, and discover connections across your knowledge base.</p>
|
||||||
</div>
|
|
||||||
<div class="rl-step">
|
|
||||||
<div class="rl-step__num">4</div>
|
|
||||||
<h3>Private & Offline</h3>
|
|
||||||
<p>Parakeet.js runs in-browser — audio never leaves your device. Works offline once the model is cached.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Memory Cards -->
|
<!-- Looking for the Editor? -->
|
||||||
<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 — 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">🏷</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">📂</div>
|
|
||||||
<h3>Hierarchy & 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 — 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">🔄</div>
|
|
||||||
<h3>Import & 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 — 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">📄</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">📎</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">❤</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 —
|
|
||||||
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 — speak and watch your words appear in real time.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rl-card rl-card--center">
|
|
||||||
<h3>Parakeet.js</h3>
|
|
||||||
<p>NVIDIA’s in-browser speech recognition. Transcribe audio and video files offline — 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 -->
|
|
||||||
<section class="rl-section rl-section--alt">
|
<section class="rl-section rl-section--alt">
|
||||||
<div class="rl-container" style="text-align:center">
|
<div class="rl-container" style="text-align:center">
|
||||||
<h2 class="rl-heading">Your Data, Protected</h2>
|
<h2 class="rl-heading">Looking for the Rich Editor?</h2>
|
||||||
<p class="rl-subtext">How rNotes keeps your information safe.</p>
|
<p class="rl-subtext">
|
||||||
<div class="rl-grid-3">
|
The full TipTap editor with notebooks, voice transcription, AI summarization,
|
||||||
<div class="rl-card rl-card--center">
|
and import from 6 sources has moved to <strong>rDocs</strong>.
|
||||||
<div class="rl-icon-box">🔒</div>
|
</p>
|
||||||
<h3>End-to-End Encryption</h3>
|
<div class="rl-cta-row">
|
||||||
<span class="rl-badge">Coming Soon</span>
|
<a href="/rdocs" class="rl-cta-primary">Open rDocs</a>
|
||||||
<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">🕵</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">🏠</div>
|
|
||||||
<h3>Self-Hosted</h3>
|
|
||||||
<p>Run on your own infrastructure. Your server, your rules, your data.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -420,11 +102,10 @@ export function renderLanding(): string {
|
||||||
<!-- CTA -->
|
<!-- CTA -->
|
||||||
<section class="rl-section">
|
<section class="rl-section">
|
||||||
<div class="rl-container" style="text-align:center">
|
<div class="rl-container" style="text-align:center">
|
||||||
<h2 class="rl-heading">(You)rNotes, your thoughts unbound.</h2>
|
<h2 class="rl-heading">Sync Your Vault</h2>
|
||||||
<p class="rl-subtext">Try the demo or create a space to get started.</p>
|
<p class="rl-subtext">Upload your Obsidian or Logseq vault to get started.</p>
|
||||||
<div class="rl-cta-row">
|
<div class="rl-cta-row">
|
||||||
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary">Open Notebook</a>
|
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary">Open Vault Browser</a>
|
||||||
<a href="#transcription-demo" class="rl-cta-secondary">Transcribe</a>
|
|
||||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -1,83 +1,43 @@
|
||||||
/**
|
/**
|
||||||
* rNotes Automerge document schemas.
|
* rNotes Automerge document schemas — Vault Browser.
|
||||||
*
|
*
|
||||||
* Granularity: one Automerge document per notebook.
|
* rNotes is now a vault sync + browse module for Obsidian/Logseq vaults.
|
||||||
* DocId format: {space}:notes:notebooks:{notebookId}
|
* Rich editing moved to rDocs.
|
||||||
*
|
*
|
||||||
* The shape matches the PG→Automerge migration adapter
|
* DocId format: {space}:rnotes:vaults:{vaultId}
|
||||||
* (server/local-first/migration/pg-to-automerge.ts:notesMigration)
|
*
|
||||||
* and the client-side NotebookDoc type in folk-notes-app.ts.
|
* 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';
|
import type { DocSchema } from '../../shared/local-first/document';
|
||||||
|
|
||||||
// ── Document types ──
|
// ── Vault note metadata (no content — lightweight) ──
|
||||||
|
|
||||||
export interface SourceRef {
|
export interface VaultNoteMeta {
|
||||||
source: 'logseq' | 'obsidian' | 'notion' | 'google-docs' | 'evernote' | 'roam' | 'manual';
|
path: string; // relative path within vault (e.g. "daily/2026-04-10.md")
|
||||||
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;
|
|
||||||
title: string;
|
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[];
|
tags: string[];
|
||||||
summary?: string;
|
contentHash: string; // SHA-256 of file content for change detection
|
||||||
summaryModel?: string;
|
sizeBytes: number;
|
||||||
openNotebookSourceId?: string;
|
lastModifiedAt: number; // file mtime from vault
|
||||||
sourceRef?: SourceRef;
|
syncStatus: 'synced' | 'local-modified' | 'conflict';
|
||||||
conflictContent?: string; // Stores remote version on conflict
|
frontmatter?: Record<string, any>; // parsed YAML frontmatter
|
||||||
collabEnabled?: boolean;
|
|
||||||
comments?: Record<string, CommentThread>;
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotebookMeta {
|
export interface VaultMeta {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
name: string;
|
||||||
slug: string;
|
source: 'obsidian' | 'logseq';
|
||||||
description: string;
|
totalNotes: number;
|
||||||
coverColor: string;
|
totalSizeBytes: number;
|
||||||
isPublic: boolean;
|
lastSyncedAt: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotebookDoc {
|
export interface VaultDoc {
|
||||||
meta: {
|
meta: {
|
||||||
module: string;
|
module: string;
|
||||||
collection: string;
|
collection: string;
|
||||||
|
|
@ -85,112 +45,50 @@ export interface NotebookDoc {
|
||||||
spaceSlug: string;
|
spaceSlug: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
notebook: NotebookMeta;
|
vault: VaultMeta;
|
||||||
items: Record<string, NoteItem>;
|
notes: Record<string, VaultNoteMeta>; // keyed by path
|
||||||
|
wikilinks: Record<string, string[]>; // outgoing links per path
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Schema registration ──
|
// ── Schema registration ──
|
||||||
|
|
||||||
export interface ConnectionsDoc {
|
export const vaultSchema: DocSchema<VaultDoc> = {
|
||||||
meta: {
|
module: 'rnotes',
|
||||||
module: string;
|
collection: 'vaults',
|
||||||
collection: string;
|
version: 1,
|
||||||
version: number;
|
init: (): VaultDoc => ({
|
||||||
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 => ({
|
|
||||||
meta: {
|
meta: {
|
||||||
module: 'notes',
|
module: 'rnotes',
|
||||||
collection: 'notebooks',
|
collection: 'vaults',
|
||||||
version: 5,
|
version: 1,
|
||||||
spaceSlug: '',
|
spaceSlug: '',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
notebook: {
|
vault: {
|
||||||
id: '',
|
id: '',
|
||||||
title: 'Untitled Notebook',
|
name: 'Untitled Vault',
|
||||||
slug: '',
|
source: 'obsidian',
|
||||||
description: '',
|
totalNotes: 0,
|
||||||
coverColor: '#3b82f6',
|
totalSizeBytes: 0,
|
||||||
isPublic: false,
|
lastSyncedAt: Date.now(),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
|
||||||
},
|
},
|
||||||
items: {},
|
notes: {},
|
||||||
|
wikilinks: {},
|
||||||
}),
|
}),
|
||||||
migrate: (doc: NotebookDoc, fromVersion: number): NotebookDoc => {
|
migrate: (doc: VaultDoc, _fromVersion: number): VaultDoc => doc,
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
/** Generate a docId for a notebook. */
|
/** Generate a docId for a vault. */
|
||||||
export function notebookDocId(space: string, notebookId: string) {
|
export function vaultDocId(space: string, vaultId: string) {
|
||||||
return `${space}:notes:notebooks:${notebookId}` as const;
|
return `${space}:rnotes:vaults:${vaultId}` as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a fresh NoteItem with defaults. */
|
// ── Legacy re-exports for backward compat ──
|
||||||
export function createNoteItem(
|
// The old rNotes schemas (NotebookDoc, NoteItem, etc.) are now in rdocs/schemas.
|
||||||
id: string,
|
// Converters and MCP tools that still import from here should be updated.
|
||||||
notebookId: string,
|
// For now, re-export from rdocs to avoid breaking shared/converters/types.ts.
|
||||||
title: string,
|
|
||||||
opts: Partial<NoteItem> = {},
|
export type { NoteItem, SourceRef } from '../rdocs/schemas';
|
||||||
): 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
@ -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,
|
* Tools: rnotes_list_vaults, rnotes_browse_vault, rnotes_search_vault,
|
||||||
* rnotes_create_note, rnotes_update_note
|
* rnotes_get_vault_note, rnotes_sync_status
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { SyncServer } from "../local-first/sync-server";
|
import type { SyncServer } from "../local-first/sync-server";
|
||||||
import { notebookDocId, createNoteItem } from "../../modules/rnotes/schemas";
|
import { vaultDocId } from "../../modules/rnotes/schemas";
|
||||||
import type { NotebookDoc, NoteItem } from "../../modules/rnotes/schemas";
|
import type { VaultDoc } from "../../modules/rnotes/schemas";
|
||||||
import { resolveAccess, accessDeniedResponse } from "./_auth";
|
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. */
|
/** Find all vault docIds for a space. */
|
||||||
function findNotebookDocIds(syncServer: SyncServer, space: string): string[] {
|
function findVaultDocIds(syncServer: SyncServer, space: string): string[] {
|
||||||
const prefix = `${space}${NOTEBOOK_PREFIX}`;
|
const prefix = `${space}${VAULT_PREFIX}`;
|
||||||
return syncServer.getDocIds().filter(id => id.startsWith(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) {
|
export function registerNotesTools(server: McpServer, syncServer: SyncServer) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"rnotes_list_notebooks",
|
"rnotes_list_vaults",
|
||||||
"List all notebooks in a space",
|
"List all synced vaults (Obsidian/Logseq) in a space",
|
||||||
{
|
{
|
||||||
space: z.string().describe("Space slug"),
|
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 }) => {
|
async ({ space, token }) => {
|
||||||
const access = await resolveAccess(token, space, false);
|
const access = await resolveAccess(token, space, false);
|
||||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||||
|
|
||||||
const docIds = findNotebookDocIds(syncServer, space);
|
const docIds = findVaultDocIds(syncServer, space);
|
||||||
const notebooks = [];
|
const vaults = [];
|
||||||
for (const docId of docIds) {
|
for (const docId of docIds) {
|
||||||
const doc = syncServer.getDoc<NotebookDoc>(docId);
|
const doc = syncServer.getDoc<VaultDoc>(docId);
|
||||||
if (!doc?.notebook) continue;
|
if (!doc?.vault) continue;
|
||||||
notebooks.push({
|
vaults.push({
|
||||||
id: doc.notebook.id,
|
id: doc.vault.id,
|
||||||
title: doc.notebook.title,
|
name: doc.vault.name,
|
||||||
slug: doc.notebook.slug,
|
source: doc.vault.source,
|
||||||
description: doc.notebook.description,
|
totalNotes: doc.vault.totalNotes,
|
||||||
noteCount: Object.keys(doc.items || {}).length,
|
lastSyncedAt: doc.vault.lastSyncedAt,
|
||||||
createdAt: doc.notebook.createdAt,
|
createdAt: doc.vault.createdAt,
|
||||||
updatedAt: doc.notebook.updatedAt,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { content: [{ type: "text", text: JSON.stringify(notebooks, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(vaults, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"rnotes_list_notes",
|
"rnotes_browse_vault",
|
||||||
"List notes, optionally filtered by notebook, search text, or tags",
|
"Browse notes in a vault, optionally filtered by folder path",
|
||||||
{
|
{
|
||||||
space: z.string().describe("Space slug"),
|
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"),
|
||||||
notebook_id: z.string().optional().describe("Filter by notebook ID"),
|
vault_id: z.string().describe("Vault ID"),
|
||||||
search: z.string().optional().describe("Search in title/content"),
|
folder: z.string().optional().describe("Folder path prefix (e.g. 'daily/')"),
|
||||||
limit: z.number().optional().describe("Max results (default 50)"),
|
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);
|
const access = await resolveAccess(token, space, false);
|
||||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||||
|
|
||||||
const docIds = notebook_id
|
const doc = syncServer.getDoc<VaultDoc>(vaultDocId(space, vault_id));
|
||||||
? [notebookDocId(space, notebook_id)]
|
if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] };
|
||||||
: findNotebookDocIds(syncServer, space);
|
|
||||||
|
|
||||||
let notes: Array<NoteItem & { notebookTitle: string }> = [];
|
let notes = Object.values(doc.notes || {});
|
||||||
for (const docId of docIds) {
|
if (folder) {
|
||||||
const doc = syncServer.getDoc<NotebookDoc>(docId);
|
notes = notes.filter(n => n.path.startsWith(folder));
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
notes.sort((a, b) => b.lastModifiedAt - a.lastModifiedAt);
|
||||||
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);
|
|
||||||
const maxResults = limit || 50;
|
const maxResults = limit || 50;
|
||||||
notes = notes.slice(0, maxResults);
|
notes = notes.slice(0, maxResults);
|
||||||
|
|
||||||
const summary = notes.map(n => ({
|
const summary = notes.map(n => ({
|
||||||
id: n.id,
|
path: n.path,
|
||||||
notebookId: n.notebookId,
|
|
||||||
notebookTitle: n.notebookTitle,
|
|
||||||
title: n.title,
|
title: n.title,
|
||||||
type: n.type,
|
|
||||||
tags: n.tags,
|
tags: n.tags,
|
||||||
isPinned: n.isPinned,
|
sizeBytes: n.sizeBytes,
|
||||||
contentPreview: (n.contentPlain || "").slice(0, 200),
|
lastModifiedAt: n.lastModifiedAt,
|
||||||
createdAt: n.createdAt,
|
syncStatus: n.syncStatus,
|
||||||
updatedAt: n.updatedAt,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
||||||
|
|
@ -116,117 +108,114 @@ export function registerNotesTools(server: McpServer, syncServer: SyncServer) {
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"rnotes_get_note",
|
"rnotes_search_vault",
|
||||||
"Get the full content of a specific note",
|
"Search notes across all vaults by title or tags",
|
||||||
{
|
{
|
||||||
space: z.string().describe("Space slug"),
|
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"),
|
||||||
note_id: z.string().describe("Note ID"),
|
search: z.string().describe("Search term (matches title and tags)"),
|
||||||
notebook_id: z.string().optional().describe("Notebook ID (speeds up lookup)"),
|
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);
|
const access = await resolveAccess(token, space, false);
|
||||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||||
|
|
||||||
if (notebook_id) {
|
const docIds = vault_id
|
||||||
const doc = syncServer.getDoc<NotebookDoc>(notebookDocId(space, notebook_id));
|
? [vaultDocId(space, vault_id)]
|
||||||
const note = doc?.items?.[note_id];
|
: findVaultDocIds(syncServer, space);
|
||||||
if (note) {
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const docId of findNotebookDocIds(syncServer, space)) {
|
const q = search.toLowerCase();
|
||||||
const doc = syncServer.getDoc<NotebookDoc>(docId);
|
const results: Array<{ vaultName: string; path: string; title: string; tags: string[] }> = [];
|
||||||
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);
|
|
||||||
|
|
||||||
for (const docId of docIds) {
|
for (const docId of docIds) {
|
||||||
const doc = syncServer.getDoc<NotebookDoc>(docId);
|
const doc = syncServer.getDoc<VaultDoc>(docId);
|
||||||
if (!doc?.items?.[note_id]) continue;
|
if (!doc?.notes) continue;
|
||||||
|
const vaultName = doc.vault?.name || "Unknown";
|
||||||
syncServer.changeDoc<NotebookDoc>(docId, `Update note ${note_id}`, (d) => {
|
for (const note of Object.values(doc.notes)) {
|
||||||
const n = d.items[note_id];
|
if (
|
||||||
if (updates.title !== undefined) n.title = updates.title;
|
note.title.toLowerCase().includes(q) ||
|
||||||
if (updates.content !== undefined) {
|
note.path.toLowerCase().includes(q) ||
|
||||||
n.content = updates.content;
|
note.tags.some(t => t.toLowerCase().includes(q))
|
||||||
n.contentPlain = updates.content;
|
) {
|
||||||
|
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),
|
||||||
|
}],
|
||||||
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getUpcomingEventsForMI } from "../modules/rcal/mod";
|
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 { getRecentTasksForMI } from "../modules/rtasks/mod";
|
||||||
import { getRecentCampaignsForMI } from "../modules/rsocials/mod";
|
import { getRecentCampaignsForMI } from "../modules/rsocials/mod";
|
||||||
import { getRecentContactsForMI } from "../modules/rnetwork/mod";
|
import { getRecentContactsForMI } from "../modules/rnetwork/mod";
|
||||||
|
|
@ -61,12 +61,12 @@ export function queryModuleContent(
|
||||||
): MiQueryResult {
|
): MiQueryResult {
|
||||||
switch (module) {
|
switch (module) {
|
||||||
case "rnotes": {
|
case "rnotes": {
|
||||||
const notes = getRecentNotesForMI(space, limit);
|
const notes = getRecentVaultNotesForMI(space, limit);
|
||||||
if (queryType === "count") {
|
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)}...`);
|
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 ? `Recent notes:\n${lines.join("\n")}` : "No notes found." };
|
return { ok: true, module, queryType, data: notes, summary: lines.length ? `Vault notes:\n${lines.join("\n")}` : "No vault notes found." };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "rtasks": {
|
case "rtasks": {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import type { EncryptIDClaims } from "./auth";
|
||||||
import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes";
|
import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes";
|
||||||
import type { MiAction } from "../lib/mi-actions";
|
import type { MiAction } from "../lib/mi-actions";
|
||||||
import { getUpcomingEventsForMI } from "../modules/rcal/mod";
|
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 { getRecentTasksForMI } from "../modules/rtasks/mod";
|
||||||
import { getRecentCampaignsForMI } from "../modules/rsocials/mod";
|
import { getRecentCampaignsForMI } from "../modules/rsocials/mod";
|
||||||
import { getRecentContactsForMI } from "../modules/rnetwork/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")}`;
|
calendarContext = `\n- Upcoming events (next 14 days):\n${lines.join("\n")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recentNotes = getRecentNotesForMI(space, 3);
|
const vaultNotes = getRecentVaultNotesForMI(space, 3);
|
||||||
if (recentNotes.length > 0) {
|
if (vaultNotes.length > 0) {
|
||||||
const lines = recentNotes.map((n) =>
|
const lines = vaultNotes.map((n) =>
|
||||||
`- "${n.title}" (${n.type}${n.tags.length ? `, tags: ${n.tags.join(", ")}` : ""}): ${n.contentPlain.slice(0, 100)}…`
|
`- "${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);
|
const openTasks = getRecentTasksForMI(space, 5);
|
||||||
|
|
@ -885,12 +885,12 @@ mi.post("/suggestions", async (c) => {
|
||||||
|
|
||||||
// Check if current module has zero content — "get started" suggestion
|
// Check if current module has zero content — "get started" suggestion
|
||||||
if (currentModule === "rnotes") {
|
if (currentModule === "rnotes") {
|
||||||
const notes = getRecentNotesForMI(space, 1);
|
const vaults = getRecentVaultNotesForMI(space, 1);
|
||||||
if (notes.length === 0) {
|
if (vaults.length === 0) {
|
||||||
suggestions.push({
|
suggestions.push({
|
||||||
label: "Create your first note",
|
label: "Upload your first vault",
|
||||||
icon: "📝",
|
icon: "🔗",
|
||||||
prompt: "Help me create my first notebook",
|
prompt: "Help me upload my Obsidian or Logseq vault",
|
||||||
autoSend: true,
|
autoSend: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -928,12 +928,12 @@ mi.post("/suggestions", async (c) => {
|
||||||
|
|
||||||
// Recent note/doc to continue editing
|
// Recent note/doc to continue editing
|
||||||
if (currentModule === "rnotes") {
|
if (currentModule === "rnotes") {
|
||||||
const recent = getRecentNotesForMI(space, 1);
|
const recent = getRecentVaultNotesForMI(space, 1);
|
||||||
if (recent.length > 0) {
|
if (recent.length > 0) {
|
||||||
suggestions.push({
|
suggestions.push({
|
||||||
label: `Continue "${recent[0].title}"`,
|
label: `Browse "${recent[0].title}"`,
|
||||||
icon: "📝",
|
icon: "🔗",
|
||||||
prompt: `Help me continue working on "${recent[0].title}"`,
|
prompt: `Show me the note "${recent[0].title}" from ${recent[0].vaultName}`,
|
||||||
autoSend: true,
|
autoSend: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type { MiAction } from "../../lib/mi-actions";
|
||||||
import { MiActionExecutor } from "../../lib/mi-action-executor";
|
import { MiActionExecutor } from "../../lib/mi-action-executor";
|
||||||
import { suggestTools, type ToolHint } from "../../lib/mi-tool-schema";
|
import { suggestTools, type ToolHint } from "../../lib/mi-tool-schema";
|
||||||
import { SpeechDictation } from "../../lib/speech-dictation";
|
import { SpeechDictation } from "../../lib/speech-dictation";
|
||||||
|
import { MiVoiceBridge, type VoiceState } from "../../lib/mi-voice-bridge";
|
||||||
import { getContextSuggestions } from "../../lib/mi-suggestions";
|
import { getContextSuggestions } from "../../lib/mi-suggestions";
|
||||||
import type { MiSuggestion } from "../../lib/mi-suggestions";
|
import type { MiSuggestion } from "../../lib/mi-suggestions";
|
||||||
|
|
||||||
|
|
@ -45,6 +46,12 @@ export class RStackMi extends HTMLElement {
|
||||||
#dynamicSuggestions: MiSuggestion[] = [];
|
#dynamicSuggestions: MiSuggestion[] = [];
|
||||||
#placeholderIdx = 0;
|
#placeholderIdx = 0;
|
||||||
#placeholderTimer: ReturnType<typeof setInterval> | null = null;
|
#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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -63,6 +70,7 @@ export class RStackMi extends HTMLElement {
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
document.removeEventListener("keydown", this.#keyHandler);
|
document.removeEventListener("keydown", this.#keyHandler);
|
||||||
if (this.#placeholderTimer) clearInterval(this.#placeholderTimer);
|
if (this.#placeholderTimer) clearInterval(this.#placeholderTimer);
|
||||||
|
if (this.#voiceMode) this.#deactivateVoiceMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
#keyHandler = (e: KeyboardEvent) => {
|
#keyHandler = (e: KeyboardEvent) => {
|
||||||
|
|
@ -147,6 +155,7 @@ export class RStackMi extends HTMLElement {
|
||||||
<input class="mi-input-bar" id="mi-bar-input" type="text"
|
<input class="mi-input-bar" id="mi-bar-input" type="text"
|
||||||
placeholder="Ask mi anything..." autocomplete="off" />
|
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-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>
|
||||||
<div class="mi-panel" id="mi-panel">
|
<div class="mi-panel" id="mi-panel">
|
||||||
<div class="mi-panel-header">
|
<div class="mi-panel-header">
|
||||||
|
|
@ -154,6 +163,7 @@ export class RStackMi extends HTMLElement {
|
||||||
<span class="mi-panel-title">mi</span>
|
<span class="mi-panel-title">mi</span>
|
||||||
<select class="mi-model-select" id="mi-model-select" title="Select AI model"></select>
|
<select class="mi-model-select" id="mi-model-select" title="Select AI model"></select>
|
||||||
<div class="mi-panel-spacer"></div>
|
<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)">−</button>
|
<button class="mi-panel-btn" id="mi-minimize" title="Minimize (Escape)">−</button>
|
||||||
<button class="mi-panel-btn" id="mi-close" title="Close">×</button>
|
<button class="mi-panel-btn" id="mi-close" title="Close">×</button>
|
||||||
</div>
|
</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>
|
<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>
|
<span class="mi-scaffold-label" id="mi-scaffold-label"></span>
|
||||||
</div>
|
</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">×</button>
|
||||||
|
</div>
|
||||||
<div class="mi-input-area">
|
<div class="mi-input-area">
|
||||||
<textarea class="mi-input" id="mi-input" rows="1"
|
<textarea class="mi-input" id="mi-input" rows="1"
|
||||||
placeholder="Ask mi to build, create, or explore..." autocomplete="off"></textarea>
|
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
|
// Close panel on outside click — use composedPath to pierce Shadow DOM
|
||||||
document.addEventListener("pointerdown", (e) => {
|
document.addEventListener("pointerdown", (e) => {
|
||||||
|
if (this.#voiceMode) return; // Keep panel open during voice conversation
|
||||||
const path = e.composedPath();
|
const path = e.composedPath();
|
||||||
if (!path.includes(this)) {
|
if (!path.includes(this)) {
|
||||||
panel.classList.remove("open");
|
panel.classList.remove("open");
|
||||||
|
|
@ -324,6 +341,7 @@ export class RStackMi extends HTMLElement {
|
||||||
|
|
||||||
micBtn.addEventListener("click", (e) => {
|
micBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (this.#voiceMode) return; // Bar mic disabled during voice mode
|
||||||
if (!this.#dictation!.isRecording) {
|
if (!this.#dictation!.isRecording) {
|
||||||
baseText = barInput.value;
|
baseText = barInput.value;
|
||||||
}
|
}
|
||||||
|
|
@ -331,6 +349,24 @@ export class RStackMi extends HTMLElement {
|
||||||
barInput.focus();
|
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() {
|
#loadSuggestions() {
|
||||||
|
|
@ -440,6 +476,184 @@ export class RStackMi extends HTMLElement {
|
||||||
}, 8000);
|
}, 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() {
|
#minimize() {
|
||||||
this.#minimized = true;
|
this.#minimized = true;
|
||||||
const panel = this.#shadow.getElementById("mi-panel")!;
|
const panel = this.#shadow.getElementById("mi-panel")!;
|
||||||
|
|
@ -1099,6 +1313,90 @@ const STYLES = `
|
||||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
-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) {
|
@media (max-width: 640px) {
|
||||||
.mi { max-width: none; width: 100%; }
|
.mi { max-width: none; width: 100%; }
|
||||||
.mi-panel {
|
.mi-panel {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue