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
};