feat: capture text files for browser inbox

This commit is contained in:
mirivlad 2026-06-29 09:57:21 +08:00
parent f0b66c05ff
commit 8970333d13
9 changed files with 89 additions and 8 deletions

View File

@ -1,7 +1,7 @@
# verstak-browser-extension # verstak-browser-extension
Verstak Browser Extension captures pages, selected text, and links and sends Verstak Browser Extension captures pages, selected text, links, and selected
them to a local Verstak browser inbox receiver. text files and sends them to a local Verstak browser inbox receiver.
The extension does not know Notes, Files, Activity, or Journal internals. It The extension does not know Notes, Files, Activity, or Journal internals. It
only sends capture events through the public local receiver protocol. If the only sends capture events through the public local receiver protocol. If the
@ -43,8 +43,8 @@ release artifacts are local outputs and are not committed.
1. Start Verstak desktop with the `verstak.browser-inbox` plugin installed. 1. Start Verstak desktop with the `verstak.browser-inbox` plugin installed.
2. Open the `Browser Inbox` workspace item so it subscribes to capture events. 2. Open the `Browser Inbox` workspace item so it subscribes to capture events.
3. Install/load `dist/firefox` or the signed XPI in Firefox. 3. Install/load `dist/firefox` or the signed XPI in Firefox.
4. Use the popup `Send Page` action, or use page context menu actions for 4. Use the popup `Send Page` or `Send File` actions, or use page context menu
selection/link captures. actions for selection/link captures.
## Local Receiver Protocol ## Local Receiver Protocol
@ -84,6 +84,7 @@ Supported `kind` values:
- `page` - `page`
- `selection`, with `selection.text` - `selection`, with `selection.text`
- `link`, with `link.url` and optional `link.text` - `link`, with `link.url` and optional `link.text`
- `file`, with `file.name` and `file.text` for selected text files up to 2 MB
Expected success response: Expected success response:

View File

@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Verstak Bridge", "name": "Verstak Bridge",
"version": "2.0.1", "version": "2.0.1",
"description": "Send pages, selections, and links to the local Verstak browser inbox.", "description": "Send pages, selections, links, and text files to the local Verstak browser inbox.",
"author": "Verstak", "author": "Verstak",
"homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension", "homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension",
"permissions": ["contextMenus", "storage", "tabs"], "permissions": ["contextMenus", "storage", "tabs"],

View File

@ -2,7 +2,7 @@
"manifest_version": 2, "manifest_version": 2,
"name": "Verstak Bridge", "name": "Verstak Bridge",
"version": "2.0.1", "version": "2.0.1",
"description": "Send pages, selections, and links to the local Verstak browser inbox.", "description": "Send pages, selections, links, and text files to the local Verstak browser inbox.",
"author": "Verstak", "author": "Verstak",
"homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension", "homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension",
"browser_specific_settings": { "browser_specific_settings": {

View File

@ -25,7 +25,23 @@ const selection = protocol.buildCapture({
assert.equal(selection.selection.text, 'selected text'); assert.equal(selection.selection.text, 'selected text');
assert.equal(protocol.validateCapture(selection), true); assert.equal(protocol.validateCapture(selection), true);
const file = protocol.buildCapture({
kind: 'file',
url: 'https://example.com/docs',
title: 'Example Docs',
fileName: 'notes.txt',
fileMime: 'text/plain',
fileSize: 12,
fileText: 'hello file'
});
assert.equal(file.file.name, 'notes.txt');
assert.equal(file.file.mime, 'text/plain');
assert.equal(file.file.size, 12);
assert.equal(file.file.text, 'hello file');
assert.equal(protocol.validateCapture(file), true);
assert.throws(() => protocol.validateCapture({ schemaVersion: 1, kind: 'link', captureId: 'x', capturedAt: 'now', page: { url: 'https://example.com' } }), /link.url/); assert.throws(() => protocol.validateCapture({ schemaVersion: 1, kind: 'link', captureId: 'x', capturedAt: 'now', page: { url: 'https://example.com' } }), /link.url/);
assert.throws(() => protocol.validateCapture({ schemaVersion: 1, kind: 'file', captureId: 'x', capturedAt: 'now', page: { url: 'https://example.com' }, file: { name: 'notes.txt' } }), /file.text/);
let request; let request;
const fetchOk = (url, options) => { const fetchOk = (url, options) => {

View File

@ -46,7 +46,11 @@
title: tab && tab.title || '', title: tab && tab.title || '',
selectionText: info.selectionText || '', selectionText: info.selectionText || '',
linkUrl: info.linkUrl || '', linkUrl: info.linkUrl || '',
linkText: info.selectionText || '' linkText: info.selectionText || '',
fileName: info.fileName || '',
fileMime: info.fileMime || '',
fileSize: info.fileSize || 0,
fileText: info.fileText || ''
}); });
} }

View File

@ -50,6 +50,7 @@ h1 {
} }
.panel, .panel,
.file-picker,
.settings { .settings {
border: 1px solid #273244; border: 1px solid #273244;
border-radius: 6px; border-radius: 6px;
@ -66,6 +67,7 @@ h1 {
} }
.row span, .row span,
.file-picker label,
.settings label, .settings label,
.hint { .hint {
color: #94a3b8; color: #94a3b8;
@ -124,6 +126,11 @@ input {
color: #e5e7eb; color: #e5e7eb;
} }
input[type="file"] {
cursor: pointer;
font-size: 12px;
}
.hint { .hint {
margin: 0; margin: 0;
font-size: 12px; font-size: 12px;

View File

@ -32,9 +32,15 @@
<section class="actions"> <section class="actions">
<button id="capture-page">Send Page</button> <button id="capture-page">Send Page</button>
<button id="capture-file">Send File</button>
<button id="retry">Retry Pending</button> <button id="retry">Retry Pending</button>
</section> </section>
<section class="file-picker">
<label for="file-input">Text file</label>
<input id="file-input" type="file">
</section>
<section class="settings"> <section class="settings">
<label for="receiver-input">Receiver URL</label> <label for="receiver-input">Receiver URL</label>
<input id="receiver-input" type="url" spellcheck="false"> <input id="receiver-input" type="url" spellcheck="false">

View File

@ -6,8 +6,10 @@
var receiverStateEl = document.getElementById('receiver-state'); var receiverStateEl = document.getElementById('receiver-state');
var receiverUrlEl = document.getElementById('receiver-url'); var receiverUrlEl = document.getElementById('receiver-url');
var receiverInputEl = document.getElementById('receiver-input'); var receiverInputEl = document.getElementById('receiver-input');
var fileInputEl = document.getElementById('file-input');
var pendingCountEl = document.getElementById('pending-count'); var pendingCountEl = document.getElementById('pending-count');
var statusDotEl = document.getElementById('status-dot'); var statusDotEl = document.getElementById('status-dot');
var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024;
function setStatus(text) { function setStatus(text) {
statusEl.textContent = text; statusEl.textContent = text;
@ -67,6 +69,31 @@
send({ type: 'verstak.capture', kind: 'page' }); send({ type: 'verstak.capture', kind: 'page' });
}); });
document.getElementById('capture-file').addEventListener('click', function () {
var file = fileInputEl.files && fileInputEl.files[0];
if (!file) {
setStatus('Choose a text file first');
return;
}
if (file.size > MAX_FILE_TEXT_LENGTH) {
setStatus('File is too large for text capture');
return;
}
setStatus('Reading file...');
file.text().then(function (content) {
send({
type: 'verstak.capture',
kind: 'file',
fileName: file.name,
fileMime: file.type || 'text/plain',
fileSize: file.size,
fileText: content
});
}).catch(function (err) {
setStatus(err && err.message ? err.message : String(err));
});
});
document.getElementById('retry').addEventListener('click', function () { document.getElementById('retry').addEventListener('click', function () {
send({ type: 'verstak.capture', action: 'retryPending' }); send({ type: 'verstak.capture', action: 'retryPending' });
}); });

View File

@ -3,6 +3,7 @@
var CAPTURE_SCHEMA_VERSION = 1; var CAPTURE_SCHEMA_VERSION = 1;
var DEFAULT_RECEIVER_URL = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'; var DEFAULT_RECEIVER_URL = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures';
var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024;
function nowIso() { function nowIso() {
return new Date().toISOString(); return new Date().toISOString();
@ -20,6 +21,12 @@
return text; return text;
} }
function cleanTextContent(value, maxLength) {
var text = String(value == null ? '' : value);
if (maxLength && text.length > maxLength) return text.slice(0, maxLength);
return text;
}
function hostname(url) { function hostname(url) {
try { try {
return new URL(url).hostname; return new URL(url).hostname;
@ -59,6 +66,16 @@
text: cleanString(input.linkText || input.selectionText || '', 512) text: cleanString(input.linkText || input.selectionText || '', 512)
}; };
} }
if (kind === 'file') {
var fileText = cleanTextContent(input.fileText || '', MAX_FILE_TEXT_LENGTH);
var fileSize = Number(input.fileSize);
payload.file = {
name: cleanString(input.fileName || '', 255),
mime: cleanString(input.fileMime || '', 128),
size: Number.isFinite(fileSize) && fileSize >= 0 ? fileSize : fileText.length,
text: fileText
};
}
if (input.context) payload.context = input.context; if (input.context) payload.context = input.context;
return payload; return payload;
} }
@ -68,16 +85,19 @@
if (payload.schemaVersion !== CAPTURE_SCHEMA_VERSION) throw new Error('unsupported schemaVersion'); if (payload.schemaVersion !== CAPTURE_SCHEMA_VERSION) throw new Error('unsupported schemaVersion');
if (!payload.captureId) throw new Error('captureId is required'); if (!payload.captureId) throw new Error('captureId is required');
if (!payload.capturedAt) throw new Error('capturedAt is required'); if (!payload.capturedAt) throw new Error('capturedAt is required');
if (['page', 'selection', 'link'].indexOf(payload.kind) === -1) throw new Error('unsupported kind'); if (['page', 'selection', 'link', 'file'].indexOf(payload.kind) === -1) throw new Error('unsupported kind');
if (!payload.page || !payload.page.url) throw new Error('page.url is required'); if (!payload.page || !payload.page.url) throw new Error('page.url is required');
if (payload.kind === 'selection' && (!payload.selection || !payload.selection.text)) throw new Error('selection.text is required'); if (payload.kind === 'selection' && (!payload.selection || !payload.selection.text)) throw new Error('selection.text is required');
if (payload.kind === 'link' && (!payload.link || !payload.link.url)) throw new Error('link.url is required'); if (payload.kind === 'link' && (!payload.link || !payload.link.url)) throw new Error('link.url is required');
if (payload.kind === 'file' && (!payload.file || !payload.file.name)) throw new Error('file.name is required');
if (payload.kind === 'file' && (!payload.file || !payload.file.text)) throw new Error('file.text is required');
return true; return true;
} }
var api = { var api = {
CAPTURE_SCHEMA_VERSION: CAPTURE_SCHEMA_VERSION, CAPTURE_SCHEMA_VERSION: CAPTURE_SCHEMA_VERSION,
DEFAULT_RECEIVER_URL: DEFAULT_RECEIVER_URL, DEFAULT_RECEIVER_URL: DEFAULT_RECEIVER_URL,
MAX_FILE_TEXT_LENGTH: MAX_FILE_TEXT_LENGTH,
buildCapture: buildCapture, buildCapture: buildCapture,
validateCapture: validateCapture validateCapture: validateCapture
}; };