feat: capture text files for browser inbox
This commit is contained in:
parent
f0b66c05ff
commit
8970333d13
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 || ''
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue