Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m53s
Details
CI/CD / deploy (push) Successful in 2m53s
Details
This commit is contained in:
commit
76e75c4e69
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
id: TASK-140
|
||||
title: IPFS integration for backups and generated files
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-02 22:11'
|
||||
updated_date: '2026-04-02 22:11'
|
||||
labels:
|
||||
- infra
|
||||
- ipfs
|
||||
- storage
|
||||
dependencies: []
|
||||
references:
|
||||
- server/ipfs.ts
|
||||
- server/ipfs-routes.ts
|
||||
- server/local-first/backup-store.ts
|
||||
- server/local-first/backup-routes.ts
|
||||
- server/index.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add IPFS as a redundant storage layer via Kubo (ipfs.jeffemmett.com). Pin encrypted backups and AI-generated files (images, 3D models, zines) to IPFS fire-and-forget. Filesystem remains primary — IPFS failures are non-fatal. API routes at /api/ipfs for status, pin/unpin, and gateway proxy.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 server/ipfs.ts client library with pin/unpin/status functions
|
||||
- [x] #2 server/ipfs-routes.ts Hono router at /api/ipfs (status, pin, unpin, gateway proxy)
|
||||
- [x] #3 Backup pinning in backup-store.ts (fire-and-forget, CID in manifest)
|
||||
- [x] #4 IPFS URL route in backup-routes.ts (GET /:space/:docId/ipfs)
|
||||
- [x] #5 Generated file pinning with .cid sidecar files for 8 producer endpoints
|
||||
- [x] #6 IPFS_API_URL and IPFS_GATEWAY_URL env vars in docker-compose.yml
|
||||
- [x] #7 Kubo reachable from rspace container via traefik-public network
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented IPFS integration for rspace-online. Created server/ipfs.ts (client library) and server/ipfs-routes.ts (API routes at /api/ipfs). Modified backup-store.ts to pin encrypted backups fire-and-forget with CID stored in manifest. Added pinGeneratedFile() helper in server/index.ts called from 8 producer endpoints (3D models, fal.ai images, Gemini/Imagen images, zine pages). Each pinned file gets a .cid sidecar loaded into memory cache on startup. Kubo container is collab-server-ipfs-1 on traefik-public network. Deployed and verified on Netcup. Key deployment discovery: server uses local Gitea registry (localhost:3000/jeffemmett/rspace-online), not compose build — documented in MEMORY.md.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
id: TASK-141
|
||||
title: SMS/text-based poll input for rSpace (magic links + optional Twilio 2-way)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-04-09 16:42'
|
||||
labels:
|
||||
- rChoices
|
||||
- rCal
|
||||
- integration
|
||||
- SMS
|
||||
dependencies: []
|
||||
references:
|
||||
- modules/rchoices/mod.ts
|
||||
- modules/rcal/mod.ts
|
||||
- modules/rschedule/mod.ts
|
||||
- server/index.ts (webhook pattern examples around line 2808)
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Enable lightweight poll/RSVP responses via text message. Users send a text or click a magic link in SMS/email to respond to polls (e.g. "1" for Yes, "0" for No).
|
||||
|
||||
## Phased approach:
|
||||
1. **Phase 1 — Magic links (minimal infra):** Generate per-participant short token URLs for polls/RSVPs. Send via Mailcow email (free) or any SMS API. Recipient clicks link → minimal no-auth 1-tap response page → updates Automerge doc (rChoices poll or rCal RSVP).
|
||||
2. **Phase 2 — Twilio 2-way SMS:** Twilio number (~$1/mo), outbound SMS with poll question + response codes, inbound webhook at `POST /api/sms/inbound` parses reply digit, phone→DID mapping table to attribute responses.
|
||||
|
||||
## Key integration points:
|
||||
- **rChoices** (`modules/rchoices/`) — simple polls (vote/rank/spider)
|
||||
- **rCal** (`modules/rcal/`) — event RSVPs (attendee fields exist but stub)
|
||||
- **rSchedule** (`modules/rschedule/`) — could add SMS as action type for scheduled sends
|
||||
- **Existing webhook pattern** — follow payment webhook style (unauthenticated POST endpoints)
|
||||
|
||||
## Design notes from initial discussion:
|
||||
- Magic link approach gets 90% of value with minimal new infra
|
||||
- Twilio costs ~$0.02/round-trip, magic links ~$0.01 outbound only
|
||||
- Email-based variant is free via existing Mailcow setup
|
||||
- Need phone→DID mapping if doing 2-way SMS
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Magic link generation for polls/RSVPs with unique per-participant tokens
|
||||
- [ ] #2 Minimal no-auth response page (1-tap Yes/No) that updates Automerge doc
|
||||
- [ ] #3 Email delivery of magic links via Mailcow
|
||||
- [ ] #4 Optional: Twilio outbound SMS delivery
|
||||
- [ ] #5 Optional: Twilio inbound webhook parsing for 2-way SMS replies
|
||||
- [ ] #6 Optional: Phone-to-DID mapping table for SMS identity attribution
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
id: TASK-HIGH.7
|
||||
title: Intent-routed resource-backed commitments for rTime
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-01 05:36'
|
||||
updated_date: '2026-04-01 05:38'
|
||||
labels: []
|
||||
dependencies: []
|
||||
parent_task_id: TASK-HIGH
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Anoma-style intent routing integrated into rTime. Members declare needs/capacities as intents, solver finds collaboration clusters via Mycelium Clustering algorithm, settlement locks tokens via CRDT escrow. New files: schemas-intent.ts, solver.ts, settlement.ts, skill-curve.ts, reputation.ts, intent-routes.ts. Frontend: Collaborate tab with intent cards, solver results, accept/reject, skill prices, status rings on pool orbs.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Intent CRUD routes working
|
||||
- [x] #2 Solver produces valid cluster matches
|
||||
- [x] #3 Settlement creates connections and tasks atomically
|
||||
- [x] #4 Skill curve pricing responds to supply/demand
|
||||
- [x] #5 Collaborate tab renders in frontend
|
||||
- [x] #6 Status rings visible on pool orbs
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Committed 08cae26, pushed to Gitea/GitHub, merged dev→main, rebuild triggered on Netcup.
|
||||
|
||||
Deployed to production. Commit 08cae26, built and running on Netcup. Live at rspace.online/{space}/rtime (Collaborate tab).
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -11,6 +11,8 @@ import './folk-listing';
|
|||
import './folk-stay-request';
|
||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||
import { getModuleApiBase } from "../../../shared/url-helpers";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { bnbSchema, bnbDocId } from "../schemas";
|
||||
|
||||
const BNB_TOUR_STEPS: TourStep[] = [
|
||||
{ target: '.bnb-search', title: 'Search', message: 'Filter listings by location, type, or economy model.' },
|
||||
|
|
@ -68,16 +70,28 @@ class FolkBnbView extends HTMLElement {
|
|||
#mapContainer: HTMLElement | null = null;
|
||||
#tour: LightTourEngine | null = null;
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
this.#space = this.getAttribute('space') || 'demo';
|
||||
this.#render();
|
||||
this.#loadData();
|
||||
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rbnb', context: 'Listings' }));
|
||||
if (this.#space !== 'demo') this.subscribeOffline();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._stopPresence?.();
|
||||
this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
try {
|
||||
const docId = bnbDocId(this.#space) as DocumentId;
|
||||
await runtime.subscribe(docId, bnbSchema);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||
|
|
@ -277,7 +291,7 @@ class FolkBnbView extends HTMLElement {
|
|||
const typeLabel = (l.type || 'room').replace(/_/g, ' ');
|
||||
|
||||
return `
|
||||
<div class="bnb-card" data-listing-id="${l.id}">
|
||||
<div class="bnb-card" data-listing-id="${l.id}" data-collab-id="listing:${l.id}">
|
||||
<div class="bnb-card__cover">${typeIcon}</div>
|
||||
<div class="bnb-card__body">
|
||||
<div class="bnb-card__title">${this.#esc(l.title)}</div>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ class FolkCartShop extends HTMLElement {
|
|||
private creatingPayment = false;
|
||||
private creatingGroupBuy = false;
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts");
|
||||
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts", "rcart");
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
|
||||
// Guided tour
|
||||
|
|
@ -91,9 +91,12 @@ class FolkCartShop extends HTMLElement {
|
|||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rcart', context: this.selectedCatalogItem?.title || this.view }));
|
||||
window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._history.destroy();
|
||||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
this._offlineUnsubs = [];
|
||||
this._stopPresence?.();
|
||||
|
|
@ -539,6 +542,14 @@ class FolkCartShop extends HTMLElement {
|
|||
|
||||
startTour() { this._tour.start(); }
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
if (e.detail?.moduleId !== 'rcart') return;
|
||||
this.view = e.detail.view;
|
||||
if (e.detail.view !== "cart-detail") this.selectedCartId = null;
|
||||
if (e.detail.view !== "order-detail") this.selectedOrder = null;
|
||||
this.render();
|
||||
};
|
||||
|
||||
private goBack() {
|
||||
const prev = this._history.back();
|
||||
if (!prev) return;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@
|
|||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { ChoicesLocalFirstClient } from "../local-first-client";
|
||||
import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas";
|
||||
import { choicesSchema, choicesDocId } from "../schemas";
|
||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||
import { getModuleApiBase, rspaceNavUrl, getCurrentSpace } from "../../../shared/url-helpers";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
// ── CrowdSurf types ──
|
||||
interface CrowdSurfOption {
|
||||
|
|
@ -139,6 +141,7 @@ class FolkChoicesDashboard extends HTMLElement {
|
|||
} catch (err) {
|
||||
console.warn('[rChoices] Local-first init failed, falling back to API:', err);
|
||||
}
|
||||
this.subscribeCollabOverlay();
|
||||
|
||||
// Also load canvas-based choices
|
||||
await this.loadChoices();
|
||||
|
|
@ -148,6 +151,15 @@ class FolkChoicesDashboard extends HTMLElement {
|
|||
this.bindLiveEvents();
|
||||
}
|
||||
|
||||
private async subscribeCollabOverlay() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
try {
|
||||
const docId = choicesDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, choicesSchema);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private extractSessions(doc: ChoicesDoc) {
|
||||
this.sessions = doc.sessions ? Object.values(doc.sessions).sort((a, b) => b.createdAt - a.createdAt) : [];
|
||||
// Pre-compute votes per session
|
||||
|
|
|
|||
|
|
@ -0,0 +1,315 @@
|
|||
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.
|
After Width: | Height: | Size: 837 B |
Binary file not shown.
|
After Width: | Height: | Size: 185 B |
Binary file not shown.
|
After Width: | Height: | Size: 349 B |
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
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);
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* 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,
|
||||
};
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
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);
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 360px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
background: #171717;
|
||||
border-bottom: 1px solid #262626;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.header .brand {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: #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>
|
||||
|
|
@ -0,0 +1,610 @@
|
|||
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);
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* 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,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,918 @@
|
|||
/**
|
||||
* <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
|
|
@ -0,0 +1,578 @@
|
|||
/**
|
||||
* <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
|
|
@ -0,0 +1,7 @@
|
|||
/* Notes module — dark theme (host-level styles) */
|
||||
folk-notes-app {
|
||||
display: block;
|
||||
min-height: 400px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* 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,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
/**
|
||||
* 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);
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* 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';
|
||||
}
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
/**
|
||||
* 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);
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Converter registry and shared types for rDocs import/export.
|
||||
*
|
||||
* All source-specific converters implement NoteConverter.
|
||||
* ConvertedNote is the intermediate format between external sources and NoteItem.
|
||||
*/
|
||||
|
||||
import type { NoteItem, SourceRef } from '../schemas';
|
||||
|
||||
// Re-export types from shared converters
|
||||
export type { ConvertedNote, ImportResult, ExportResult, NoteConverter, ImportInput, ExportOptions } from '../../../shared/converters/types';
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
// ── Converter registry ──
|
||||
|
||||
import type { NoteConverter } from '../../../shared/converters/types';
|
||||
|
||||
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 ──
|
||||
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');
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Logseq converter — re-exports from shared and registers with rDocs converter system.
|
||||
*/
|
||||
import { logseqConverter } from '../../../shared/converters/logseq';
|
||||
import { registerConverter } from './index';
|
||||
|
||||
export { logseqConverter };
|
||||
|
||||
registerConverter(logseqConverter);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Re-export from shared location.
|
||||
* Markdown ↔ TipTap conversion is now shared across modules.
|
||||
*/
|
||||
export {
|
||||
markdownToTiptap,
|
||||
tiptapToMarkdown,
|
||||
extractPlainTextFromTiptap,
|
||||
} from '../../../shared/markdown-tiptap';
|
||||
|
|
@ -0,0 +1,462 @@
|
|||
/**
|
||||
* 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';
|
||||
// Note: imports from './index' and './markdown-tiptap' resolve to rdocs-local copies
|
||||
|
||||
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);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Obsidian converter — re-exports from shared and registers with rDocs converter system.
|
||||
*/
|
||||
import { obsidianConverter } from '../../../shared/converters/obsidian';
|
||||
import { registerConverter } from './index';
|
||||
|
||||
export { obsidianConverter };
|
||||
|
||||
registerConverter(obsidianConverter);
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* 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);
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* 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 { tiptapToMarkdown } 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* rDocs module landing page — static HTML, no React.
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
<!-- Hero -->
|
||||
<div class="rl-hero">
|
||||
<span class="rl-tagline">rDocs</span>
|
||||
<h1 class="rl-heading" style="background:linear-gradient(135deg,#14b8a6,#22d3ee);-webkit-background-clip:text;background-clip:text">Documents, notebooks, and knowledge.</h1>
|
||||
<p class="rl-subtitle" style="background:linear-gradient(135deg,#14b8a6,#22d3ee);-webkit-background-clip:text;background-clip:text">Rich Text, Voice, Import & Export — All in One Place</p>
|
||||
<p class="rl-subtext">
|
||||
Full TipTap editor with notebooks, voice transcription, AI summarization,
|
||||
and import from Obsidian, Logseq, Notion, Google Docs, Evernote, and Roam.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rdocs" class="rl-cta-primary" id="ml-primary">Open Docs</a>
|
||||
<a href="#features" class="rl-cta-secondary">Features</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<section class="rl-section" id="features">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">What rDocs Handles</h2>
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">📝</div>
|
||||
<h3>Rich Text Editor</h3>
|
||||
<p>Write with a full TipTap editor — formatting, code blocks, checklists, embeds, slash commands, and comments with track changes.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🎤</div>
|
||||
<h3>Voice & Transcription</h3>
|
||||
<p>Record voice notes with live transcription via Web Speech API. Drop audio or video files for offline transcripts with Parakeet.js.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔄</div>
|
||||
<h3>Import & Export</h3>
|
||||
<p>Bring your notes from Obsidian, Logseq, Notion, Google Docs, Evernote, and Roam. Export back to any format.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Built on Open Source -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
|
||||
<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.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>TipTap</h3>
|
||||
<p>Headless rich text editor built on ProseMirror.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>Parakeet.js</h3>
|
||||
<p>In-browser speech recognition for offline transcription.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<h3>Hono</h3>
|
||||
<p>Ultra-fast API framework powering the backend.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container" style="text-align:center">
|
||||
<h2 class="rl-heading">Start Writing</h2>
|
||||
<p class="rl-subtext">Create a space or try the demo.</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rdocs" class="rl-cta-primary">Open Docs</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rl-back">
|
||||
<a href="/">← Back to rSpace</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -1,55 +1,159 @@
|
|||
/**
|
||||
* rDocs Local-First Client — syncs linked Docmost documents.
|
||||
* rDocs Local-First Client
|
||||
*
|
||||
* Wraps the shared local-first stack (DocSyncManager + EncryptedDocStore)
|
||||
* into a docs-specific API with proper offline support and encryption.
|
||||
*/
|
||||
|
||||
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 { docsSchema, docsDocId } from './schemas';
|
||||
import type { DocsDoc, LinkedDocument } from './schemas';
|
||||
import { notebookSchema, notebookDocId } from './schemas';
|
||||
import type { NotebookDoc, NoteItem, NotebookMeta } from './schemas';
|
||||
|
||||
export class DocsLocalFirstClient {
|
||||
#space: string; #documents: DocumentManager; #store: EncryptedDocStore; #sync: DocSyncManager; #initialized = false;
|
||||
#space: string;
|
||||
#documents: DocumentManager;
|
||||
#store: EncryptedDocStore;
|
||||
#sync: DocSyncManager;
|
||||
#initialized = false;
|
||||
|
||||
constructor(space: string, docCrypto?: DocCrypto) {
|
||||
this.#space = space; this.#documents = new DocumentManager();
|
||||
this.#space = space;
|
||||
this.#documents = new DocumentManager();
|
||||
this.#store = new EncryptedDocStore(space, docCrypto);
|
||||
this.#sync = new DocSyncManager({ documents: this.#documents, store: this.#store });
|
||||
this.#documents.registerSchema(docsSchema);
|
||||
this.#sync = new DocSyncManager({
|
||||
documents: this.#documents,
|
||||
store: this.#store,
|
||||
});
|
||||
|
||||
this.#documents.registerSchema(notebookSchema);
|
||||
}
|
||||
|
||||
get isConnected(): boolean { return this.#sync.isConnected; }
|
||||
get isInitialized(): boolean { return this.#initialized; }
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.#initialized) return;
|
||||
|
||||
await this.#store.open();
|
||||
const cachedIds = await this.#store.listByModule('docs', 'links');
|
||||
|
||||
const cachedIds = await this.#store.listByModule('notes', 'notebooks');
|
||||
const cached = await this.#store.loadMany(cachedIds);
|
||||
for (const [docId, binary] of cached) this.#documents.open<DocsDoc>(docId, docsSchema, binary);
|
||||
for (const [docId, binary] of cached) {
|
||||
this.#documents.open<NotebookDoc>(docId, notebookSchema, binary);
|
||||
}
|
||||
|
||||
await this.#sync.preloadSyncStates(cachedIds);
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
try { await this.#sync.connect(`${proto}//${location.host}/ws/${this.#space}`, this.#space); } catch {}
|
||||
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
|
||||
try {
|
||||
await this.#sync.connect(wsUrl, this.#space);
|
||||
} catch {
|
||||
console.warn('[DocsClient] WebSocket connection failed, working offline');
|
||||
}
|
||||
|
||||
this.#initialized = true;
|
||||
}
|
||||
|
||||
async subscribe(): Promise<DocsDoc | null> {
|
||||
const docId = docsDocId(this.#space) as DocumentId;
|
||||
let doc = this.#documents.get<DocsDoc>(docId);
|
||||
if (!doc) { const b = await this.#store.load(docId); doc = b ? this.#documents.open<DocsDoc>(docId, docsSchema, b) : this.#documents.open<DocsDoc>(docId, docsSchema); }
|
||||
await this.#sync.subscribe([docId]); return doc ?? null;
|
||||
async subscribeNotebook(notebookId: string): Promise<NotebookDoc | null> {
|
||||
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
|
||||
|
||||
let doc = this.#documents.get<NotebookDoc>(docId);
|
||||
if (!doc) {
|
||||
const binary = await this.#store.load(docId);
|
||||
if (binary) {
|
||||
doc = this.#documents.open<NotebookDoc>(docId, notebookSchema, binary);
|
||||
} else {
|
||||
doc = this.#documents.open<NotebookDoc>(docId, notebookSchema);
|
||||
}
|
||||
}
|
||||
|
||||
getDoc(): DocsDoc | undefined { return this.#documents.get<DocsDoc>(docsDocId(this.#space) as DocumentId); }
|
||||
onChange(cb: (doc: DocsDoc) => void): () => void { return this.#sync.onChange(docsDocId(this.#space) as DocumentId, cb as (doc: any) => void); }
|
||||
|
||||
linkDocument(doc: LinkedDocument): void {
|
||||
this.#sync.change<DocsDoc>(docsDocId(this.#space) as DocumentId, `Link ${doc.title}`, (d) => { d.linkedDocuments[doc.id] = doc; });
|
||||
}
|
||||
unlinkDocument(id: string): void {
|
||||
this.#sync.change<DocsDoc>(docsDocId(this.#space) as DocumentId, `Unlink document`, (d) => { delete d.linkedDocuments[id]; });
|
||||
await this.#sync.subscribe([docId]);
|
||||
return doc ?? null;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> { await this.#sync.flush(); this.#sync.disconnect(); }
|
||||
unsubscribeNotebook(notebookId: string): void {
|
||||
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
|
||||
this.#sync.unsubscribe([docId]);
|
||||
}
|
||||
|
||||
getNotebook(notebookId: string): NotebookDoc | undefined {
|
||||
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
|
||||
return this.#documents.get<NotebookDoc>(docId);
|
||||
}
|
||||
|
||||
listNotebookIds(): string[] {
|
||||
return this.#documents.list(this.#space, 'notes');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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];
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
onConnect(cb: () => void): () => void {
|
||||
return this.#sync.onConnect(cb);
|
||||
}
|
||||
|
||||
onDisconnect(cb: () => void): () => void {
|
||||
return this.#sync.onDisconnect(cb);
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.#sync.flush();
|
||||
this.#sync.disconnect();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1818
modules/rdocs/mod.ts
1818
modules/rdocs/mod.ts
File diff suppressed because it is too large
Load Diff
|
|
@ -1,36 +1,193 @@
|
|||
/**
|
||||
* rDocs Automerge document schemas.
|
||||
*
|
||||
* Syncs linked Docmost documents per space.
|
||||
* Actual document content lives in the Docmost instance.
|
||||
* Granularity: one Automerge document per notebook.
|
||||
* DocId format: {space}:notes:notebooks:{notebookId}
|
||||
*
|
||||
* DocId format: {space}:docs:links
|
||||
* IMPORTANT: These are identical to the original rNotes schemas.
|
||||
* The docId prefix stays `notes:notebooks` for backward compatibility —
|
||||
* existing notebook data is accessible without migration.
|
||||
*/
|
||||
|
||||
import type { DocSchema } from '../../shared/local-first/document';
|
||||
|
||||
export interface LinkedDocument {
|
||||
// ── Document types ──
|
||||
|
||||
export interface SourceRef {
|
||||
source: 'logseq' | 'obsidian' | 'notion' | 'google-docs' | 'evernote' | 'roam' | 'manual';
|
||||
externalId: string;
|
||||
lastSyncedAt: number;
|
||||
contentHash?: string;
|
||||
syncStatus?: 'synced' | 'local-modified' | 'remote-modified' | 'conflict';
|
||||
}
|
||||
|
||||
export interface CommentMessage {
|
||||
id: string;
|
||||
url: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface CommentThread {
|
||||
id: string;
|
||||
anchor: string;
|
||||
resolved: boolean;
|
||||
messages: CommentMessage[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface NoteItem {
|
||||
id: string;
|
||||
notebookId: string;
|
||||
authorId: string | null;
|
||||
title: string;
|
||||
addedBy: string | null;
|
||||
addedAt: number;
|
||||
content: string;
|
||||
contentPlain: string;
|
||||
contentFormat?: 'html' | 'tiptap-json';
|
||||
type: 'NOTE' | 'CLIP' | 'BOOKMARK' | 'CODE' | 'IMAGE' | 'FILE' | 'AUDIO';
|
||||
url: string | null;
|
||||
language: string | null;
|
||||
fileUrl: string | null;
|
||||
mimeType: string | null;
|
||||
fileSize: number | null;
|
||||
duration: number | null;
|
||||
isPinned: boolean;
|
||||
sortOrder: number;
|
||||
tags: string[];
|
||||
summary?: string;
|
||||
summaryModel?: string;
|
||||
openNotebookSourceId?: string;
|
||||
sourceRef?: SourceRef;
|
||||
conflictContent?: string;
|
||||
collabEnabled?: boolean;
|
||||
comments?: Record<string, CommentThread>;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface DocsDoc {
|
||||
meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number };
|
||||
linkedDocuments: Record<string, LinkedDocument>;
|
||||
export interface NotebookMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
coverColor: string;
|
||||
isPublic: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export const docsSchema: DocSchema<DocsDoc> = {
|
||||
module: 'docs',
|
||||
collection: 'links',
|
||||
version: 1,
|
||||
init: (): DocsDoc => ({
|
||||
meta: { module: 'docs', collection: 'links', version: 1, spaceSlug: '', createdAt: Date.now() },
|
||||
linkedDocuments: {},
|
||||
export interface NotebookDoc {
|
||||
meta: {
|
||||
module: string;
|
||||
collection: string;
|
||||
version: number;
|
||||
spaceSlug: string;
|
||||
createdAt: number;
|
||||
};
|
||||
notebook: NotebookMeta;
|
||||
items: Record<string, NoteItem>;
|
||||
}
|
||||
|
||||
// ── Schema registration ──
|
||||
|
||||
export interface ConnectionsDoc {
|
||||
meta: {
|
||||
module: string;
|
||||
collection: string;
|
||||
version: number;
|
||||
spaceSlug: string;
|
||||
createdAt: number;
|
||||
};
|
||||
notion?: {
|
||||
accessToken: string;
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
connectedAt: number;
|
||||
};
|
||||
google?: {
|
||||
refreshToken: string;
|
||||
accessToken: string;
|
||||
expiresAt: number;
|
||||
email: string;
|
||||
connectedAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Generate a docId for a space's integration connections. */
|
||||
export function connectionsDocId(space: string) {
|
||||
return `${space}:notes:connections` as const;
|
||||
}
|
||||
|
||||
export const notebookSchema: DocSchema<NotebookDoc> = {
|
||||
module: 'notes',
|
||||
collection: 'notebooks',
|
||||
version: 5,
|
||||
init: (): NotebookDoc => ({
|
||||
meta: {
|
||||
module: 'notes',
|
||||
collection: 'notebooks',
|
||||
version: 5,
|
||||
spaceSlug: '',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
notebook: {
|
||||
id: '',
|
||||
title: 'Untitled Notebook',
|
||||
slug: '',
|
||||
description: '',
|
||||
coverColor: '#3b82f6',
|
||||
isPublic: false,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
items: {},
|
||||
}),
|
||||
migrate: (doc: any) => { if (!doc.linkedDocuments) doc.linkedDocuments = {}; doc.meta.version = 1; return doc; },
|
||||
migrate: (doc: NotebookDoc, fromVersion: number): NotebookDoc => {
|
||||
if (fromVersion < 2) {
|
||||
for (const item of Object.values(doc.items)) {
|
||||
if (!(item as any).contentFormat) (item as any).contentFormat = 'html';
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
},
|
||||
};
|
||||
|
||||
export function docsDocId(space: string) { return `${space}:docs:links` as const; }
|
||||
// ── Helpers ──
|
||||
|
||||
/** Generate a docId for a notebook. */
|
||||
export function notebookDocId(space: string, notebookId: string) {
|
||||
return `${space}:notes:notebooks:${notebookId}` as const;
|
||||
}
|
||||
|
||||
/** Create a fresh NoteItem with defaults. */
|
||||
export function createNoteItem(
|
||||
id: string,
|
||||
notebookId: string,
|
||||
title: string,
|
||||
opts: Partial<NoteItem> = {},
|
||||
): NoteItem {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id,
|
||||
notebookId,
|
||||
authorId: null,
|
||||
title,
|
||||
content: '',
|
||||
contentPlain: '',
|
||||
contentFormat: 'tiptap-json',
|
||||
type: 'NOTE',
|
||||
url: null,
|
||||
language: null,
|
||||
fileUrl: null,
|
||||
mimeType: null,
|
||||
fileSize: null,
|
||||
duration: null,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
tags: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Re-export from shared location.
|
||||
* The Yjs provider is now shared across modules (rNotes, rInbox, etc.).
|
||||
*/
|
||||
export { RSpaceYjsProvider } from '../../shared/yjs-ws-provider';
|
||||
|
|
@ -22,7 +22,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
private space = "";
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _history = new ViewHistory<"list" | "detail" | "create">("list");
|
||||
private _history = new ViewHistory<"list" | "detail" | "create">("list", "rforum");
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '.instance-card', title: "Forum Instances", message: "View your deployed Discourse forums — click one for status and logs.", advanceOnClick: false },
|
||||
|
|
@ -53,6 +53,7 @@ class FolkForumDashboard extends HTMLElement {
|
|||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rforum', context: this.selectedInstance?.name || 'Forum' }));
|
||||
window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
|
|
@ -66,6 +67,8 @@ class FolkForumDashboard extends HTMLElement {
|
|||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._history.destroy();
|
||||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
this._stopPresence?.();
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
this._offlineUnsub?.();
|
||||
|
|
@ -537,6 +540,14 @@ class FolkForumDashboard extends HTMLElement {
|
|||
if (form) form.addEventListener("submit", (e) => this.handleCreate(e));
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
if (e.detail?.moduleId !== 'rforum') return;
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
this.view = e.detail.view;
|
||||
if (e.detail.view === "list") this.loadInstances();
|
||||
else this.render();
|
||||
};
|
||||
|
||||
private goBack() {
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
const prev = this._history.back();
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class FolkInboxClient extends HTMLElement {
|
|||
private agentFormOpen = false;
|
||||
private _usernameCache = new Map<string, string>();
|
||||
private _currentUsername: string | null = null;
|
||||
private _history = new ViewHistory<"mailboxes" | "threads" | "thread" | "approvals" | "personal" | "agents">("mailboxes");
|
||||
private _history = new ViewHistory<"mailboxes" | "threads" | "thread" | "approvals" | "personal" | "agents">("mailboxes", "rinbox");
|
||||
private _fwdStatus: 'loading' | 'unavailable' | 'no-email' | 'ready' | 'enabled' | 'error' = 'loading';
|
||||
private _fwdAddress = '';
|
||||
private _fwdTarget = '';
|
||||
|
|
@ -144,6 +144,7 @@ class FolkInboxClient extends HTMLElement {
|
|||
if (this.space === "demo") { this.loadDemoData(); }
|
||||
else { this.subscribeOffline(); this.loadMailboxes(); }
|
||||
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rinbox', context: this.currentThread?.subject || 'Inbox' }));
|
||||
window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
@ -151,8 +152,18 @@ class FolkInboxClient extends HTMLElement {
|
|||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
this._offlineUnsubs = [];
|
||||
this._stopPresence?.();
|
||||
this._history.destroy();
|
||||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
if (e.detail?.moduleId !== 'rinbox') return;
|
||||
if (this.view === "thread") this.composeOpen = false;
|
||||
if (e.detail.view === "mailboxes") this.currentMailbox = null;
|
||||
this.view = e.detail.view;
|
||||
this.render();
|
||||
};
|
||||
|
||||
/** Extract username from EncryptID session */
|
||||
private _loadUsername() {
|
||||
this._currentUsername = getUsername();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import { requireAuth } from "../../../shared/auth-fetch";
|
|||
import { getUsername } from "../../../shared/components/rstack-identity";
|
||||
import { MapsLocalFirstClient } from "../local-first-client";
|
||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { mapsSchema, mapsDocId } from "../schemas";
|
||||
|
||||
// MapLibre loaded via CDN — use window access with type assertion
|
||||
|
||||
|
|
@ -144,8 +146,9 @@ class FolkMapViewer extends HTMLElement {
|
|||
private activeRoute: { segments: any[]; totalDistance: number; estimatedTime: number; destination: string } | null = null;
|
||||
private thumbnailTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _themeObserver: MutationObserver | null = null;
|
||||
private _history = new ViewHistory<"lobby" | "map">("lobby");
|
||||
private _history = new ViewHistory<"lobby" | "map">("lobby", "rmaps");
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
|
||||
// Chat + Local-first state
|
||||
private lfClient: MapsLocalFirstClient | null = null;
|
||||
|
|
@ -197,6 +200,7 @@ class FolkMapViewer extends HTMLElement {
|
|||
this.checkSyncHealth();
|
||||
this.render();
|
||||
}
|
||||
this.subscribeOffline();
|
||||
}
|
||||
if (!localStorage.getItem("rmaps_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
|
|
@ -214,10 +218,14 @@ class FolkMapViewer extends HTMLElement {
|
|||
this.setMapInteractive(false);
|
||||
}
|
||||
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rmaps', context: this.room || 'Maps' }));
|
||||
window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._history.destroy();
|
||||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
this._stopPresence?.();
|
||||
this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||
if (this._demoInterval) { clearInterval(this._demoInterval); this._demoInterval = null; }
|
||||
this.leaveRoom();
|
||||
if (this._themeObserver) {
|
||||
|
|
@ -237,6 +245,15 @@ class FolkMapViewer extends HTMLElement {
|
|||
private _onEditExit: (() => void) | null = null;
|
||||
private _mapInteractive = true;
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
try {
|
||||
const docId = mapsDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, mapsSchema);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private setMapInteractive(interactive: boolean) {
|
||||
this._mapInteractive = interactive;
|
||||
if (this.map) {
|
||||
|
|
@ -2825,6 +2842,13 @@ class FolkMapViewer extends HTMLElement {
|
|||
// No-op — legacy FAB removed; share state updated via updateShareButton()
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
if (e.detail?.moduleId !== 'rmaps') return;
|
||||
if (e.detail.view === "lobby" && this.view === "map") this.leaveRoom();
|
||||
this.view = e.detail.view;
|
||||
if (e.detail.view === "lobby") this.loadStats();
|
||||
};
|
||||
|
||||
private goBack() {
|
||||
const prev = this._history.back();
|
||||
if (!prev) return;
|
||||
|
|
|
|||
|
|
@ -131,13 +131,17 @@ class FolkJitsiRoom extends HTMLElement {
|
|||
height: "100%",
|
||||
configOverwrite: {
|
||||
prejoinConfig: { enabled: true },
|
||||
requireDisplayName: true,
|
||||
disableDeepLinking: true,
|
||||
hideConferenceSubject: false,
|
||||
disableVirtualBackground: false,
|
||||
disableProfile: false,
|
||||
toolbarButtons: [
|
||||
"camera", "microphone", "desktop", "hangup",
|
||||
"raisehand", "tileview", "toggle-camera",
|
||||
"fullscreen", "select-background",
|
||||
"sharedvideo", "sharedmusic",
|
||||
"fullscreen", "chat", "settings",
|
||||
"participants-pane", "select-background",
|
||||
"sharedvideo",
|
||||
],
|
||||
// Hide panels that add stray close (×) buttons
|
||||
disableChat: false,
|
||||
|
|
@ -149,6 +153,7 @@ class FolkJitsiRoom extends HTMLElement {
|
|||
SHOW_BRAND_WATERMARK: false,
|
||||
CLOSE_PAGE_GUEST_HINT: false,
|
||||
SHOW_PROMOTIONAL_CLOSE_PAGE: false,
|
||||
SETTINGS_SECTIONS: ['devices', 'language', 'moderator', 'profile', 'sounds', 'more'],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -544,6 +544,7 @@ routes.get("/:room", (c) => {
|
|||
|
||||
// Default: clean full-screen Jitsi — no rSpace shell, mobile-friendly
|
||||
const jitsiRoom = encodeURIComponent(room);
|
||||
const meetsBase = `/${escapeHtml(space)}/rmeets`;
|
||||
return c.html(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
|
@ -564,14 +565,49 @@ routes.get("/:room", (c) => {
|
|||
.ended a{color:#6366f1;text-decoration:none;padding:10px 24px;border:1px solid #6366f1;border-radius:8px;font-size:0.95rem;transition:background 0.15s}
|
||||
.ended a:hover,.ended a:active{background:#6366f122}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
/* MI overlay */
|
||||
.mi-fab{position:fixed;top:12px;right:12px;z-index:10000;display:flex;align-items:center;gap:6px}
|
||||
.mi-fab-btn{width:40px;height:40px;border-radius:50%;border:none;background:rgba(99,102,241,0.85);color:#fff;font-size:1.1rem;cursor:pointer;backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;transition:background 0.15s,transform 0.15s;box-shadow:0 2px 8px rgba(0,0,0,0.3)}
|
||||
.mi-fab-btn:hover{background:rgba(99,102,241,1);transform:scale(1.08)}
|
||||
.mi-fab-btn.active{background:rgba(99,102,241,1);transform:scale(1.08)}
|
||||
.mi-dropdown{display:none;position:absolute;top:48px;right:0;background:rgba(30,30,46,0.95);backdrop-filter:blur(12px);border:1px solid rgba(99,102,241,0.3);border-radius:12px;padding:8px 0;min-width:200px;box-shadow:0 8px 24px rgba(0,0,0,0.4)}
|
||||
.mi-dropdown.open{display:block}
|
||||
.mi-dropdown a,.mi-dropdown button{display:flex;align-items:center;gap:10px;width:100%;padding:10px 16px;border:none;background:none;color:#e2e8f0;font-size:0.9rem;text-decoration:none;cursor:pointer;text-align:left;font-family:inherit;transition:background 0.12s}
|
||||
.mi-dropdown a:hover,.mi-dropdown button:hover{background:rgba(99,102,241,0.15)}
|
||||
.mi-dropdown .mi-icon{font-size:1.1rem;width:22px;text-align:center;flex-shrink:0}
|
||||
.mi-dropdown .mi-sep{height:1px;background:rgba(99,102,241,0.15);margin:4px 0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="jitsi-container">
|
||||
<div class="loading"><div class="spinner"></div><span>Connecting to meeting...</span></div>
|
||||
</div>
|
||||
<div class="mi-fab">
|
||||
<button class="mi-fab-btn" id="mi-fab-toggle" title="Meeting Intelligence">🧠</button>
|
||||
<div class="mi-dropdown" id="mi-dropdown">
|
||||
<a href="${meetsBase}/recordings"><span class="mi-icon">🎥</span> Recordings</a>
|
||||
<a href="${meetsBase}/search"><span class="mi-icon">🔍</span> Search Transcripts</a>
|
||||
<div class="mi-sep"></div>
|
||||
<a href="${meetsBase}"><span class="mi-icon">🏠</span> rMeets Hub</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="${escapeHtml(JITSI_URL)}/libs/external_api.min.js"></script>
|
||||
<script>
|
||||
// MI dropdown toggle
|
||||
(function() {
|
||||
var fab = document.getElementById("mi-fab-toggle");
|
||||
var dd = document.getElementById("mi-dropdown");
|
||||
fab.addEventListener("click", function(e) {
|
||||
e.stopPropagation();
|
||||
var open = dd.classList.toggle("open");
|
||||
fab.classList.toggle("active", open);
|
||||
});
|
||||
document.addEventListener("click", function() {
|
||||
dd.classList.remove("open");
|
||||
fab.classList.remove("active");
|
||||
});
|
||||
})();
|
||||
|
||||
try {
|
||||
var api = new JitsiMeetExternalAPI("${escapeHtml(JITSI_URL.replace(/^https?:\/\//, ""))}", {
|
||||
roomName: decodeURIComponent("${jitsiRoom}"),
|
||||
|
|
@ -581,17 +617,19 @@ routes.get("/:room", (c) => {
|
|||
configOverwrite: {
|
||||
startWithAudioMuted: false,
|
||||
startWithVideoMuted: false,
|
||||
prejoinConfig: { enabled: false },
|
||||
prejoinPageEnabled: false,
|
||||
prejoinConfig: { enabled: true },
|
||||
requireDisplayName: true,
|
||||
disableDeepLinking: true,
|
||||
disableThirdPartyRequests: false,
|
||||
enableClosePage: false,
|
||||
disableVirtualBackground: false,
|
||||
disableProfile: false,
|
||||
toolbarButtons: [
|
||||
"microphone","camera","desktop","hangup",
|
||||
"raisehand","tileview","toggle-camera",
|
||||
"fullscreen","chat","settings",
|
||||
"participants-pane","select-background",
|
||||
"sharedvideo","sharedmusic",
|
||||
"sharedvideo",
|
||||
],
|
||||
},
|
||||
interfaceConfigOverwrite: {
|
||||
|
|
@ -601,6 +639,7 @@ routes.get("/:room", (c) => {
|
|||
MOBILE_APP_PROMO: false,
|
||||
HIDE_DEEP_LINKING_LOGO: true,
|
||||
DISABLE_JOIN_LEAVE_NOTIFICATIONS: false,
|
||||
SETTINGS_SECTIONS: ['devices', 'language', 'moderator', 'profile', 'sounds', 'more'],
|
||||
},
|
||||
});
|
||||
// Remove loading spinner — iframe is already injected by the constructor
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ const STAGE_LABELS: Record<string, string> = {
|
|||
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { networkSchema, networkDocId } from "../schemas";
|
||||
|
||||
class FolkCrmView extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
|
|
@ -154,6 +156,9 @@ class FolkCrmView extends HTMLElement {
|
|||
document.addEventListener("rapp-tab-change", this._onTabChange);
|
||||
this.render();
|
||||
this.loadData();
|
||||
if (this.space !== "demo") {
|
||||
this.subscribeCollabOverlay();
|
||||
}
|
||||
// Auto-start tour on first visit
|
||||
if (!localStorage.getItem("rnetwork_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
|
|
@ -166,6 +171,15 @@ class FolkCrmView extends HTMLElement {
|
|||
this._stopPresence?.();
|
||||
}
|
||||
|
||||
private async subscribeCollabOverlay() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
try {
|
||||
const docId = networkDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, networkSchema);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^(\/[^/]+)?\/rnetwork/);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||
import { NetworkLocalFirstClient } from '../local-first-client';
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { networkSchema, networkDocId } from "../schemas";
|
||||
|
||||
interface WeightAccounting {
|
||||
delegatedAway: Record<string, number>; // per authority: total weight delegated out
|
||||
|
|
@ -211,6 +213,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
this._lfClient.init().then(() => this._lfClient!.subscribe()).then(() => {
|
||||
this.restoreLayerConfig();
|
||||
}).catch(() => { /* offline is fine */ });
|
||||
this.subscribeCollabOverlay();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
@ -236,6 +239,15 @@ class FolkGraphViewer extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
private async subscribeCollabOverlay() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
try {
|
||||
const docId = networkDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, networkSchema);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private getApiBase(): string {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^(\/[^/]+)?\/rnetwork/);
|
||||
|
|
|
|||
|
|
@ -1,268 +1,9 @@
|
|||
/**
|
||||
* Logseq graph ↔ rNotes converter.
|
||||
*
|
||||
* Import: ZIP of pages/ + journals/ dirs, property:: value syntax, bullet outliner blocks
|
||||
* Export: ZIP with Logseq-compatible page files + properties
|
||||
* Logseq converter — re-exports from shared and registers with rNotes converter system.
|
||||
*/
|
||||
import { logseqConverter } from '../../../shared/converters/logseq';
|
||||
import { registerConverter } from './index';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
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';
|
||||
|
||||
/** Parse Logseq property:: value lines from the top of a page. */
|
||||
function parseLogseqProperties(content: string): { properties: Record<string, string>; body: string } {
|
||||
const lines = content.split('\n');
|
||||
const properties: Record<string, string> = {};
|
||||
let bodyStart = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^([a-zA-Z_-]+)::\s*(.*)$/);
|
||||
if (match) {
|
||||
properties[match[1].toLowerCase()] = match[2].trim();
|
||||
bodyStart = i + 1;
|
||||
} else if (lines[i].trim() === '') {
|
||||
bodyStart = i + 1;
|
||||
continue;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { properties, body: lines.slice(bodyStart).join('\n') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Logseq outliner bullet format to regular markdown.
|
||||
* Logseq uses `- content` for all blocks with indentation for nesting.
|
||||
*/
|
||||
function convertOutlinerToMarkdown(content: string): string {
|
||||
const lines = content.split('\n');
|
||||
const result: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Detect indented bullets: tabs or spaces followed by -
|
||||
const match = line.match(/^(\s*)- (.*)$/);
|
||||
if (match) {
|
||||
const indent = match[1];
|
||||
const text = match[2];
|
||||
|
||||
// Calculate nesting level: count tabs, or pairs of spaces
|
||||
let level = 0;
|
||||
if (indent.length > 0) {
|
||||
// Count actual tab characters first
|
||||
const tabCount = (indent.match(/\t/g) || []).length;
|
||||
const spaceCount = indent.replace(/\t/g, '').length;
|
||||
level = tabCount + Math.floor(spaceCount / 2);
|
||||
}
|
||||
|
||||
// Check if this looks like a heading (common Logseq pattern)
|
||||
if (level === 0 && text.startsWith('# ')) {
|
||||
result.push(text);
|
||||
} else if (level === 0) {
|
||||
// Top-level bullet → list item
|
||||
result.push(`- ${text}`);
|
||||
} else {
|
||||
// Nested bullet → indented list item
|
||||
const indentation = ' '.repeat(level);
|
||||
result.push(`${indentation}- ${text}`);
|
||||
}
|
||||
} else {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
/** Convert [[page references]] to standard links. */
|
||||
function convertPageRefs(md: string): string {
|
||||
return md.replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)');
|
||||
}
|
||||
|
||||
/** Convert Logseq tags (#tag or #[[multi word tag]]). */
|
||||
function extractLogseqTags(content: string): string[] {
|
||||
const tags: string[] = [];
|
||||
// #tag
|
||||
const singleTags = content.match(/#([a-zA-Z0-9_-]+)/g);
|
||||
if (singleTags) tags.push(...singleTags.map(t => t.slice(1).toLowerCase()));
|
||||
// #[[multi word tag]]
|
||||
const multiTags = content.match(/#\[\[([^\]]+)\]\]/g);
|
||||
if (multiTags) tags.push(...multiTags.map(t => t.slice(3, -2).toLowerCase().replace(/\s+/g, '-')));
|
||||
return [...new Set(tags)];
|
||||
}
|
||||
|
||||
/** Parse Logseq journal filename to date. */
|
||||
function parseJournalDate(filename: string): string | null {
|
||||
// Common Logseq journal formats: 2026_03_01.md, 2026-03-01.md
|
||||
const match = filename.match(/(\d{4})[_-](\d{2})[_-](\d{2})\.md$/);
|
||||
if (match) return `${match[1]}-${match[2]}-${match[3]}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract title from filename. */
|
||||
function titleFromPath(filePath: string): string {
|
||||
const filename = filePath.split('/').pop() || 'Untitled';
|
||||
return filename.replace(/\.md$/i, '').replace(/%2F/g, '/').replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
const logseqConverter: NoteConverter = {
|
||||
id: 'logseq',
|
||||
name: 'Logseq',
|
||||
requiresAuth: false,
|
||||
|
||||
async import(input: ImportInput): Promise<ImportResult> {
|
||||
if (!input.fileData) {
|
||||
throw new Error('Logseq import requires a ZIP file');
|
||||
}
|
||||
|
||||
const zip = await JSZip.loadAsync(input.fileData);
|
||||
const notes: ConvertedNote[] = [];
|
||||
const warnings: string[] = [];
|
||||
let graphName = 'Logseq Import';
|
||||
|
||||
// Collect all .md files
|
||||
const mdFiles: { path: string; file: JSZip.JSZipObject; isJournal: boolean }[] = [];
|
||||
zip.forEach((path, file) => {
|
||||
if (file.dir) return;
|
||||
if (!path.endsWith('.md')) return;
|
||||
// Skip config/hidden files
|
||||
if (path.includes('logseq/') && !path.includes('pages/') && !path.includes('journals/')) return;
|
||||
if (path.includes('.recycle/')) return;
|
||||
|
||||
const isJournal = path.includes('journals/');
|
||||
mdFiles.push({ path, file, isJournal });
|
||||
});
|
||||
|
||||
if (mdFiles.length === 0) {
|
||||
warnings.push('No .md files found in pages/ or journals/ directories');
|
||||
return { notes, notebookTitle: graphName, warnings };
|
||||
}
|
||||
|
||||
// Detect graph name from common root
|
||||
const firstPath = mdFiles[0].path;
|
||||
const rootFolder = firstPath.split('/')[0];
|
||||
if (rootFolder && mdFiles.every(f => f.path.startsWith(rootFolder + '/'))) {
|
||||
graphName = rootFolder;
|
||||
for (const f of mdFiles) {
|
||||
f.path = f.path.slice(rootFolder.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { path, file, isJournal } of mdFiles) {
|
||||
try {
|
||||
const raw = await file.async('string');
|
||||
const { properties, body } = parseLogseqProperties(raw);
|
||||
|
||||
// Convert Logseq format to standard markdown
|
||||
let md = convertOutlinerToMarkdown(body);
|
||||
md = convertPageRefs(md);
|
||||
|
||||
const filename = path.split('/').pop() || '';
|
||||
let title: string;
|
||||
|
||||
if (isJournal) {
|
||||
const date = parseJournalDate(filename);
|
||||
title = date ? `Journal: ${date}` : titleFromPath(path);
|
||||
} else {
|
||||
title = properties.title || titleFromPath(path);
|
||||
}
|
||||
|
||||
const tiptapJson = markdownToTiptap(md);
|
||||
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
|
||||
|
||||
// Collect tags
|
||||
const tags: string[] = [];
|
||||
if (properties.tags) {
|
||||
const tagStr = properties.tags.replace(/\[\[|\]\]/g, '');
|
||||
tags.push(...tagStr.split(',').map(t => t.trim().toLowerCase()).filter(Boolean));
|
||||
}
|
||||
tags.push(...extractLogseqTags(raw));
|
||||
if (isJournal) tags.push('journal');
|
||||
|
||||
notes.push({
|
||||
title,
|
||||
content: tiptapJson,
|
||||
contentPlain,
|
||||
markdown: md,
|
||||
tags: [...new Set(tags)],
|
||||
sourceRef: {
|
||||
source: 'logseq',
|
||||
externalId: path,
|
||||
lastSyncedAt: Date.now(),
|
||||
contentHash: hashContent(raw),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to parse ${path}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { notes, notebookTitle: graphName, warnings };
|
||||
},
|
||||
|
||||
async export(notes: NoteItem[], opts: ExportOptions): Promise<ExportResult> {
|
||||
const zip = new JSZip();
|
||||
const graphName = opts.notebookTitle || 'rNotes Export';
|
||||
const pagesDir = zip.folder('pages')!;
|
||||
|
||||
for (const note of notes) {
|
||||
// Convert content to markdown
|
||||
let md: string;
|
||||
if (note.contentFormat === 'tiptap-json' && note.content) {
|
||||
md = tiptapToMarkdown(note.content);
|
||||
} else if (note.content) {
|
||||
md = note.content.replace(/<[^>]*>/g, '').trim();
|
||||
} else {
|
||||
md = '';
|
||||
}
|
||||
|
||||
// Build Logseq properties block
|
||||
const props: string[] = [];
|
||||
if (note.tags.length > 0) {
|
||||
props.push(`tags:: ${note.tags.map(t => `[[${t}]]`).join(', ')}`);
|
||||
}
|
||||
if (note.type !== 'NOTE') {
|
||||
props.push(`type:: ${note.type.toLowerCase()}`);
|
||||
}
|
||||
props.push(`created:: ${new Date(note.createdAt).toISOString().split('T')[0]}`);
|
||||
|
||||
// Convert markdown paragraphs to Logseq outliner bullets
|
||||
const mdLines = md.split('\n');
|
||||
const outliner: string[] = [];
|
||||
for (const line of mdLines) {
|
||||
if (line.trim() === '') continue;
|
||||
if (line.startsWith('#')) {
|
||||
outliner.push(`- ${line}`);
|
||||
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
outliner.push(`- ${line.slice(2)}`);
|
||||
} else if (line.match(/^\d+\.\s/)) {
|
||||
outliner.push(`- ${line.replace(/^\d+\.\s/, '')}`);
|
||||
} else {
|
||||
outliner.push(`- ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
const propsBlock = props.length > 0 ? props.join('\n') + '\n\n' : '';
|
||||
const fileContent = `${propsBlock}${outliner.join('\n')}\n`;
|
||||
|
||||
// Sanitize filename for Logseq (uses %2F for namespaced pages)
|
||||
const filename = note.title
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
.replace(/\//g, '%2F')
|
||||
.trim() || 'Untitled';
|
||||
|
||||
pagesDir.file(`${filename}.md`, fileContent);
|
||||
}
|
||||
|
||||
const data = await zip.generateAsync({ type: 'uint8array' });
|
||||
return {
|
||||
data,
|
||||
filename: `${graphName.replace(/\s+/g, '-').toLowerCase()}-logseq.zip`,
|
||||
mimeType: 'application/zip',
|
||||
};
|
||||
},
|
||||
};
|
||||
export { logseqConverter };
|
||||
|
||||
registerConverter(logseqConverter);
|
||||
|
|
|
|||
|
|
@ -1,235 +1,9 @@
|
|||
/**
|
||||
* Obsidian vault ↔ rNotes converter.
|
||||
*
|
||||
* Import: ZIP of .md files with YAML frontmatter, [[wikilinks]], callouts, nested folders → tags
|
||||
* Export: ZIP of .md files with YAML frontmatter, organized by notebook
|
||||
* Obsidian converter — re-exports from shared and registers with rNotes converter system.
|
||||
*/
|
||||
import { obsidianConverter } from '../../../shared/converters/obsidian';
|
||||
import { registerConverter } from './index';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
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';
|
||||
|
||||
/** Parse YAML frontmatter from an Obsidian markdown file. */
|
||||
function parseFrontmatter(content: string): { frontmatter: Record<string, any>; body: string } {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!match) return { frontmatter: {}, body: content };
|
||||
|
||||
try {
|
||||
const frontmatter = parseYaml(match[1]) || {};
|
||||
return { frontmatter, body: match[2] };
|
||||
} catch {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert Obsidian [[wikilinks]] to standard markdown links. */
|
||||
function convertWikilinks(md: string): string {
|
||||
// [[Page Name|Display Text]] → [Display Text](Page Name)
|
||||
// [[Page Name]] → [Page Name](Page Name)
|
||||
return md.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '[$2]($1)')
|
||||
.replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)');
|
||||
}
|
||||
|
||||
/** Convert Obsidian callouts to blockquotes. */
|
||||
function convertCallouts(md: string): string {
|
||||
// > [!type] Title → > **Type:** Title
|
||||
return md.replace(/^> \[!(\w+)\]\s*(.*)/gm, (_, type, title) => {
|
||||
const label = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
return title ? `> **${label}:** ${title}` : `> **${label}**`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Extract folder path as tag prefix from file path. */
|
||||
function pathToTags(filePath: string): string[] {
|
||||
const parts = filePath.split('/').slice(0, -1); // Remove filename
|
||||
// Filter out common vault root folders
|
||||
const filtered = parts.filter(p => !['attachments', 'assets', 'templates', '.obsidian'].includes(p.toLowerCase()));
|
||||
if (filtered.length === 0) return [];
|
||||
return filtered.map(p => p.toLowerCase().replace(/\s+/g, '-'));
|
||||
}
|
||||
|
||||
/** Extract title from filename (without .md extension). */
|
||||
function titleFromPath(filePath: string): string {
|
||||
const filename = filePath.split('/').pop() || 'Untitled';
|
||||
return filename.replace(/\.md$/i, '');
|
||||
}
|
||||
|
||||
const obsidianConverter: NoteConverter = {
|
||||
id: 'obsidian',
|
||||
name: 'Obsidian',
|
||||
requiresAuth: false,
|
||||
|
||||
async import(input: ImportInput): Promise<ImportResult> {
|
||||
if (!input.fileData) {
|
||||
throw new Error('Obsidian import requires a ZIP file');
|
||||
}
|
||||
|
||||
const zip = await JSZip.loadAsync(input.fileData);
|
||||
const notes: ConvertedNote[] = [];
|
||||
const warnings: string[] = [];
|
||||
let vaultName = 'Obsidian Import';
|
||||
|
||||
// Build image map from non-.md files in the ZIP for embedded image resolution
|
||||
const imageMap = new Map<string, { file: JSZip.JSZipObject; mimeType: string }>();
|
||||
const imageExts: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp' };
|
||||
zip.forEach((path, file) => {
|
||||
if (file.dir) return;
|
||||
const ext = path.substring(path.lastIndexOf('.')).toLowerCase();
|
||||
if (imageExts[ext]) {
|
||||
// Index by basename for ![[filename.png]] lookup
|
||||
const basename = path.split('/').pop()!;
|
||||
imageMap.set(basename, { file, mimeType: imageExts[ext] });
|
||||
// Also index by full path
|
||||
imageMap.set(path, { file, mimeType: imageExts[ext] });
|
||||
}
|
||||
});
|
||||
|
||||
// Find markdown files in the ZIP
|
||||
const mdFiles: { path: string; file: JSZip.JSZipObject }[] = [];
|
||||
zip.forEach((path, file) => {
|
||||
if (file.dir) return;
|
||||
if (!path.endsWith('.md')) return;
|
||||
// Skip hidden/config files
|
||||
if (path.includes('.obsidian/') || path.includes('.trash/')) return;
|
||||
mdFiles.push({ path, file });
|
||||
});
|
||||
|
||||
if (mdFiles.length === 0) {
|
||||
warnings.push('No .md files found in the ZIP archive');
|
||||
return { notes, notebookTitle: vaultName, warnings };
|
||||
}
|
||||
|
||||
// Try to detect vault name from common root folder
|
||||
const firstPath = mdFiles[0].path;
|
||||
const rootFolder = firstPath.split('/')[0];
|
||||
if (rootFolder && mdFiles.every(f => f.path.startsWith(rootFolder + '/'))) {
|
||||
vaultName = rootFolder;
|
||||
// Strip root folder prefix from all paths
|
||||
for (const f of mdFiles) {
|
||||
f.path = f.path.slice(rootFolder.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { path, file } of mdFiles) {
|
||||
try {
|
||||
const raw = await file.async('string');
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
|
||||
// Process markdown
|
||||
let md = convertWikilinks(body);
|
||||
md = convertCallouts(md);
|
||||
|
||||
// Resolve embedded images: ![[image.png]] was converted to 
|
||||
// Also handle original ![[image.png]] syntax in case wikilinks missed it
|
||||
const attachments: { filename: string; data: Uint8Array; mimeType: string }[] = [];
|
||||
const imageRefPattern = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const resolvedImages = new Set<string>();
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = imageRefPattern.exec(md)) !== null) {
|
||||
const ref = match[2];
|
||||
const basename = ref.split('/').pop()!;
|
||||
const imgEntry = imageMap.get(basename) || imageMap.get(ref);
|
||||
if (imgEntry && !resolvedImages.has(basename)) {
|
||||
resolvedImages.add(basename);
|
||||
const data = await imgEntry.file.async('uint8array');
|
||||
attachments.push({ filename: basename, data, mimeType: imgEntry.mimeType });
|
||||
}
|
||||
}
|
||||
|
||||
// Replace image URLs to point to uploaded files
|
||||
if (attachments.length > 0) {
|
||||
md = md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (full, alt, ref) => {
|
||||
const basename = ref.split('/').pop()!;
|
||||
if (imageMap.has(basename) || imageMap.has(ref)) {
|
||||
return ``;
|
||||
}
|
||||
return full;
|
||||
});
|
||||
}
|
||||
|
||||
const title = frontmatter.title || titleFromPath(path);
|
||||
const tiptapJson = markdownToTiptap(md);
|
||||
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
|
||||
|
||||
// Collect tags from frontmatter + folder path
|
||||
const tags: string[] = [];
|
||||
if (frontmatter.tags) {
|
||||
const fmTags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [frontmatter.tags];
|
||||
tags.push(...fmTags.map((t: string) => String(t).toLowerCase().replace(/^#/, '')));
|
||||
}
|
||||
tags.push(...pathToTags(path));
|
||||
|
||||
notes.push({
|
||||
title,
|
||||
content: tiptapJson,
|
||||
contentPlain,
|
||||
markdown: md,
|
||||
tags: [...new Set(tags)],
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
sourceRef: {
|
||||
source: 'obsidian',
|
||||
externalId: path,
|
||||
lastSyncedAt: Date.now(),
|
||||
contentHash: hashContent(raw),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to parse ${path}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { notes, notebookTitle: vaultName, warnings };
|
||||
},
|
||||
|
||||
async export(notes: NoteItem[], opts: ExportOptions): Promise<ExportResult> {
|
||||
const zip = new JSZip();
|
||||
const notebookTitle = opts.notebookTitle || 'rNotes Export';
|
||||
|
||||
for (const note of notes) {
|
||||
// Convert content to markdown
|
||||
let md: string;
|
||||
if (note.contentFormat === 'tiptap-json' && note.content) {
|
||||
md = tiptapToMarkdown(note.content);
|
||||
} else if (note.content) {
|
||||
// Legacy HTML — strip tags for basic markdown
|
||||
md = note.content.replace(/<[^>]*>/g, '').trim();
|
||||
} else {
|
||||
md = '';
|
||||
}
|
||||
|
||||
// Build YAML frontmatter
|
||||
const frontmatter: Record<string, any> = {};
|
||||
if (note.tags.length > 0) frontmatter.tags = note.tags;
|
||||
frontmatter.created = new Date(note.createdAt).toISOString();
|
||||
frontmatter.updated = new Date(note.updatedAt).toISOString();
|
||||
if (note.type !== 'NOTE') frontmatter.type = note.type.toLowerCase();
|
||||
if (note.sourceRef) {
|
||||
frontmatter['rnotes-id'] = note.id;
|
||||
}
|
||||
|
||||
const yamlStr = stringifyYaml(frontmatter).trim();
|
||||
const fileContent = `---\n${yamlStr}\n---\n\n${md}\n`;
|
||||
|
||||
// Sanitize filename
|
||||
const filename = note.title
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || 'Untitled';
|
||||
|
||||
zip.file(`${notebookTitle}/${filename}.md`, fileContent);
|
||||
}
|
||||
|
||||
const data = await zip.generateAsync({ type: 'uint8array' });
|
||||
return {
|
||||
data,
|
||||
filename: `${notebookTitle.replace(/\s+/g, '-').toLowerCase()}-obsidian.zip`,
|
||||
mimeType: 'application/zip',
|
||||
};
|
||||
},
|
||||
};
|
||||
export { obsidianConverter };
|
||||
|
||||
registerConverter(obsidianConverter);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { makeDraggableAll } from "../../../shared/draggable";
|
|||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { ViewHistory } from "../../../shared/view-history.js";
|
||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { photosSchema, photosDocId } from "../schemas";
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
|
|
@ -51,7 +53,8 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
private showingSampleData = false;
|
||||
private _tour!: TourEngine;
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _history = new ViewHistory<"gallery" | "album" | "lightbox">("gallery");
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _history = new ViewHistory<"gallery" | "album" | "lightbox">("gallery", "rphotos");
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '.album-card', title: "Albums", message: "Browse shared photo albums — click one to see its photos.", advanceOnClick: false },
|
||||
{ target: '.photo-cell', title: "Photo Grid", message: "Click any photo to open it in the lightbox viewer.", advanceOnClick: false },
|
||||
|
|
@ -75,15 +78,37 @@ class FolkPhotoGallery extends HTMLElement {
|
|||
this.loadDemoData();
|
||||
} else {
|
||||
this.loadGallery();
|
||||
this.subscribeOffline();
|
||||
}
|
||||
if (!localStorage.getItem("rphotos_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rphotos', context: this.selectedAlbum?.albumName || 'Photos' }));
|
||||
window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._stopPresence?.();
|
||||
this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||
this._history.destroy();
|
||||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
if (e.detail?.moduleId !== 'rphotos') return;
|
||||
this.view = e.detail.view;
|
||||
if (e.detail.view === "gallery") { this.selectedAlbum = null; this.albumAssets = []; }
|
||||
this.lightboxAsset = null;
|
||||
this.render();
|
||||
};
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
try {
|
||||
const docId = photosDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, photosSchema);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ class FolkScheduleApp extends HTMLElement {
|
|||
private log: LogEntry[] = [];
|
||||
private reminders: ReminderData[] = [];
|
||||
private view: "jobs" | "log" | "form" | "reminders" | "reminder-form" = "jobs";
|
||||
private _history = new ViewHistory<"jobs" | "log" | "form" | "reminders" | "reminder-form">("jobs");
|
||||
private _history = new ViewHistory<"jobs" | "log" | "form" | "reminders" | "reminder-form">("jobs", "rschedule");
|
||||
private editingJob: JobData | null = null;
|
||||
private editingReminder: ReminderData | null = null;
|
||||
private loading = false;
|
||||
|
|
@ -137,9 +137,12 @@ class FolkScheduleApp extends HTMLElement {
|
|||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rschedule', context: 'Schedule' }));
|
||||
window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._history.destroy();
|
||||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
if (this._offlineUnsub) {
|
||||
this._offlineUnsub();
|
||||
this._offlineUnsub = null;
|
||||
|
|
@ -454,6 +457,14 @@ class FolkScheduleApp extends HTMLElement {
|
|||
this.render();
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
if (e.detail?.moduleId !== 'rschedule') return;
|
||||
this.view = e.detail.view;
|
||||
if (e.detail.view === "reminders") this.loadReminders();
|
||||
else if (e.detail.view === "log") this.loadLog();
|
||||
else this.render();
|
||||
};
|
||||
|
||||
private goBack() {
|
||||
const prev = this._history.back();
|
||||
if (!prev) return;
|
||||
|
|
|
|||
|
|
@ -153,7 +153,9 @@ function posterMockupSvg(): string {
|
|||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { SwagLocalFirstClient } from "../local-first-client";
|
||||
import type { SwagDoc, SwagDesign } from "../schemas";
|
||||
import { swagSchema, swagDocId } from "../schemas";
|
||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
// Auth helpers
|
||||
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
|
||||
|
|
@ -308,6 +310,16 @@ class FolkSwagDesigner extends HTMLElement {
|
|||
} catch (err) {
|
||||
console.warn('[rSwag] Local-first init failed:', err);
|
||||
}
|
||||
this.subscribeCollabOverlay();
|
||||
}
|
||||
|
||||
private async subscribeCollabOverlay() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
try {
|
||||
const docId = swagDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, swagSchema);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private extractDesigns(doc: SwagDoc) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
private error = "";
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _history = new ViewHistory<"list" | "detail" | "ai-planner">("list");
|
||||
private _history = new ViewHistory<"list" | "detail" | "ai-planner">("list", "rtrips");
|
||||
private _aiMessages: { role: string; content: string; toolCalls?: any[] }[] = [];
|
||||
private _aiGeneratedItems: { type: string; props: Record<string, any>; accepted: boolean; id: string }[] = [];
|
||||
private _aiLoading = false;
|
||||
|
|
@ -64,9 +64,12 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rtrips', context: this.trip?.name || 'Trips' }));
|
||||
window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._history.destroy();
|
||||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
this._offlineUnsubs = [];
|
||||
this._stopPresence?.();
|
||||
|
|
@ -1365,6 +1368,15 @@ GUIDELINES:
|
|||
window.location.href = rspaceNavUrl(this.space, 'rspace') + '#trip-import';
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
if (e.detail?.moduleId !== 'rtrips') return;
|
||||
this.destroyMap();
|
||||
this.view = e.detail.view;
|
||||
if (e.detail.view === "list") {
|
||||
if (this.space === "demo") { this.loadDemoData(); } else { this.loadTrips(); }
|
||||
} else { this.render(); }
|
||||
};
|
||||
|
||||
private goBack() {
|
||||
this.destroyMap();
|
||||
const prev = this._history.back();
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { authFetch, requireAuth } from "../../../shared/auth-fetch";
|
||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { tubeSchema, tubeDocId } from "../schemas";
|
||||
|
||||
class FolkVideoPlayer extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
|
|
@ -34,6 +36,7 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
private liveSplitStatusInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private expandedView: number | null = null;
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '[data-mode="library"]', title: "Video Library", message: "Browse your recorded videos — search, select, and play.", advanceOnClick: false },
|
||||
|
|
@ -55,7 +58,7 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
if (this.space === "demo") { this.loadDemoData(); }
|
||||
else { this.loadVideos(); }
|
||||
else { this.loadVideos(); this.subscribeOffline(); }
|
||||
if (!localStorage.getItem("rtube_tour_done")) {
|
||||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
|
|
@ -64,6 +67,16 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
|
||||
disconnectedCallback() {
|
||||
this._stopPresence?.();
|
||||
this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
try {
|
||||
const docId = tubeDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, tubeSchema);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import './folk-vehicle-card';
|
|||
import './folk-rental-request';
|
||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||
import { getModuleApiBase } from "../../../shared/url-helpers";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { vnbSchema, vnbDocId } from "../schemas";
|
||||
|
||||
const VNB_TOUR_STEPS: TourStep[] = [
|
||||
{ target: '.vnb-search', title: 'Search', message: 'Filter by vehicle type, dates, or economy model.' },
|
||||
|
|
@ -73,16 +75,28 @@ class FolkVnbView extends HTMLElement {
|
|||
#mapContainer: HTMLElement | null = null;
|
||||
#tour: LightTourEngine | null = null;
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
this.#space = this.getAttribute('space') || 'demo';
|
||||
this.#render();
|
||||
this.#loadData();
|
||||
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rvnb', context: 'Venues' }));
|
||||
if (this.#space !== 'demo') this.subscribeOffline();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._stopPresence?.();
|
||||
this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||
}
|
||||
|
||||
private async subscribeOffline() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
try {
|
||||
const docId = vnbDocId(this.#space) as DocumentId;
|
||||
await runtime.subscribe(docId, vnbSchema);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||
|
|
@ -287,7 +301,7 @@ class FolkVnbView extends HTMLElement {
|
|||
].filter(Boolean).join(' ');
|
||||
|
||||
return `
|
||||
<div class="vnb-card" data-vehicle-id="${v.id}">
|
||||
<div class="vnb-card" data-vehicle-id="${v.id}" data-collab-id="vehicle:${v.id}">
|
||||
<div class="vnb-card__cover">${typeIcon}</div>
|
||||
<div class="vnb-card__body">
|
||||
<div class="vnb-card__title">${this.#esc(v.title)}</div>
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
private showTrendChart = true;
|
||||
private scoreHistory: ScoreSnapshot[] = [];
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
private _history = new ViewHistory<"spaces" | "proposals" | "proposal" | "rank">("spaces");
|
||||
private _history = new ViewHistory<"spaces" | "proposals" | "proposal" | "rank">("spaces", "rvote");
|
||||
|
||||
// Pairwise ranking state
|
||||
private rankPairA: Proposal | null = null;
|
||||
|
|
@ -99,9 +99,12 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
setTimeout(() => this._tour.start(), 1200);
|
||||
}
|
||||
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rvote', context: this.selectedProposal?.title || 'Voting' }));
|
||||
window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._history.destroy();
|
||||
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
|
||||
for (const unsub of this._offlineUnsubs) unsub();
|
||||
this._offlineUnsubs = [];
|
||||
this._stopPresence?.();
|
||||
|
|
@ -1397,6 +1400,12 @@ class FolkVoteDashboard extends HTMLElement {
|
|||
});
|
||||
}
|
||||
|
||||
private _onViewRestored = (e: CustomEvent) => {
|
||||
if (e.detail?.moduleId !== 'rvote') return;
|
||||
this.view = e.detail.view;
|
||||
this.render();
|
||||
};
|
||||
|
||||
private goBack() {
|
||||
const prev = this._history.back();
|
||||
if (!prev) return;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ import type { ProtocolComparison, SandboxAsset } from "../lib/yield-sandbox";
|
|||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { WalletLocalFirstClient } from "../local-first-client";
|
||||
import type { WalletDoc, WatchedAddress } from "../schemas";
|
||||
import { walletSchema, walletDocId } from "../schemas";
|
||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
interface ChainInfo {
|
||||
chainId: string;
|
||||
|
|
@ -135,6 +137,7 @@ interface AllChainBalanceEntry {
|
|||
|
||||
class FolkWalletViewer extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
private address = "";
|
||||
private detectedChains: ChainInfo[] = [];
|
||||
private selectedChain: string | null = null;
|
||||
|
|
@ -241,6 +244,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
const space = this.getAttribute("space") || "";
|
||||
this.space = space;
|
||||
if (space === "demo") {
|
||||
this.loadDemoData();
|
||||
} else {
|
||||
|
|
@ -312,6 +316,16 @@ class FolkWalletViewer extends HTMLElement {
|
|||
} catch (err) {
|
||||
console.warn('[rWallet] Local-first init failed:', err);
|
||||
}
|
||||
this.subscribeCollabOverlay();
|
||||
}
|
||||
|
||||
private async subscribeCollabOverlay() {
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime?.isInitialized) return;
|
||||
try {
|
||||
const docId = walletDocId(this.space) as DocumentId;
|
||||
await runtime.subscribe(docId, walletSchema);
|
||||
} catch { /* runtime unavailable */ }
|
||||
}
|
||||
|
||||
private extractWatchlist(doc: WalletDoc) {
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ import { photosModule } from "../modules/rphotos/mod";
|
|||
import { socialsModule } from "../modules/rsocials/mod";
|
||||
import { meetsModule } from "../modules/rmeets/mod";
|
||||
import { chatsModule } from "../modules/rchats/mod";
|
||||
// import { docsModule } from "../modules/rdocs/mod";
|
||||
import { docsModule } from "../modules/rdocs/mod";
|
||||
import { designModule } from "../modules/rdesign/mod";
|
||||
import { scheduleModule } from "../modules/rschedule/mod";
|
||||
import { bnbModule } from "../modules/rbnb/mod";
|
||||
|
|
@ -155,7 +155,7 @@ registerModule(tubeModule);
|
|||
registerModule(tripsModule);
|
||||
registerModule(booksModule);
|
||||
registerModule(sheetsModule);
|
||||
// registerModule(docsModule); // placeholder — not yet an rApp
|
||||
registerModule(docsModule); // Full TipTap editor (split from rNotes)
|
||||
|
||||
// ── Config ──
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
* Stateless mode: fresh McpServer + transport per request.
|
||||
* Direct Automerge syncServer access for reads (no HTTP round-trip).
|
||||
*
|
||||
* 101 tools across 35 groups:
|
||||
* 105 tools across 35 groups:
|
||||
* spaces (2), rcal (4), rnotes (5), rtasks (5), rwallet (4),
|
||||
* rsocials (4), rnetwork (3), rinbox (4), rtime (4), rfiles (3), rschedule (4),
|
||||
* rvote (3), rchoices (3), rtrips (4), rcart (4), rexchange (4), rbnb (4),
|
||||
* rvnb (3), crowdsurf (2), rbooks (2), rpubs (2), rmeets (2), rtube (2),
|
||||
* rswag (2), rdesign (2), rsplat (2), rphotos (2), rflows (2), rdocs (1),
|
||||
* rswag (2), rdesign (2), rsplat (2), rphotos (2), rflows (2), rdocs (5),
|
||||
* rdata (1), rforum (2), rchats (3), rmaps (3), rsheets (2), rgov (2)
|
||||
* 1 resource: rspace://spaces/{slug}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,39 +1,232 @@
|
|||
/**
|
||||
* MCP tools for rDocs (linked documents).
|
||||
* MCP tools for rDocs (notebooks & docs — full TipTap editor).
|
||||
*
|
||||
* Tools: rdocs_list_documents
|
||||
* Tools: rdocs_list_notebooks, rdocs_list_docs, rdocs_get_doc,
|
||||
* rdocs_create_doc, rdocs_update_doc
|
||||
*/
|
||||
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { SyncServer } from "../local-first/sync-server";
|
||||
import { docsDocId } from "../../modules/rdocs/schemas";
|
||||
import type { DocsDoc } from "../../modules/rdocs/schemas";
|
||||
import { notebookDocId, createNoteItem } from "../../modules/rdocs/schemas";
|
||||
import type { NotebookDoc, NoteItem } from "../../modules/rdocs/schemas";
|
||||
import { resolveAccess, accessDeniedResponse } from "./_auth";
|
||||
|
||||
const NOTEBOOK_PREFIX = ":notes:notebooks:";
|
||||
|
||||
/** Find all notebook docIds for a space. */
|
||||
function findNotebookDocIds(syncServer: SyncServer, space: string): string[] {
|
||||
const prefix = `${space}${NOTEBOOK_PREFIX}`;
|
||||
return syncServer.getDocIds().filter(id => id.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
|
||||
server.tool(
|
||||
"rdocs_list_documents",
|
||||
"List linked documents in a space",
|
||||
"rdocs_list_notebooks",
|
||||
"List all notebooks in a space (rDocs)",
|
||||
{
|
||||
space: z.string().describe("Space slug"),
|
||||
token: z.string().optional().describe("JWT auth token"),
|
||||
search: z.string().optional().describe("Search in title"),
|
||||
token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"),
|
||||
},
|
||||
async ({ space, token, search }) => {
|
||||
async ({ space, token }) => {
|
||||
const access = await resolveAccess(token, space, false);
|
||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||
|
||||
const doc = syncServer.getDoc<DocsDoc>(docsDocId(space));
|
||||
if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "No docs data found" }) }] };
|
||||
const docIds = findNotebookDocIds(syncServer, space);
|
||||
const notebooks = [];
|
||||
for (const docId of docIds) {
|
||||
const doc = syncServer.getDoc<NotebookDoc>(docId);
|
||||
if (!doc?.notebook) continue;
|
||||
notebooks.push({
|
||||
id: doc.notebook.id,
|
||||
title: doc.notebook.title,
|
||||
slug: doc.notebook.slug,
|
||||
description: doc.notebook.description,
|
||||
noteCount: Object.keys(doc.items || {}).length,
|
||||
createdAt: doc.notebook.createdAt,
|
||||
updatedAt: doc.notebook.updatedAt,
|
||||
});
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(notebooks, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
let documents = Object.values(doc.linkedDocuments || {});
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
documents = documents.filter(d => d.title.toLowerCase().includes(q));
|
||||
server.tool(
|
||||
"rdocs_list_docs",
|
||||
"List docs, optionally filtered by notebook, search text, or tags",
|
||||
{
|
||||
space: z.string().describe("Space slug"),
|
||||
token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"),
|
||||
notebook_id: z.string().optional().describe("Filter by notebook ID"),
|
||||
search: z.string().optional().describe("Search in title/content"),
|
||||
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 }) => {
|
||||
const access = await resolveAccess(token, space, false);
|
||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||
|
||||
const docIds = notebook_id
|
||||
? [notebookDocId(space, notebook_id)]
|
||||
: findNotebookDocIds(syncServer, space);
|
||||
|
||||
let notes: Array<NoteItem & { notebookTitle: string }> = [];
|
||||
for (const docId of docIds) {
|
||||
const doc = syncServer.getDoc<NotebookDoc>(docId);
|
||||
if (!doc?.items) continue;
|
||||
const nbTitle = doc.notebook?.title || "Untitled";
|
||||
for (const note of Object.values(doc.items)) {
|
||||
notes.push({ ...JSON.parse(JSON.stringify(note)), notebookTitle: nbTitle });
|
||||
}
|
||||
}
|
||||
|
||||
return { content: [{ type: "text", text: JSON.stringify(documents, null, 2) }] };
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
notes = notes.filter(n =>
|
||||
n.title.toLowerCase().includes(q) ||
|
||||
(n.contentPlain && n.contentPlain.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
notes = notes.filter(n =>
|
||||
n.tags && tags.some(t => n.tags.includes(t)),
|
||||
);
|
||||
}
|
||||
|
||||
notes.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const maxResults = limit || 50;
|
||||
notes = notes.slice(0, maxResults);
|
||||
|
||||
const summary = notes.map(n => ({
|
||||
id: n.id,
|
||||
notebookId: n.notebookId,
|
||||
notebookTitle: n.notebookTitle,
|
||||
title: n.title,
|
||||
type: n.type,
|
||||
tags: n.tags,
|
||||
isPinned: n.isPinned,
|
||||
contentPreview: (n.contentPlain || "").slice(0, 200),
|
||||
createdAt: n.createdAt,
|
||||
updatedAt: n.updatedAt,
|
||||
}));
|
||||
|
||||
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"rdocs_get_doc",
|
||||
"Get the full content of a specific doc",
|
||||
{
|
||||
space: z.string().describe("Space slug"),
|
||||
token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"),
|
||||
note_id: z.string().describe("Doc/note ID"),
|
||||
notebook_id: z.string().optional().describe("Notebook ID (speeds up lookup)"),
|
||||
},
|
||||
async ({ space, token, note_id, notebook_id }) => {
|
||||
const access = await resolveAccess(token, space, false);
|
||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||
|
||||
if (notebook_id) {
|
||||
const doc = syncServer.getDoc<NotebookDoc>(notebookDocId(space, notebook_id));
|
||||
const note = doc?.items?.[note_id];
|
||||
if (note) {
|
||||
return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] };
|
||||
}
|
||||
}
|
||||
|
||||
for (const docId of findNotebookDocIds(syncServer, space)) {
|
||||
const doc = syncServer.getDoc<NotebookDoc>(docId);
|
||||
const note = doc?.items?.[note_id];
|
||||
if (note) {
|
||||
return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] };
|
||||
}
|
||||
}
|
||||
|
||||
return { content: [{ type: "text", text: JSON.stringify({ error: "Doc not found" }) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"rdocs_create_doc",
|
||||
"Create a new doc 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("Doc title"),
|
||||
content: z.string().optional().describe("Doc content (plain text or HTML)"),
|
||||
tags: z.array(z.string()).optional().describe("Doc 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 doc ${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(
|
||||
"rdocs_update_doc",
|
||||
"Update an existing doc (requires auth token + space membership)",
|
||||
{
|
||||
space: z.string().describe("Space slug"),
|
||||
token: z.string().describe("JWT auth token"),
|
||||
note_id: z.string().describe("Doc/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 doc"),
|
||||
},
|
||||
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) {
|
||||
const doc = syncServer.getDoc<NotebookDoc>(docId);
|
||||
if (!doc?.items?.[note_id]) continue;
|
||||
|
||||
syncServer.changeDoc<NotebookDoc>(docId, `Update doc ${note_id}`, (d) => {
|
||||
const n = d.items[note_id];
|
||||
if (updates.title !== undefined) n.title = updates.title;
|
||||
if (updates.content !== undefined) {
|
||||
n.content = updates.content;
|
||||
n.contentPlain = updates.content;
|
||||
}
|
||||
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: "Doc not found" }) }], isError: true };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { getRecentMessagesForMI } from "../modules/rchats/mod";
|
|||
import { getRecentPublicationsForMI } from "../modules/rpubs/mod";
|
||||
import { getRecentDesignsForMI } from "../modules/rswag/mod";
|
||||
import { getRecentSheetsForMI } from "../modules/rsheets/mod";
|
||||
import { getLinkedDocsForMI } from "../modules/rdocs/mod";
|
||||
import { getRecentDocsForMI } from "../modules/rdocs/mod";
|
||||
import { getRecentSessionsForMI } from "../modules/rdesign/mod";
|
||||
import { getSharedAlbumsForMI } from "../modules/rphotos/mod";
|
||||
import { getRecentFlowsForMI } from "../modules/rflows/mod";
|
||||
|
|
@ -215,12 +215,12 @@ export function queryModuleContent(
|
|||
}
|
||||
|
||||
case "rdocs": {
|
||||
const docs = getLinkedDocsForMI(space, limit);
|
||||
const docs = getRecentDocsForMI(space, limit);
|
||||
if (queryType === "count") {
|
||||
return { ok: true, module, queryType, data: { count: docs.length }, summary: `${docs.length} documents found.` };
|
||||
return { ok: true, module, queryType, data: { count: docs.length }, summary: `${docs.length} docs found.` };
|
||||
}
|
||||
const lines = docs.map((d) => `- "${d.title}" (added ${new Date(d.addedAt).toLocaleDateString()})`);
|
||||
return { ok: true, module, queryType, data: docs, summary: lines.length ? `Documents:\n${lines.join("\n")}` : "No documents found." };
|
||||
const lines = docs.map((n) => `- "${n.title}" (${n.type}, updated ${new Date(n.updatedAt).toLocaleDateString()})${n.tags.length ? ` [${n.tags.join(", ")}]` : ""}: ${n.contentPlain.slice(0, 100)}...`);
|
||||
return { ok: true, module, queryType, data: docs, summary: lines.length ? `Recent docs:\n${lines.join("\n")}` : "No docs found." };
|
||||
}
|
||||
|
||||
case "rdesign": {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { getRecentMessagesForMI } from "../modules/rchats/mod";
|
|||
import { getRecentPublicationsForMI } from "../modules/rpubs/mod";
|
||||
import { getRecentDesignsForMI } from "../modules/rswag/mod";
|
||||
import { getRecentSheetsForMI } from "../modules/rsheets/mod";
|
||||
import { getLinkedDocsForMI } from "../modules/rdocs/mod";
|
||||
import { getRecentDocsForMI } from "../modules/rdocs/mod";
|
||||
import { getRecentSessionsForMI } from "../modules/rdesign/mod";
|
||||
import { getSharedAlbumsForMI } from "../modules/rphotos/mod";
|
||||
import { getRecentFlowsForMI } from "../modules/rflows/mod";
|
||||
|
|
@ -341,10 +341,12 @@ mi.post("/ask", async (c) => {
|
|||
sheetsContext = `\n- Spreadsheets:\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
const linkedDocs = getLinkedDocsForMI(space, 3);
|
||||
if (linkedDocs.length > 0) {
|
||||
const lines = linkedDocs.map((d) => `- "${d.title}"`);
|
||||
docsContext = `\n- Documents:\n${lines.join("\n")}`;
|
||||
const recentDocs = getRecentDocsForMI(space, 3);
|
||||
if (recentDocs.length > 0) {
|
||||
const lines = recentDocs.map((n) =>
|
||||
`- "${n.title}" (${n.type}${n.tags.length ? `, tags: ${n.tags.join(", ")}` : ""}): ${n.contentPlain.slice(0, 100)}…`
|
||||
);
|
||||
docsContext = `\n- Recent docs:\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
const designSessions = getRecentSessionsForMI(space, 3);
|
||||
|
|
@ -892,6 +894,16 @@ mi.post("/suggestions", async (c) => {
|
|||
autoSend: true,
|
||||
});
|
||||
}
|
||||
} else if (currentModule === "rdocs") {
|
||||
const docs = getRecentDocsForMI(space, 1);
|
||||
if (docs.length === 0) {
|
||||
suggestions.push({
|
||||
label: "Create your first doc",
|
||||
icon: "📄",
|
||||
prompt: "Help me create my first notebook",
|
||||
autoSend: true,
|
||||
});
|
||||
}
|
||||
} else if (currentModule === "rtasks") {
|
||||
const t = getRecentTasksForMI(space, 1);
|
||||
if (t.length === 0) {
|
||||
|
|
@ -914,7 +926,7 @@ mi.post("/suggestions", async (c) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Recent note to continue editing
|
||||
// Recent note/doc to continue editing
|
||||
if (currentModule === "rnotes") {
|
||||
const recent = getRecentNotesForMI(space, 1);
|
||||
if (recent.length > 0) {
|
||||
|
|
@ -925,6 +937,16 @@ mi.post("/suggestions", async (c) => {
|
|||
autoSend: true,
|
||||
});
|
||||
}
|
||||
} else if (currentModule === "rdocs") {
|
||||
const recent = getRecentDocsForMI(space, 1);
|
||||
if (recent.length > 0) {
|
||||
suggestions.push({
|
||||
label: `Continue "${recent[0].title}"`,
|
||||
icon: "📄",
|
||||
prompt: `Help me continue working on "${recent[0].title}"`,
|
||||
autoSend: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("[mi/suggestions]", e.message);
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
|
||||
import { Hono } from 'hono';
|
||||
import * as Automerge from '@automerge/automerge';
|
||||
import { connectionsDocId } from '../../modules/rnotes/schemas';
|
||||
import type { ConnectionsDoc } from '../../modules/rnotes/schemas';
|
||||
import { connectionsDocId } from '../../modules/rdocs/schemas';
|
||||
import type { ConnectionsDoc } from '../../modules/rdocs/schemas';
|
||||
import type { SyncServer } from '../local-first/sync-server';
|
||||
|
||||
const googleOAuthRoutes = new Hono();
|
||||
|
|
@ -132,7 +132,7 @@ googleOAuthRoutes.get('/callback', async (c) => {
|
|||
};
|
||||
});
|
||||
|
||||
const redirectUrl = c.get("isSubdomain") ? `/rnotes?connected=google` : `/${state.space}/rnotes?connected=google`;
|
||||
const redirectUrl = c.get("isSubdomain") ? `/rdocs?connected=google` : `/${state.space}/rdocs?connected=google`;
|
||||
return c.redirect(redirectUrl);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ import * as Automerge from '@automerge/automerge';
|
|||
import { notionOAuthRoutes } from './notion';
|
||||
import { googleOAuthRoutes } from './google';
|
||||
import { clickupOAuthRoutes } from './clickup';
|
||||
import { connectionsDocId } from '../../modules/rnotes/schemas';
|
||||
import { connectionsDocId } from '../../modules/rdocs/schemas';
|
||||
import { clickupConnectionDocId } from '../../modules/rtasks/schemas';
|
||||
import type { ConnectionsDoc } from '../../modules/rnotes/schemas';
|
||||
import type { ConnectionsDoc } from '../../modules/rdocs/schemas';
|
||||
import type { ClickUpConnectionDoc } from '../../modules/rtasks/schemas';
|
||||
import type { SyncServer } from '../local-first/sync-server';
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ oauthRouter.get('/status', (c) => {
|
|||
if (!space) return c.json({ error: 'space query param required' }, 400);
|
||||
if (!_syncServer) return c.json({ error: 'SyncServer not initialized' }, 500);
|
||||
|
||||
// Read rNotes connections doc (Google + Notion)
|
||||
// Read rDocs connections doc (Google + Notion)
|
||||
const connDoc = _syncServer.getDoc<ConnectionsDoc>(connectionsDocId(space));
|
||||
// Read rTasks ClickUp connection doc
|
||||
const clickupDoc = _syncServer.getDoc<ClickUpConnectionDoc>(clickupConnectionDocId(space));
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
|
||||
import { Hono } from 'hono';
|
||||
import * as Automerge from '@automerge/automerge';
|
||||
import { connectionsDocId } from '../../modules/rnotes/schemas';
|
||||
import type { ConnectionsDoc } from '../../modules/rnotes/schemas';
|
||||
import { connectionsDocId } from '../../modules/rdocs/schemas';
|
||||
import type { ConnectionsDoc } from '../../modules/rdocs/schemas';
|
||||
import type { SyncServer } from '../local-first/sync-server';
|
||||
|
||||
const notionOAuthRoutes = new Hono();
|
||||
|
|
@ -104,8 +104,8 @@ notionOAuthRoutes.get('/callback', async (c) => {
|
|||
};
|
||||
});
|
||||
|
||||
// Redirect back to rNotes
|
||||
const redirectUrl = c.get("isSubdomain") ? `/rnotes?connected=notion` : `/${state.space}/rnotes?connected=notion`;
|
||||
// Redirect back to rDocs
|
||||
const redirectUrl = c.get("isSubdomain") ? `/rdocs?connected=notion` : `/${state.space}/rdocs?connected=notion`;
|
||||
return c.redirect(redirectUrl);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* Logseq graph ↔ rNotes/rDocs converter.
|
||||
*
|
||||
* Import: ZIP of pages/ + journals/ dirs, property:: value syntax, bullet outliner blocks
|
||||
* Export: ZIP with Logseq-compatible page files + properties
|
||||
*
|
||||
* Shared across rDocs and rNotes — each module registers this converter in its own registry.
|
||||
*/
|
||||
|
||||
import JSZip from 'jszip';
|
||||
import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from '../markdown-tiptap';
|
||||
import { hashContent } from './types';
|
||||
import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter, NoteItem } from './types';
|
||||
|
||||
/** Parse Logseq property:: value lines from the top of a page. */
|
||||
function parseLogseqProperties(content: string): { properties: Record<string, string>; body: string } {
|
||||
const lines = content.split('\n');
|
||||
const properties: Record<string, string> = {};
|
||||
let bodyStart = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^([a-zA-Z_-]+)::\s*(.*)$/);
|
||||
if (match) {
|
||||
properties[match[1].toLowerCase()] = match[2].trim();
|
||||
bodyStart = i + 1;
|
||||
} else if (lines[i].trim() === '') {
|
||||
bodyStart = i + 1;
|
||||
continue;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { properties, body: lines.slice(bodyStart).join('\n') };
|
||||
}
|
||||
|
||||
/** Convert Logseq outliner bullet format to regular markdown. */
|
||||
function convertOutlinerToMarkdown(content: string): string {
|
||||
const lines = content.split('\n');
|
||||
const result: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(\s*)- (.*)$/);
|
||||
if (match) {
|
||||
const indent = match[1];
|
||||
const text = match[2];
|
||||
|
||||
let level = 0;
|
||||
if (indent.length > 0) {
|
||||
const tabCount = (indent.match(/\t/g) || []).length;
|
||||
const spaceCount = indent.replace(/\t/g, '').length;
|
||||
level = tabCount + Math.floor(spaceCount / 2);
|
||||
}
|
||||
|
||||
if (level === 0 && text.startsWith('# ')) {
|
||||
result.push(text);
|
||||
} else if (level === 0) {
|
||||
result.push(`- ${text}`);
|
||||
} else {
|
||||
const indentation = ' '.repeat(level);
|
||||
result.push(`${indentation}- ${text}`);
|
||||
}
|
||||
} else {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
/** Convert [[page references]] to standard links. */
|
||||
function convertPageRefs(md: string): string {
|
||||
return md.replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)');
|
||||
}
|
||||
|
||||
/** Convert Logseq tags (#tag or #[[multi word tag]]). */
|
||||
function extractLogseqTags(content: string): string[] {
|
||||
const tags: string[] = [];
|
||||
const singleTags = content.match(/#([a-zA-Z0-9_-]+)/g);
|
||||
if (singleTags) tags.push(...singleTags.map(t => t.slice(1).toLowerCase()));
|
||||
const multiTags = content.match(/#\[\[([^\]]+)\]\]/g);
|
||||
if (multiTags) tags.push(...multiTags.map(t => t.slice(3, -2).toLowerCase().replace(/\s+/g, '-')));
|
||||
return [...new Set(tags)];
|
||||
}
|
||||
|
||||
/** Parse Logseq journal filename to date. */
|
||||
function parseJournalDate(filename: string): string | null {
|
||||
const match = filename.match(/(\d{4})[_-](\d{2})[_-](\d{2})\.md$/);
|
||||
if (match) return `${match[1]}-${match[2]}-${match[3]}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract title from filename. */
|
||||
function titleFromPath(filePath: string): string {
|
||||
const filename = filePath.split('/').pop() || 'Untitled';
|
||||
return filename.replace(/\.md$/i, '').replace(/%2F/g, '/').replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export const logseqConverter: NoteConverter = {
|
||||
id: 'logseq',
|
||||
name: 'Logseq',
|
||||
requiresAuth: false,
|
||||
|
||||
async import(input: ImportInput): Promise<ImportResult> {
|
||||
if (!input.fileData) {
|
||||
throw new Error('Logseq import requires a ZIP file');
|
||||
}
|
||||
|
||||
const zip = await JSZip.loadAsync(input.fileData);
|
||||
const notes: ConvertedNote[] = [];
|
||||
const warnings: string[] = [];
|
||||
let graphName = 'Logseq Import';
|
||||
|
||||
const mdFiles: { path: string; file: JSZip.JSZipObject; isJournal: boolean }[] = [];
|
||||
zip.forEach((path, file) => {
|
||||
if (file.dir) return;
|
||||
if (!path.endsWith('.md')) return;
|
||||
if (path.includes('logseq/') && !path.includes('pages/') && !path.includes('journals/')) return;
|
||||
if (path.includes('.recycle/')) return;
|
||||
|
||||
const isJournal = path.includes('journals/');
|
||||
mdFiles.push({ path, file, isJournal });
|
||||
});
|
||||
|
||||
if (mdFiles.length === 0) {
|
||||
warnings.push('No .md files found in pages/ or journals/ directories');
|
||||
return { notes, notebookTitle: graphName, warnings };
|
||||
}
|
||||
|
||||
const firstPath = mdFiles[0].path;
|
||||
const rootFolder = firstPath.split('/')[0];
|
||||
if (rootFolder && mdFiles.every(f => f.path.startsWith(rootFolder + '/'))) {
|
||||
graphName = rootFolder;
|
||||
for (const f of mdFiles) {
|
||||
f.path = f.path.slice(rootFolder.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { path, file, isJournal } of mdFiles) {
|
||||
try {
|
||||
const raw = await file.async('string');
|
||||
const { properties, body } = parseLogseqProperties(raw);
|
||||
|
||||
let md = convertOutlinerToMarkdown(body);
|
||||
md = convertPageRefs(md);
|
||||
|
||||
const filename = path.split('/').pop() || '';
|
||||
let title: string;
|
||||
|
||||
if (isJournal) {
|
||||
const date = parseJournalDate(filename);
|
||||
title = date ? `Journal: ${date}` : titleFromPath(path);
|
||||
} else {
|
||||
title = properties.title || titleFromPath(path);
|
||||
}
|
||||
|
||||
const tiptapJson = markdownToTiptap(md);
|
||||
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
|
||||
|
||||
const tags: string[] = [];
|
||||
if (properties.tags) {
|
||||
const tagStr = properties.tags.replace(/\[\[|\]\]/g, '');
|
||||
tags.push(...tagStr.split(',').map(t => t.trim().toLowerCase()).filter(Boolean));
|
||||
}
|
||||
tags.push(...extractLogseqTags(raw));
|
||||
if (isJournal) tags.push('journal');
|
||||
|
||||
notes.push({
|
||||
title,
|
||||
content: tiptapJson,
|
||||
contentPlain,
|
||||
markdown: md,
|
||||
tags: [...new Set(tags)],
|
||||
sourceRef: {
|
||||
source: 'logseq',
|
||||
externalId: path,
|
||||
lastSyncedAt: Date.now(),
|
||||
contentHash: hashContent(raw),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to parse ${path}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { notes, notebookTitle: graphName, warnings };
|
||||
},
|
||||
|
||||
async export(notes: NoteItem[], opts: ExportOptions): Promise<ExportResult> {
|
||||
const zip = new JSZip();
|
||||
const graphName = opts.notebookTitle || 'rNotes Export';
|
||||
const pagesDir = zip.folder('pages')!;
|
||||
|
||||
for (const note of notes) {
|
||||
let md: string;
|
||||
if (note.contentFormat === 'tiptap-json' && note.content) {
|
||||
md = tiptapToMarkdown(note.content);
|
||||
} else if (note.content) {
|
||||
md = note.content.replace(/<[^>]*>/g, '').trim();
|
||||
} else {
|
||||
md = '';
|
||||
}
|
||||
|
||||
const props: string[] = [];
|
||||
if (note.tags.length > 0) {
|
||||
props.push(`tags:: ${note.tags.map(t => `[[${t}]]`).join(', ')}`);
|
||||
}
|
||||
if (note.type !== 'NOTE') {
|
||||
props.push(`type:: ${note.type.toLowerCase()}`);
|
||||
}
|
||||
props.push(`created:: ${new Date(note.createdAt).toISOString().split('T')[0]}`);
|
||||
|
||||
const mdLines = md.split('\n');
|
||||
const outliner: string[] = [];
|
||||
for (const line of mdLines) {
|
||||
if (line.trim() === '') continue;
|
||||
if (line.startsWith('#')) {
|
||||
outliner.push(`- ${line}`);
|
||||
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
outliner.push(`- ${line.slice(2)}`);
|
||||
} else if (line.match(/^\d+\.\s/)) {
|
||||
outliner.push(`- ${line.replace(/^\d+\.\s/, '')}`);
|
||||
} else {
|
||||
outliner.push(`- ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
const propsBlock = props.length > 0 ? props.join('\n') + '\n\n' : '';
|
||||
const fileContent = `${propsBlock}${outliner.join('\n')}\n`;
|
||||
|
||||
const filename = note.title
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
.replace(/\//g, '%2F')
|
||||
.trim() || 'Untitled';
|
||||
|
||||
pagesDir.file(`${filename}.md`, fileContent);
|
||||
}
|
||||
|
||||
const data = await zip.generateAsync({ type: 'uint8array' });
|
||||
return {
|
||||
data,
|
||||
filename: `${graphName.replace(/\s+/g, '-').toLowerCase()}-logseq.zip`,
|
||||
mimeType: 'application/zip',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
/**
|
||||
* Obsidian vault ↔ rNotes/rDocs converter.
|
||||
*
|
||||
* Import: ZIP of .md files with YAML frontmatter, [[wikilinks]], callouts, nested folders → tags
|
||||
* Export: ZIP of .md files with YAML frontmatter, organized by notebook
|
||||
*
|
||||
* Shared across rDocs and rNotes — each module registers this converter in its own registry.
|
||||
*/
|
||||
|
||||
import JSZip from 'jszip';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from '../markdown-tiptap';
|
||||
import { hashContent } from './types';
|
||||
import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter, NoteItem } from './types';
|
||||
|
||||
/** Parse YAML frontmatter from an Obsidian markdown file. */
|
||||
function parseFrontmatter(content: string): { frontmatter: Record<string, any>; body: string } {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!match) return { frontmatter: {}, body: content };
|
||||
|
||||
try {
|
||||
const frontmatter = parseYaml(match[1]) || {};
|
||||
return { frontmatter, body: match[2] };
|
||||
} catch {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert Obsidian [[wikilinks]] to standard markdown links. */
|
||||
function convertWikilinks(md: string): string {
|
||||
return md.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '[$2]($1)')
|
||||
.replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)');
|
||||
}
|
||||
|
||||
/** Convert Obsidian callouts to blockquotes. */
|
||||
function convertCallouts(md: string): string {
|
||||
return md.replace(/^> \[!(\w+)\]\s*(.*)/gm, (_, type, title) => {
|
||||
const label = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
return title ? `> **${label}:** ${title}` : `> **${label}**`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Extract folder path as tag prefix from file path. */
|
||||
function pathToTags(filePath: string): string[] {
|
||||
const parts = filePath.split('/').slice(0, -1);
|
||||
const filtered = parts.filter(p => !['attachments', 'assets', 'templates', '.obsidian'].includes(p.toLowerCase()));
|
||||
if (filtered.length === 0) return [];
|
||||
return filtered.map(p => p.toLowerCase().replace(/\s+/g, '-'));
|
||||
}
|
||||
|
||||
/** Extract title from filename (without .md extension). */
|
||||
function titleFromPath(filePath: string): string {
|
||||
const filename = filePath.split('/').pop() || 'Untitled';
|
||||
return filename.replace(/\.md$/i, '');
|
||||
}
|
||||
|
||||
export const obsidianConverter: NoteConverter = {
|
||||
id: 'obsidian',
|
||||
name: 'Obsidian',
|
||||
requiresAuth: false,
|
||||
|
||||
async import(input: ImportInput): Promise<ImportResult> {
|
||||
if (!input.fileData) {
|
||||
throw new Error('Obsidian import requires a ZIP file');
|
||||
}
|
||||
|
||||
const zip = await JSZip.loadAsync(input.fileData);
|
||||
const notes: ConvertedNote[] = [];
|
||||
const warnings: string[] = [];
|
||||
let vaultName = 'Obsidian Import';
|
||||
|
||||
// Build image map from non-.md files in the ZIP for embedded image resolution
|
||||
const imageMap = new Map<string, { file: JSZip.JSZipObject; mimeType: string }>();
|
||||
const imageExts: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp' };
|
||||
zip.forEach((path, file) => {
|
||||
if (file.dir) return;
|
||||
const ext = path.substring(path.lastIndexOf('.')).toLowerCase();
|
||||
if (imageExts[ext]) {
|
||||
const basename = path.split('/').pop()!;
|
||||
imageMap.set(basename, { file, mimeType: imageExts[ext] });
|
||||
imageMap.set(path, { file, mimeType: imageExts[ext] });
|
||||
}
|
||||
});
|
||||
|
||||
// Find markdown files in the ZIP
|
||||
const mdFiles: { path: string; file: JSZip.JSZipObject }[] = [];
|
||||
zip.forEach((path, file) => {
|
||||
if (file.dir) return;
|
||||
if (!path.endsWith('.md')) return;
|
||||
if (path.includes('.obsidian/') || path.includes('.trash/')) return;
|
||||
mdFiles.push({ path, file });
|
||||
});
|
||||
|
||||
if (mdFiles.length === 0) {
|
||||
warnings.push('No .md files found in the ZIP archive');
|
||||
return { notes, notebookTitle: vaultName, warnings };
|
||||
}
|
||||
|
||||
// Try to detect vault name from common root folder
|
||||
const firstPath = mdFiles[0].path;
|
||||
const rootFolder = firstPath.split('/')[0];
|
||||
if (rootFolder && mdFiles.every(f => f.path.startsWith(rootFolder + '/'))) {
|
||||
vaultName = rootFolder;
|
||||
for (const f of mdFiles) {
|
||||
f.path = f.path.slice(rootFolder.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { path, file } of mdFiles) {
|
||||
try {
|
||||
const raw = await file.async('string');
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
|
||||
let md = convertWikilinks(body);
|
||||
md = convertCallouts(md);
|
||||
|
||||
// Resolve embedded images
|
||||
const attachments: { filename: string; data: Uint8Array; mimeType: string }[] = [];
|
||||
const imageRefPattern = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const resolvedImages = new Set<string>();
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = imageRefPattern.exec(md)) !== null) {
|
||||
const ref = match[2];
|
||||
const basename = ref.split('/').pop()!;
|
||||
const imgEntry = imageMap.get(basename) || imageMap.get(ref);
|
||||
if (imgEntry && !resolvedImages.has(basename)) {
|
||||
resolvedImages.add(basename);
|
||||
const data = await imgEntry.file.async('uint8array');
|
||||
attachments.push({ filename: basename, data, mimeType: imgEntry.mimeType });
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.length > 0) {
|
||||
md = md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (full, alt, ref) => {
|
||||
const basename = ref.split('/').pop()!;
|
||||
if (imageMap.has(basename) || imageMap.has(ref)) {
|
||||
return ``;
|
||||
}
|
||||
return full;
|
||||
});
|
||||
}
|
||||
|
||||
const title = frontmatter.title || titleFromPath(path);
|
||||
const tiptapJson = markdownToTiptap(md);
|
||||
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
|
||||
|
||||
const tags: string[] = [];
|
||||
if (frontmatter.tags) {
|
||||
const fmTags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [frontmatter.tags];
|
||||
tags.push(...fmTags.map((t: string) => String(t).toLowerCase().replace(/^#/, '')));
|
||||
}
|
||||
tags.push(...pathToTags(path));
|
||||
|
||||
notes.push({
|
||||
title,
|
||||
content: tiptapJson,
|
||||
contentPlain,
|
||||
markdown: md,
|
||||
tags: [...new Set(tags)],
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
sourceRef: {
|
||||
source: 'obsidian',
|
||||
externalId: path,
|
||||
lastSyncedAt: Date.now(),
|
||||
contentHash: hashContent(raw),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to parse ${path}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { notes, notebookTitle: vaultName, warnings };
|
||||
},
|
||||
|
||||
async export(notes: NoteItem[], opts: ExportOptions): Promise<ExportResult> {
|
||||
const zip = new JSZip();
|
||||
const notebookTitle = opts.notebookTitle || 'rNotes Export';
|
||||
|
||||
for (const note of notes) {
|
||||
let md: string;
|
||||
if (note.contentFormat === 'tiptap-json' && note.content) {
|
||||
md = tiptapToMarkdown(note.content);
|
||||
} else if (note.content) {
|
||||
md = note.content.replace(/<[^>]*>/g, '').trim();
|
||||
} else {
|
||||
md = '';
|
||||
}
|
||||
|
||||
const frontmatter: Record<string, any> = {};
|
||||
if (note.tags.length > 0) frontmatter.tags = note.tags;
|
||||
frontmatter.created = new Date(note.createdAt).toISOString();
|
||||
frontmatter.updated = new Date(note.updatedAt).toISOString();
|
||||
if (note.type !== 'NOTE') frontmatter.type = note.type.toLowerCase();
|
||||
if (note.sourceRef) {
|
||||
frontmatter['rnotes-id'] = note.id;
|
||||
}
|
||||
|
||||
const yamlStr = stringifyYaml(frontmatter).trim();
|
||||
const fileContent = `---\n${yamlStr}\n---\n\n${md}\n`;
|
||||
|
||||
const filename = note.title
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || 'Untitled';
|
||||
|
||||
zip.file(`${notebookTitle}/${filename}.md`, fileContent);
|
||||
}
|
||||
|
||||
const data = await zip.generateAsync({ type: 'uint8array' });
|
||||
return {
|
||||
data,
|
||||
filename: `${notebookTitle.replace(/\s+/g, '-').toLowerCase()}-obsidian.zip`,
|
||||
mimeType: 'application/zip',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Shared converter types and utilities.
|
||||
*
|
||||
* Used by both rDocs and rNotes converter systems.
|
||||
* Source-specific converters implement NoteConverter.
|
||||
* ConvertedNote is the intermediate format between external sources and NoteItem.
|
||||
*/
|
||||
|
||||
import type { NoteItem, SourceRef } from '../../modules/rnotes/schemas';
|
||||
|
||||
// Re-export schema types that converters need
|
||||
export type { NoteItem, SourceRef };
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
|
@ -80,10 +80,23 @@ export class TabCache {
|
|||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intra-module view navigation (back within an rApp)
|
||||
// If same module+space and the state has a _view field, dispatch to
|
||||
// ViewHistory instead of switching panes.
|
||||
const stateSpace = state.spaceSlug || this.spaceSlug;
|
||||
if (state.moduleId === this.currentModuleId &&
|
||||
stateSpace === this.spaceSlug &&
|
||||
('_view' in state || (e as any)._viewNav)) {
|
||||
window.dispatchEvent(new CustomEvent('rspace-view-popstate', {
|
||||
detail: { moduleId: state.moduleId, view: state._view, context: state._viewCtx },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// If returning from dashboard, hide it
|
||||
const dashboard = document.querySelector("rstack-user-dashboard");
|
||||
if (dashboard) (dashboard as HTMLElement).style.display = "none";
|
||||
const stateSpace = state.spaceSlug || this.spaceSlug;
|
||||
const key = this.paneKey(stateSpace, state.moduleId);
|
||||
if (this.panes.has(key)) {
|
||||
if (stateSpace !== this.spaceSlug) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@
|
|||
* Each rApp with hierarchical views instantiates one, calls push()
|
||||
* on forward navigation, and back() from the back button. Replaces
|
||||
* hardcoded data-back targets with a proper history stack.
|
||||
*
|
||||
* Browser history integration (opt-in):
|
||||
* Pass `moduleId` to the constructor to sync push/back with the
|
||||
* browser's history.pushState / popstate. TabCache's popstate handler
|
||||
* dispatches `rspace-view-popstate` events for same-module navigation,
|
||||
* which this class catches and calls `back()` automatically, then
|
||||
* re-dispatches as `rspace-view-restored` for the component to re-render.
|
||||
*/
|
||||
|
||||
export interface ViewEntry<V extends string> {
|
||||
|
|
@ -16,9 +23,20 @@ const MAX_DEPTH = 20;
|
|||
export class ViewHistory<V extends string> {
|
||||
private stack: ViewEntry<V>[] = [];
|
||||
private root: V;
|
||||
private moduleId: string | null;
|
||||
private _skipNextPush = false;
|
||||
|
||||
constructor(rootView: V) {
|
||||
/**
|
||||
* @param rootView Default view when stack is empty.
|
||||
* @param moduleId If provided, push/back sync with browser history.
|
||||
*/
|
||||
constructor(rootView: V, moduleId?: string) {
|
||||
this.root = rootView;
|
||||
this.moduleId = moduleId ?? null;
|
||||
|
||||
if (this.moduleId) {
|
||||
window.addEventListener('rspace-view-popstate', this._onPopstate as EventListener);
|
||||
}
|
||||
}
|
||||
|
||||
/** Record a forward navigation. Skips if top of stack is same view+context. */
|
||||
|
|
@ -27,6 +45,16 @@ export class ViewHistory<V extends string> {
|
|||
if (top && top.view === view) return; // skip duplicate
|
||||
this.stack.push({ view, context });
|
||||
if (this.stack.length > MAX_DEPTH) this.stack.shift();
|
||||
|
||||
// Sync with browser history
|
||||
if (this.moduleId && !this._skipNextPush) {
|
||||
const existing = history.state || {};
|
||||
history.pushState(
|
||||
{ ...existing, moduleId: existing.moduleId, spaceSlug: existing.spaceSlug, _view: view, _viewCtx: context },
|
||||
'',
|
||||
);
|
||||
}
|
||||
this._skipNextPush = false;
|
||||
}
|
||||
|
||||
/** Pop and return the previous entry, or null if at root. */
|
||||
|
|
@ -55,4 +83,25 @@ export class ViewHistory<V extends string> {
|
|||
if (rootView !== undefined) this.root = rootView;
|
||||
this.stack = [];
|
||||
}
|
||||
|
||||
/** Clean up the popstate listener. Call when the component disconnects. */
|
||||
destroy(): void {
|
||||
if (this.moduleId) {
|
||||
window.removeEventListener('rspace-view-popstate', this._onPopstate as EventListener);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle popstate events forwarded by TabCache for same-module nav. */
|
||||
private _onPopstate = (e: CustomEvent) => {
|
||||
if (e.detail?.moduleId !== this.moduleId) return;
|
||||
|
||||
// Pop our stack without triggering another pushState
|
||||
const prev = this.back();
|
||||
if (!prev) return;
|
||||
|
||||
// Tell the component to re-render with the restored view
|
||||
window.dispatchEvent(new CustomEvent('rspace-view-restored', {
|
||||
detail: { moduleId: this.moduleId, view: prev.view, context: prev.context },
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue