feat(rnotes): web clipper download, URL/file note creation from sidebar
- Copy browser extension into repo so /extension/download works in Docker - Add "Web Clipper" button to sidebar footer - Replace simple "+" with context menu: New Note / From URL / Upload File - BOOKMARK notes from URL, IMAGE/AUDIO/FILE notes from uploaded files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
24c1598e60
commit
7d5209021a
|
|
@ -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);
|
||||
|
|
@ -1284,6 +1284,128 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
this.createNoteViaSync();
|
||||
}
|
||||
|
||||
/** Show a small dropdown menu near the "+" button with note creation options. */
|
||||
private showAddNoteMenu(nbId: string, anchorEl: HTMLElement) {
|
||||
// Remove any existing menu
|
||||
this.shadow.querySelector('.add-note-menu')?.remove();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'add-note-menu';
|
||||
|
||||
// Position near the anchor
|
||||
const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect();
|
||||
const anchorRect = anchorEl.getBoundingClientRect();
|
||||
menu.style.left = `${anchorRect.left - hostRect.left}px`;
|
||||
menu.style.top = `${anchorRect.bottom - hostRect.top + 4}px`;
|
||||
|
||||
menu.innerHTML = `
|
||||
<button class="add-note-menu-item" data-action="note">New Note</button>
|
||||
<button class="add-note-menu-item" data-action="url">From URL</button>
|
||||
<button class="add-note-menu-item" data-action="upload">Upload File</button>
|
||||
`;
|
||||
|
||||
this.shadow.appendChild(menu);
|
||||
|
||||
const close = () => menu.remove();
|
||||
|
||||
menu.querySelectorAll('.add-note-menu-item').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const action = (btn as HTMLElement).dataset.action;
|
||||
close();
|
||||
if (action === 'note') this.addNoteToNotebook(nbId);
|
||||
else if (action === 'url') this.createNoteFromUrl(nbId);
|
||||
else if (action === 'upload') this.createNoteFromFile(nbId);
|
||||
});
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
const onOutside = (e: Event) => {
|
||||
if (!menu.contains(e.target as Node)) {
|
||||
close();
|
||||
this.shadow.removeEventListener('click', onOutside);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => this.shadow.addEventListener('click', onOutside));
|
||||
}
|
||||
|
||||
/** Prompt for a URL and create a BOOKMARK note. */
|
||||
private async createNoteFromUrl(nbId: string) {
|
||||
const url = prompt('Enter URL:');
|
||||
if (!url) return;
|
||||
|
||||
let title: string;
|
||||
try { title = new URL(url).hostname; } catch { title = url; }
|
||||
|
||||
// Ensure notebook is selected and subscribed
|
||||
const nb = this.notebooks.find(n => n.id === nbId);
|
||||
if (!nb) return;
|
||||
this.selectedNotebook = { ...nb, notes: this.notebookNotes.get(nbId) || [] };
|
||||
if (!this.expandedNotebooks.has(nbId)) this.expandedNotebooks.add(nbId);
|
||||
|
||||
if (this.space === 'demo') {
|
||||
this.demoCreateNote();
|
||||
return;
|
||||
}
|
||||
|
||||
const needSubscribe = !this.subscribedDocId || !this.subscribedDocId.endsWith(`:${nbId}`);
|
||||
if (needSubscribe) await this.loadNotebook(nbId);
|
||||
|
||||
this.createNoteViaSync({ type: 'BOOKMARK', url, title });
|
||||
}
|
||||
|
||||
/** Open a file picker, upload the file, and create a FILE or IMAGE note. */
|
||||
private createNoteFromFile(nbId: string) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*,audio/*,video/*,.pdf,.doc,.docx,.txt,.md,.csv,.json';
|
||||
|
||||
input.addEventListener('change', async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Upload the file
|
||||
const base = this.getApiBase();
|
||||
const fd = new FormData();
|
||||
fd.append('file', file, file.name);
|
||||
|
||||
try {
|
||||
const uploadRes = await fetch(`${base}/api/uploads`, {
|
||||
method: 'POST', headers: this.authHeaders(), body: fd,
|
||||
});
|
||||
if (!uploadRes.ok) throw new Error('Upload failed');
|
||||
const uploadData = await uploadRes.json();
|
||||
const fileUrl = uploadData.url || uploadData.path;
|
||||
|
||||
// Determine note type
|
||||
const mime = file.type || '';
|
||||
const type: NoteType = mime.startsWith('image/') ? 'IMAGE'
|
||||
: mime.startsWith('audio/') ? 'AUDIO' : 'FILE';
|
||||
|
||||
// Ensure notebook ready
|
||||
const nb = this.notebooks.find(n => n.id === nbId);
|
||||
if (!nb) return;
|
||||
this.selectedNotebook = { ...nb, notes: this.notebookNotes.get(nbId) || [] };
|
||||
if (!this.expandedNotebooks.has(nbId)) this.expandedNotebooks.add(nbId);
|
||||
|
||||
if (this.space === 'demo') {
|
||||
this.demoCreateNote();
|
||||
return;
|
||||
}
|
||||
|
||||
const needSubscribe = !this.subscribedDocId || !this.subscribedDocId.endsWith(`:${nbId}`);
|
||||
if (needSubscribe) await this.loadNotebook(nbId);
|
||||
|
||||
this.createNoteViaSync({ type, fileUrl, mimeType: mime, title: file.name });
|
||||
} catch (err) {
|
||||
console.error('File upload failed:', err);
|
||||
alert('Failed to upload file. Please try again.');
|
||||
}
|
||||
});
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
private loadNote(id: string) {
|
||||
// Note is already in the Automerge doc
|
||||
if (this.doc?.items?.[id]) {
|
||||
|
|
@ -3030,6 +3152,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<button class="sidebar-footer-btn" id="btn-import-export">Import / Export</button>
|
||||
<button class="sidebar-footer-btn" id="btn-web-clipper">Web Clipper</button>
|
||||
<button class="sidebar-footer-btn" id="btn-tour">Tour</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -3188,6 +3311,11 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
this.openImportExportDialog();
|
||||
});
|
||||
|
||||
// Web Clipper download
|
||||
this.shadow.getElementById("btn-web-clipper")?.addEventListener("click", () => {
|
||||
window.open(`${this.getApiBase()}/extension/download`, '_blank');
|
||||
});
|
||||
|
||||
// Tour
|
||||
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
|
|
@ -3200,12 +3328,12 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
});
|
||||
});
|
||||
|
||||
// Add note to notebook
|
||||
// Add note to notebook (context menu on +)
|
||||
this.shadow.querySelectorAll("[data-add-note]").forEach(el => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const nbId = (el as HTMLElement).dataset.addNote!;
|
||||
this.addNoteToNotebook(nbId);
|
||||
this.showAddNoteMenu(nbId, el as HTMLElement);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -3545,7 +3673,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
|
||||
.sidebar-footer {
|
||||
padding: 8px 12px; border-top: 1px solid var(--rs-border-subtle);
|
||||
display: flex; gap: 6px;
|
||||
display: flex; gap: 6px; flex-wrap: wrap;
|
||||
}
|
||||
.sidebar-footer-btn {
|
||||
padding: 5px 10px; border-radius: 5px;
|
||||
|
|
@ -3555,6 +3683,21 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
}
|
||||
.sidebar-footer-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
|
||||
|
||||
/* Add-note context menu */
|
||||
.add-note-menu {
|
||||
position: absolute; z-index: 100;
|
||||
background: var(--rs-surface, #fff); border: 1px solid var(--rs-border);
|
||||
border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
padding: 4px; min-width: 130px;
|
||||
}
|
||||
.add-note-menu-item {
|
||||
display: block; width: 100%; padding: 6px 10px;
|
||||
border: none; background: transparent; text-align: left;
|
||||
font-size: 12px; font-family: inherit; cursor: pointer;
|
||||
color: var(--rs-text-primary); border-radius: 4px;
|
||||
}
|
||||
.add-note-menu-item:hover { background: var(--rs-surface-hover, #f3f4f6); }
|
||||
|
||||
/* Sidebar collab info */
|
||||
.sidebar-collab-info {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
|
|
|
|||
|
|
@ -1563,7 +1563,7 @@ routes.get("/extension/download", async (c) => {
|
|||
const { readdir, readFile } = await import("fs/promises");
|
||||
const { join, resolve } = await import("path");
|
||||
|
||||
const extDir = resolve(import.meta.dir, "../../../rnotes-online/browser-extension");
|
||||
const extDir = resolve(import.meta.dir, "browser-extension");
|
||||
const zip = new JSZip();
|
||||
|
||||
async function addDir(dir: string, prefix: string) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue