diff --git a/plugins/browser-inbox/frontend/src/index.js b/plugins/browser-inbox/frontend/src/index.js index e0039de..f68c7b7 100644 --- a/plugins/browser-inbox/frontend/src/index.js +++ b/plugins/browser-inbox/frontend/src/index.js @@ -146,6 +146,33 @@ return capture.title || capture.url || capture.captureId || 'Untitled capture'; } + function noteTitle(capture) { + return text((capture && (capture.title || capture.domain || capture.captureId)) || 'Browser Capture').trim() || 'Browser Capture'; + } + + function safeNoteFilename(title) { + var base = text(title).trim() + .replace(/[\\/:*?"<>|#\[\]\r\n\t]+/g, '_') + .replace(/\s+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, ''); + return (base || 'Browser_Capture') + '.md'; + } + + function captureToMarkdown(capture) { + var title = noteTitle(capture); + var lines = ['# ' + title, '']; + if (capture && capture.url) lines.push('Source: ' + capture.url); + if (capture && capture.capturedAt) lines.push('Captured: ' + capture.capturedAt); + if (capture && capture.kind) lines.push('Kind: ' + capture.kind); + if (lines[lines.length - 1] !== '') lines.push(''); + if (capture && capture.text) { + lines.push(capture.text); + lines.push(''); + } + return lines.join('\n'); + } + function eventPayload(event) { if (!event || !event.payload) return {}; return event.payload; @@ -392,6 +419,46 @@ }).then(render); } + function createNoteFromCapture(capture) { + if (!capture || !capture.workspaceRootPath) return Promise.resolve(); + if (!api || !api.files || typeof api.files.writeText !== 'function') { + statusText = 'Could not create note: files API unavailable'; + statusClass = 'error'; + render(); + return Promise.resolve(); + } + var title = noteTitle(capture); + var notePath = capture.workspaceRootPath + '/Notes/' + safeNoteFilename(title); + statusText = 'Creating note...'; + statusClass = ''; + render(); + return api.files.writeText(notePath, captureToMarkdown(capture), { + 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: 'note', + notePath: notePath, + workspaceRootPath: capture.workspaceRootPath, + title: title, + url: capture.url || '', + sourcePluginId: PLUGIN_ID + }); + } + return undefined; + }).then(function () { + statusText = 'Created note: ' + notePath; + statusClass = ''; + return removeCapture(capture.captureId); + }).catch(function (err) { + statusText = 'Could not create note: ' + (err && err.message ? err.message : String(err)); + statusClass = 'error'; + render(); + }); + } + function renderList() { listEl.innerHTML = ''; if (captures.length === 0) { @@ -444,16 +511,26 @@ if (capture.text) { detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.text })); } - detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, [ - el('button', { + var actionButtons = []; + if (capture.workspaceRootPath) { + actionButtons.push(el('button', { + className: 'browser-inbox-btn', + 'data-browser-inbox-action': 'create-note', + textContent: 'Create Note', + onClick: function () { + createNoteFromCapture(capture); + } + })); + } + actionButtons.push(el('button', { className: 'browser-inbox-btn danger', 'data-browser-inbox-action': 'remove', textContent: 'Remove', onClick: function () { removeCapture(capture.captureId); } - }) - ])); + })); + detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, actionButtons)); } function render() { diff --git a/plugins/browser-inbox/plugin.json b/plugins/browser-inbox/plugin.json index 695abe3..9a618c7 100644 --- a/plugins/browser-inbox/plugin.json +++ b/plugins/browser-inbox/plugin.json @@ -8,10 +8,14 @@ "source": "official", "icon": "inbox", "provides": [ - "browser.inbox" + "capture.browser", + "browser.inbox", + "domain.binding" ], "permissions": [ "events.subscribe", + "events.publish", + "files.write", "storage.namespace", "ui.register" ], diff --git a/scripts/smoke-browser-inbox-plugin.js b/scripts/smoke-browser-inbox-plugin.js index e960486..26741fb 100644 --- a/scripts/smoke-browser-inbox-plugin.js +++ b/scripts/smoke-browser-inbox-plugin.js @@ -134,11 +134,22 @@ function makeApi(initialSettings = {}) { const settings = { ...initialSettings }; const handlers = {}; const unsubscribed = []; + const fileWrites = []; + const publishedEvents = []; + let nextWriteError = null; return { settings, handlers, unsubscribed, + fileWrites, + publishedEvents, + failNextWrite(message) { + nextWriteError = new Error(message || 'write failed'); + }, events: { + publish: async (name, payload) => { + publishedEvents.push({ name, payload }); + }, subscribe: async (name, handler) => { handlers[name] = handler; return () => { @@ -154,6 +165,16 @@ function makeApi(initialSettings = {}) { return { ...settings }; }, }, + files: { + writeText: async (relativePath, content, options = {}) => { + if (nextWriteError) { + const err = nextWriteError; + nextWriteError = null; + throw err; + } + fileWrites.push({ relativePath, content, options }); + }, + }, getStoredCaptures(key = 'captures') { return settings[key] || []; }, @@ -393,6 +414,73 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w component.unmount && component.unmount(bindingClient.container); component.unmount && component.unmount(bindingAggregate.container); + const conversionApi = makeApi({ + 'captures:workspace:Project': [{ + captureId: 'convert-selection', + capturedAt: '2026-06-29T01:00:00.000Z', + kind: 'selection', + url: 'https://example.com/article', + title: 'Example Article', + domain: 'example.com', + text: 'Selected text from the page', + workspaceRootPath: 'Project', + workspaceName: 'Project', + }], + }); + const conversionView = await mountWithApi(conversionApi); + const createNoteButton = walk(conversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-note'); + if (!createNoteButton) throw new Error('create note button was not rendered'); + createNoteButton.click(); + await flush(); + if (conversionApi.fileWrites.length !== 1) throw new Error(`expected one note write, got ${conversionApi.fileWrites.length}`); + const noteWrite = conversionApi.fileWrites[0]; + if (noteWrite.relativePath !== 'Project/Notes/Example_Article.md') { + throw new Error(`note path mismatch: ${noteWrite.relativePath}`); + } + if (noteWrite.options.createIfMissing !== true || noteWrite.options.overwrite !== false) { + throw new Error(`note write options mismatch: ${JSON.stringify(noteWrite.options)}`); + } + if (!noteWrite.content.includes('# Example Article')) throw new Error('note content missing heading'); + if (!noteWrite.content.includes('Source: https://example.com/article')) throw new Error('note content missing source URL'); + if (!noteWrite.content.includes('Selected text from the page')) throw new Error('note content missing selected text'); + if (conversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-selection')) { + throw new Error('converted capture was not removed from queue'); + } + const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); + if (!convertedEvent) throw new Error('browser.capture.converted event was not published'); + if (convertedEvent.payload.conversionType !== 'note') throw new Error('converted event conversionType mismatch'); + if (convertedEvent.payload.notePath !== 'Project/Notes/Example_Article.md') throw new Error('converted event notePath mismatch'); + component.unmount && component.unmount(conversionView.container); + + const failedConversionApi = makeApi({ + 'captures:workspace:Project': [{ + captureId: 'convert-conflict', + capturedAt: '2026-06-29T01:10:00.000Z', + kind: 'page', + url: 'https://example.com/existing', + title: 'Existing Article', + domain: 'example.com', + workspaceRootPath: 'Project', + workspaceName: 'Project', + }], + }); + const failedConversionView = await mountWithApi(failedConversionApi); + const failedCreateNoteButton = walk(failedConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-note'); + if (!failedCreateNoteButton) throw new Error('create note button for failed conversion was not rendered'); + failedConversionApi.failNextWrite('file already exists'); + failedCreateNoteButton.click(); + await flush(); + if (!failedConversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-conflict')) { + throw new Error('failed conversion removed capture from queue'); + } + if (!failedConversionView.container.textContent.includes('Could not create note')) { + throw new Error('failed conversion did not render an error status'); + } + if (failedConversionApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) { + throw new Error('failed conversion published converted event'); + } + component.unmount && component.unmount(failedConversionView.container); + console.log('browser inbox plugin smoke passed'); })().catch((err) => { console.error(err);