From 8970333d13c2f39ecd65a0a88ec90f23ee1ebffe Mon Sep 17 00:00:00 2001 From: mirivlad Date: Mon, 29 Jun 2026 09:57:21 +0800 Subject: [PATCH] feat: capture text files for browser inbox --- README.md | 9 +++++---- chromium/manifest.json | 2 +- firefox/manifest.json | 2 +- scripts/test-protocol.js | 16 ++++++++++++++++ shared/background.js | 6 +++++- shared/popup/popup.css | 7 +++++++ shared/popup/popup.html | 6 ++++++ shared/popup/popup.js | 27 +++++++++++++++++++++++++++ shared/protocol.js | 22 +++++++++++++++++++++- 9 files changed, 89 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a4d5672..6e4a6b1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # verstak-browser-extension -Verstak Browser Extension captures pages, selected text, and links and sends -them to a local Verstak browser inbox receiver. +Verstak Browser Extension captures pages, selected text, links, and selected +text files and sends them to a local Verstak browser inbox receiver. The extension does not know Notes, Files, Activity, or Journal internals. It 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. 2. Open the `Browser Inbox` workspace item so it subscribes to capture events. 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 - selection/link captures. +4. Use the popup `Send Page` or `Send File` actions, or use page context menu + actions for selection/link captures. ## Local Receiver Protocol @@ -84,6 +84,7 @@ Supported `kind` values: - `page` - `selection`, with `selection.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: diff --git a/chromium/manifest.json b/chromium/manifest.json index 9f25a80..e67d814 100644 --- a/chromium/manifest.json +++ b/chromium/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Verstak Bridge", "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", "homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension", "permissions": ["contextMenus", "storage", "tabs"], diff --git a/firefox/manifest.json b/firefox/manifest.json index 6114045..550f9bd 100644 --- a/firefox/manifest.json +++ b/firefox/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "Verstak Bridge", "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", "homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension", "browser_specific_settings": { diff --git a/scripts/test-protocol.js b/scripts/test-protocol.js index 74e1c52..f278238 100755 --- a/scripts/test-protocol.js +++ b/scripts/test-protocol.js @@ -25,7 +25,23 @@ const selection = protocol.buildCapture({ assert.equal(selection.selection.text, 'selected text'); 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: 'file', captureId: 'x', capturedAt: 'now', page: { url: 'https://example.com' }, file: { name: 'notes.txt' } }), /file.text/); let request; const fetchOk = (url, options) => { diff --git a/shared/background.js b/shared/background.js index 6d372ee..6972a94 100644 --- a/shared/background.js +++ b/shared/background.js @@ -46,7 +46,11 @@ title: tab && tab.title || '', selectionText: info.selectionText || '', linkUrl: info.linkUrl || '', - linkText: info.selectionText || '' + linkText: info.selectionText || '', + fileName: info.fileName || '', + fileMime: info.fileMime || '', + fileSize: info.fileSize || 0, + fileText: info.fileText || '' }); } diff --git a/shared/popup/popup.css b/shared/popup/popup.css index 9ee3a86..b635990 100644 --- a/shared/popup/popup.css +++ b/shared/popup/popup.css @@ -50,6 +50,7 @@ h1 { } .panel, +.file-picker, .settings { border: 1px solid #273244; border-radius: 6px; @@ -66,6 +67,7 @@ h1 { } .row span, +.file-picker label, .settings label, .hint { color: #94a3b8; @@ -124,6 +126,11 @@ input { color: #e5e7eb; } +input[type="file"] { + cursor: pointer; + font-size: 12px; +} + .hint { margin: 0; font-size: 12px; diff --git a/shared/popup/popup.html b/shared/popup/popup.html index 5f6c6ef..7c5701b 100644 --- a/shared/popup/popup.html +++ b/shared/popup/popup.html @@ -32,9 +32,15 @@
+
+
+ + +
+
diff --git a/shared/popup/popup.js b/shared/popup/popup.js index f888501..e666b34 100644 --- a/shared/popup/popup.js +++ b/shared/popup/popup.js @@ -6,8 +6,10 @@ var receiverStateEl = document.getElementById('receiver-state'); var receiverUrlEl = document.getElementById('receiver-url'); var receiverInputEl = document.getElementById('receiver-input'); + var fileInputEl = document.getElementById('file-input'); var pendingCountEl = document.getElementById('pending-count'); var statusDotEl = document.getElementById('status-dot'); + var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024; function setStatus(text) { statusEl.textContent = text; @@ -67,6 +69,31 @@ 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 () { send({ type: 'verstak.capture', action: 'retryPending' }); }); diff --git a/shared/protocol.js b/shared/protocol.js index 9887ed0..98cd2d2 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -3,6 +3,7 @@ var CAPTURE_SCHEMA_VERSION = 1; 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() { return new Date().toISOString(); @@ -20,6 +21,12 @@ 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) { try { return new URL(url).hostname; @@ -59,6 +66,16 @@ 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; return payload; } @@ -68,16 +85,19 @@ if (payload.schemaVersion !== CAPTURE_SCHEMA_VERSION) throw new Error('unsupported schemaVersion'); if (!payload.captureId) throw new Error('captureId 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.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 === '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; } var api = { CAPTURE_SCHEMA_VERSION: CAPTURE_SCHEMA_VERSION, DEFAULT_RECEIVER_URL: DEFAULT_RECEIVER_URL, + MAX_FILE_TEXT_LENGTH: MAX_FILE_TEXT_LENGTH, buildCapture: buildCapture, validateCapture: validateCapture };