diff --git a/plugins/activity/frontend/src/index.js b/plugins/activity/frontend/src/index.js index 07ff98e..e03ad32 100644 --- a/plugins/activity/frontend/src/index.js +++ b/plugins/activity/frontend/src/index.js @@ -23,6 +23,7 @@ 'browser.capture.page', 'browser.capture.selection', 'browser.capture.link', + 'browser.capture.file', 'browser.capture.converted' ]; diff --git a/plugins/activity/plugin.json b/plugins/activity/plugin.json index da369e8..724ce86 100644 --- a/plugins/activity/plugin.json +++ b/plugins/activity/plugin.json @@ -71,6 +71,7 @@ "browser.capture.page", "browser.capture.selection", "browser.capture.link", + "browser.capture.file", "browser.capture.converted" ], "handler": "recordActivityEvent" diff --git a/plugins/browser-inbox/frontend/src/index.js b/plugins/browser-inbox/frontend/src/index.js index 39f6ada..4e58fd6 100644 --- a/plugins/browser-inbox/frontend/src/index.js +++ b/plugins/browser-inbox/frontend/src/index.js @@ -7,7 +7,7 @@ 'use strict'; 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 LEGACY_KEY = 'captures'; var GLOBAL_KEY = 'captures:global'; @@ -139,10 +139,11 @@ function cleanKind(value) { 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) { + if (capture && capture.kind === 'file' && capture.fileName) return capture.fileName; return capture.title || capture.url || capture.captureId || 'Untitled capture'; } @@ -163,6 +164,16 @@ 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) { var title = noteTitle(capture); var lines = ['# ' + title, '']; @@ -201,6 +212,10 @@ title: text(payload.title).trim(), domain: text(payload.domain).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(), browserName: text(payload.browserName).trim(), workspaceRootPath: workspaceFromPayload(payload) || (scope && scope.workspaceRoot) || '' @@ -221,6 +236,10 @@ title: text(item.title), domain: text(item.domain), 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), browserName: text(item.browserName), workspaceRootPath: cleanWorkspace(item.workspaceRootPath), @@ -241,6 +260,10 @@ title: item.title, domain: item.domain, text: item.text, + fileName: item.fileName, + fileMime: item.fileMime, + fileSize: item.fileSize, + fileText: item.fileText, source: item.source, browserName: item.browserName, 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() { listEl.innerHTML = ''; if (captures.length === 0) { @@ -559,6 +625,9 @@ if (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 = []; if (capture.workspaceRootPath) { 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', { className: 'browser-inbox-btn danger', diff --git a/scripts/smoke-activity-plugin.js b/scripts/smoke-activity-plugin.js index 5f6339c..3c69a0b 100644 --- a/scripts/smoke-activity-plugin.js +++ b/scripts/smoke-activity-plugin.js @@ -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 (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'); + 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'); - 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`); } @@ -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'); 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({ 'events:workspace:Project': [{ diff --git a/scripts/smoke-browser-inbox-plugin.js b/scripts/smoke-browser-inbox-plugin.js index 6258494..8e2f09b 100644 --- a/scripts/smoke-browser-inbox-plugin.js +++ b/scripts/smoke-browser-inbox-plugin.js @@ -197,7 +197,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w const api = makeApi(); 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`); } @@ -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'); 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 persisted = await mountWithApi(persistedApi); @@ -546,6 +546,78 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w } 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'); })().catch((err) => { console.error(err);