feat: convert browser file captures
This commit is contained in:
parent
b46a973fd3
commit
95d16092d3
|
|
@ -23,6 +23,7 @@
|
||||||
'browser.capture.page',
|
'browser.capture.page',
|
||||||
'browser.capture.selection',
|
'browser.capture.selection',
|
||||||
'browser.capture.link',
|
'browser.capture.link',
|
||||||
|
'browser.capture.file',
|
||||||
'browser.capture.converted'
|
'browser.capture.converted'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@
|
||||||
"browser.capture.page",
|
"browser.capture.page",
|
||||||
"browser.capture.selection",
|
"browser.capture.selection",
|
||||||
"browser.capture.link",
|
"browser.capture.link",
|
||||||
|
"browser.capture.file",
|
||||||
"browser.capture.converted"
|
"browser.capture.converted"
|
||||||
],
|
],
|
||||||
"handler": "recordActivityEvent"
|
"handler": "recordActivityEvent"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var PLUGIN_ID = 'verstak.browser-inbox';
|
var PLUGIN_ID = 'verstak.browser-inbox';
|
||||||
var CAPTURE_EVENTS = ['browser.capture.page', 'browser.capture.selection', 'browser.capture.link'];
|
var CAPTURE_EVENTS = ['browser.capture.page', 'browser.capture.selection', 'browser.capture.link', 'browser.capture.file'];
|
||||||
var MAX_CAPTURES = 100;
|
var MAX_CAPTURES = 100;
|
||||||
var LEGACY_KEY = 'captures';
|
var LEGACY_KEY = 'captures';
|
||||||
var GLOBAL_KEY = 'captures:global';
|
var GLOBAL_KEY = 'captures:global';
|
||||||
|
|
@ -139,10 +139,11 @@
|
||||||
|
|
||||||
function cleanKind(value) {
|
function cleanKind(value) {
|
||||||
value = text(value).trim();
|
value = text(value).trim();
|
||||||
return value === 'selection' || value === 'link' || value === 'page' ? value : 'page';
|
return value === 'selection' || value === 'link' || value === 'file' || value === 'page' ? value : 'page';
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayTitle(capture) {
|
function displayTitle(capture) {
|
||||||
|
if (capture && capture.kind === 'file' && capture.fileName) return capture.fileName;
|
||||||
return capture.title || capture.url || capture.captureId || 'Untitled capture';
|
return capture.title || capture.url || capture.captureId || 'Untitled capture';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,6 +164,16 @@
|
||||||
return safeNoteFilename(title).replace(/\.md$/, '.url');
|
return safeNoteFilename(title).replace(/\.md$/, '.url');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeFileFilename(name) {
|
||||||
|
var base = text(name).trim()
|
||||||
|
.replace(/[\\/:*?"<>|\r\n\t]+/g, '_')
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
if (!base || base === '.' || base === '..') return 'browser-file.txt';
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
function captureToMarkdown(capture) {
|
function captureToMarkdown(capture) {
|
||||||
var title = noteTitle(capture);
|
var title = noteTitle(capture);
|
||||||
var lines = ['# ' + title, ''];
|
var lines = ['# ' + title, ''];
|
||||||
|
|
@ -201,6 +212,10 @@
|
||||||
title: text(payload.title).trim(),
|
title: text(payload.title).trim(),
|
||||||
domain: text(payload.domain).trim(),
|
domain: text(payload.domain).trim(),
|
||||||
text: text(payload.text).trim(),
|
text: text(payload.text).trim(),
|
||||||
|
fileName: text(payload.fileName).trim(),
|
||||||
|
fileMime: text(payload.fileMime).trim(),
|
||||||
|
fileSize: Number(payload.fileSize) || 0,
|
||||||
|
fileText: text(payload.fileText),
|
||||||
source: text(payload.source).trim(),
|
source: text(payload.source).trim(),
|
||||||
browserName: text(payload.browserName).trim(),
|
browserName: text(payload.browserName).trim(),
|
||||||
workspaceRootPath: workspaceFromPayload(payload) || (scope && scope.workspaceRoot) || ''
|
workspaceRootPath: workspaceFromPayload(payload) || (scope && scope.workspaceRoot) || ''
|
||||||
|
|
@ -221,6 +236,10 @@
|
||||||
title: text(item.title),
|
title: text(item.title),
|
||||||
domain: text(item.domain),
|
domain: text(item.domain),
|
||||||
text: text(item.text),
|
text: text(item.text),
|
||||||
|
fileName: text(item.fileName),
|
||||||
|
fileMime: text(item.fileMime),
|
||||||
|
fileSize: Number(item.fileSize) || 0,
|
||||||
|
fileText: text(item.fileText),
|
||||||
source: text(item.source),
|
source: text(item.source),
|
||||||
browserName: text(item.browserName),
|
browserName: text(item.browserName),
|
||||||
workspaceRootPath: cleanWorkspace(item.workspaceRootPath),
|
workspaceRootPath: cleanWorkspace(item.workspaceRootPath),
|
||||||
|
|
@ -241,6 +260,10 @@
|
||||||
title: item.title,
|
title: item.title,
|
||||||
domain: item.domain,
|
domain: item.domain,
|
||||||
text: item.text,
|
text: item.text,
|
||||||
|
fileName: item.fileName,
|
||||||
|
fileMime: item.fileMime,
|
||||||
|
fileSize: item.fileSize,
|
||||||
|
fileText: item.fileText,
|
||||||
source: item.source,
|
source: item.source,
|
||||||
browserName: item.browserName,
|
browserName: item.browserName,
|
||||||
workspaceRootPath: item.workspaceRootPath,
|
workspaceRootPath: item.workspaceRootPath,
|
||||||
|
|
@ -507,6 +530,49 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFileFromCapture(capture) {
|
||||||
|
if (!capture || !capture.workspaceRootPath || capture.kind !== 'file' || !capture.fileName || !capture.fileText) return Promise.resolve();
|
||||||
|
if (!api || !api.files || typeof api.files.writeText !== 'function') {
|
||||||
|
statusText = 'Could not create file: files API unavailable';
|
||||||
|
statusClass = 'error';
|
||||||
|
render();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
var fileName = safeFileFilename(capture.fileName);
|
||||||
|
var filePath = capture.workspaceRootPath + '/Files/' + fileName;
|
||||||
|
statusText = 'Creating file...';
|
||||||
|
statusClass = '';
|
||||||
|
render();
|
||||||
|
return api.files.writeText(filePath, capture.fileText, {
|
||||||
|
createIfMissing: true,
|
||||||
|
overwrite: false
|
||||||
|
}).then(function () {
|
||||||
|
if (api.events && typeof api.events.publish === 'function') {
|
||||||
|
return api.events.publish('browser.capture.converted', {
|
||||||
|
captureId: capture.captureId,
|
||||||
|
conversionType: 'file',
|
||||||
|
filePath: filePath,
|
||||||
|
workspaceRootPath: capture.workspaceRootPath,
|
||||||
|
title: displayTitle(capture),
|
||||||
|
url: capture.url || '',
|
||||||
|
fileName: capture.fileName || '',
|
||||||
|
fileMime: capture.fileMime || '',
|
||||||
|
fileSize: capture.fileSize || 0,
|
||||||
|
sourcePluginId: PLUGIN_ID
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}).then(function () {
|
||||||
|
statusText = 'Created file: ' + filePath;
|
||||||
|
statusClass = '';
|
||||||
|
return removeCapture(capture.captureId);
|
||||||
|
}).catch(function (err) {
|
||||||
|
statusText = 'Could not create file: ' + (err && err.message ? err.message : String(err));
|
||||||
|
statusClass = 'error';
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
if (captures.length === 0) {
|
if (captures.length === 0) {
|
||||||
|
|
@ -559,6 +625,9 @@
|
||||||
if (capture.text) {
|
if (capture.text) {
|
||||||
detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.text }));
|
detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.text }));
|
||||||
}
|
}
|
||||||
|
if (capture.kind === 'file' && capture.fileText) {
|
||||||
|
detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.fileText }));
|
||||||
|
}
|
||||||
var actionButtons = [];
|
var actionButtons = [];
|
||||||
if (capture.workspaceRootPath) {
|
if (capture.workspaceRootPath) {
|
||||||
actionButtons.push(el('button', {
|
actionButtons.push(el('button', {
|
||||||
|
|
@ -579,6 +648,16 @@
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
if (capture.kind === 'file' && capture.fileName && capture.fileText) {
|
||||||
|
actionButtons.push(el('button', {
|
||||||
|
className: 'browser-inbox-btn',
|
||||||
|
'data-browser-inbox-action': 'create-file',
|
||||||
|
textContent: 'Create File',
|
||||||
|
onClick: function () {
|
||||||
|
createFileFromCapture(capture);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
actionButtons.push(el('button', {
|
actionButtons.push(el('button', {
|
||||||
className: 'browser-inbox-btn danger',
|
className: 'browser-inbox-btn danger',
|
||||||
|
|
|
||||||
|
|
@ -177,9 +177,10 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
||||||
if (!worklogCommand || worklogCommand.handler !== WORKLOG_COMMAND_ID) throw new Error('activity worklog suggestion command contribution is missing');
|
if (!worklogCommand || worklogCommand.handler !== WORKLOG_COMMAND_ID) throw new Error('activity worklog suggestion command contribution is missing');
|
||||||
if (typeof api.commandHandlers.get(WORKLOG_COMMAND_ID) !== 'function') throw new Error('activity worklog suggestion command was not registered');
|
if (typeof api.commandHandlers.get(WORKLOG_COMMAND_ID) !== 'function') throw new Error('activity worklog suggestion command was not registered');
|
||||||
const activityProvider = (manifest.contributes.activityProviders || []).find((item) => item.id === 'verstak.activity.log');
|
const activityProvider = (manifest.contributes.activityProviders || []).find((item) => item.id === 'verstak.activity.log');
|
||||||
|
if (!activityProvider || !activityProvider.events.includes('browser.capture.file')) throw new Error('activity provider must include browser.capture.file');
|
||||||
if (!activityProvider || !activityProvider.events.includes('browser.capture.converted')) throw new Error('activity provider must include browser.capture.converted');
|
if (!activityProvider || !activityProvider.events.includes('browser.capture.converted')) throw new Error('activity provider must include browser.capture.converted');
|
||||||
|
|
||||||
for (const name of ['file.opened', 'file.changed', 'note.saved', 'action.started', 'browser.capture.received', 'case.selected', 'browser.capture.selection', 'browser.capture.converted']) {
|
for (const name of ['file.opened', 'file.changed', 'note.saved', 'action.started', 'browser.capture.received', 'case.selected', 'browser.capture.selection', 'browser.capture.file', 'browser.capture.converted']) {
|
||||||
if (typeof api.handlers[name] !== 'function') throw new Error(`${name} subscription missing`);
|
if (typeof api.handlers[name] !== 'function') throw new Error(`${name} subscription missing`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,7 +334,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
||||||
if (container.textContent.includes('Project work on 2026-06-27')) throw new Error('clear action did not remove worklog suggestions');
|
if (container.textContent.includes('Project work on 2026-06-27')) throw new Error('clear action did not remove worklog suggestions');
|
||||||
|
|
||||||
component.unmount && component.unmount(container);
|
component.unmount && component.unmount(container);
|
||||||
if (api.unsubscribed.length !== 30) throw new Error(`expected 30 unsubscribers, got ${api.unsubscribed.length}`);
|
if (api.unsubscribed.length !== 33) throw new Error(`expected 33 unsubscribers, got ${api.unsubscribed.length}`);
|
||||||
|
|
||||||
const persistedApi = makeApi({
|
const persistedApi = makeApi({
|
||||||
'events:workspace:Project': [{
|
'events:workspace:Project': [{
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
||||||
const api = makeApi();
|
const api = makeApi();
|
||||||
const { component, container } = await mountWithApi(api);
|
const { component, container } = await mountWithApi(api);
|
||||||
|
|
||||||
for (const name of ['browser.capture.page', 'browser.capture.selection', 'browser.capture.link']) {
|
for (const name of ['browser.capture.page', 'browser.capture.selection', 'browser.capture.link', 'browser.capture.file']) {
|
||||||
if (typeof api.handlers[name] !== 'function') throw new Error(`${name} subscription missing`);
|
if (typeof api.handlers[name] !== 'function') throw new Error(`${name} subscription missing`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,7 +287,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
||||||
if (api.getStoredCaptures(projectKey).length !== 0) throw new Error('clear action did not empty stored captures');
|
if (api.getStoredCaptures(projectKey).length !== 0) throw new Error('clear action did not empty stored captures');
|
||||||
|
|
||||||
component.unmount && component.unmount(container);
|
component.unmount && component.unmount(container);
|
||||||
if (api.unsubscribed.length !== 9) throw new Error('component did not unsubscribe all capture handlers');
|
if (api.unsubscribed.length !== 12) throw new Error('component did not unsubscribe all capture handlers');
|
||||||
|
|
||||||
const persistedApi = makeApi({ 'captures:workspace:Project': [captures[0]] });
|
const persistedApi = makeApi({ 'captures:workspace:Project': [captures[0]] });
|
||||||
const persisted = await mountWithApi(persistedApi);
|
const persisted = await mountWithApi(persistedApi);
|
||||||
|
|
@ -546,6 +546,78 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
||||||
}
|
}
|
||||||
component.unmount && component.unmount(failedLinkView.container);
|
component.unmount && component.unmount(failedLinkView.container);
|
||||||
|
|
||||||
|
const fileConversionApi = makeApi({
|
||||||
|
'captures:workspace:Project': [{
|
||||||
|
captureId: 'convert-file',
|
||||||
|
capturedAt: '2026-06-29T02:20:00.000Z',
|
||||||
|
kind: 'file',
|
||||||
|
url: 'https://example.com/files',
|
||||||
|
title: 'Example Files',
|
||||||
|
domain: 'example.com',
|
||||||
|
fileName: 'notes.txt',
|
||||||
|
fileMime: 'text/plain',
|
||||||
|
fileSize: 11,
|
||||||
|
fileText: 'hello file',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
workspaceName: 'Project',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const fileConversionView = await mountWithApi(fileConversionApi);
|
||||||
|
const createFileButton = walk(fileConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-file');
|
||||||
|
if (!createFileButton) throw new Error('create file button was not rendered');
|
||||||
|
createFileButton.click();
|
||||||
|
await flush();
|
||||||
|
if (fileConversionApi.fileWrites.length !== 1) throw new Error(`expected one file write, got ${fileConversionApi.fileWrites.length}`);
|
||||||
|
const fileWrite = fileConversionApi.fileWrites[0];
|
||||||
|
if (fileWrite.relativePath !== 'Project/Files/notes.txt') {
|
||||||
|
throw new Error(`file path mismatch: ${fileWrite.relativePath}`);
|
||||||
|
}
|
||||||
|
if (fileWrite.content !== 'hello file') throw new Error(`file content mismatch: ${fileWrite.content}`);
|
||||||
|
if (fileWrite.options.createIfMissing !== true || fileWrite.options.overwrite !== false) {
|
||||||
|
throw new Error(`file write options mismatch: ${JSON.stringify(fileWrite.options)}`);
|
||||||
|
}
|
||||||
|
if (fileConversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-file')) {
|
||||||
|
throw new Error('converted file capture was not removed from queue');
|
||||||
|
}
|
||||||
|
const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
|
||||||
|
if (!convertedFileEvent) throw new Error('browser.capture.converted file event was not published');
|
||||||
|
if (convertedFileEvent.payload.conversionType !== 'file') throw new Error('converted file event conversionType mismatch');
|
||||||
|
if (convertedFileEvent.payload.filePath !== 'Project/Files/notes.txt') throw new Error('converted file event filePath mismatch');
|
||||||
|
component.unmount && component.unmount(fileConversionView.container);
|
||||||
|
|
||||||
|
const failedFileApi = makeApi({
|
||||||
|
'captures:workspace:Project': [{
|
||||||
|
captureId: 'convert-file-conflict',
|
||||||
|
capturedAt: '2026-06-29T02:30:00.000Z',
|
||||||
|
kind: 'file',
|
||||||
|
url: 'https://example.com/files',
|
||||||
|
title: 'Example Files',
|
||||||
|
domain: 'example.com',
|
||||||
|
fileName: 'existing.txt',
|
||||||
|
fileMime: 'text/plain',
|
||||||
|
fileSize: 12,
|
||||||
|
fileText: 'existing file',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
workspaceName: 'Project',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const failedFileView = await mountWithApi(failedFileApi);
|
||||||
|
const failedCreateFileButton = walk(failedFileView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-file');
|
||||||
|
if (!failedCreateFileButton) throw new Error('create file button for failed conversion was not rendered');
|
||||||
|
failedFileApi.failNextWrite('file already exists');
|
||||||
|
failedCreateFileButton.click();
|
||||||
|
await flush();
|
||||||
|
if (!failedFileApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-file-conflict')) {
|
||||||
|
throw new Error('failed file conversion removed capture from queue');
|
||||||
|
}
|
||||||
|
if (!failedFileView.container.textContent.includes('Could not create file')) {
|
||||||
|
throw new Error('failed file conversion did not render an error status');
|
||||||
|
}
|
||||||
|
if (failedFileApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
|
||||||
|
throw new Error('failed file conversion published converted event');
|
||||||
|
}
|
||||||
|
component.unmount && component.unmount(failedFileView.container);
|
||||||
|
|
||||||
console.log('browser inbox plugin smoke passed');
|
console.log('browser inbox plugin smoke passed');
|
||||||
})().catch((err) => {
|
})().catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue