diff --git a/plugins/browser-inbox/frontend/src/index.js b/plugins/browser-inbox/frontend/src/index.js index f68c7b7..39f6ada 100644 --- a/plugins/browser-inbox/frontend/src/index.js +++ b/plugins/browser-inbox/frontend/src/index.js @@ -159,6 +159,10 @@ return (base || 'Browser_Capture') + '.md'; } + function safeLinkFilename(title) { + return safeNoteFilename(title).replace(/\.md$/, '.url'); + } + function captureToMarkdown(capture) { var title = noteTitle(capture); var lines = ['# ' + title, '']; @@ -173,6 +177,10 @@ return lines.join('\n'); } + function captureToUrlShortcut(capture) { + return '[InternetShortcut]\nURL=' + text(capture && capture.url).trim() + '\n'; + } + function eventPayload(event) { if (!event || !event.payload) return {}; return event.payload; @@ -459,6 +467,46 @@ }); } + function createLinkFromCapture(capture) { + if (!capture || !capture.workspaceRootPath || !capture.url) return Promise.resolve(); + if (!api || !api.files || typeof api.files.writeText !== 'function') { + statusText = 'Could not create link: files API unavailable'; + statusClass = 'error'; + render(); + return Promise.resolve(); + } + var title = noteTitle(capture); + var linkPath = capture.workspaceRootPath + '/Links/' + safeLinkFilename(title); + statusText = 'Creating link...'; + statusClass = ''; + render(); + return api.files.writeText(linkPath, captureToUrlShortcut(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: 'link', + linkPath: linkPath, + workspaceRootPath: capture.workspaceRootPath, + title: title, + url: capture.url || '', + sourcePluginId: PLUGIN_ID + }); + } + return undefined; + }).then(function () { + statusText = 'Created link: ' + linkPath; + statusClass = ''; + return removeCapture(capture.captureId); + }).catch(function (err) { + statusText = 'Could not create link: ' + (err && err.message ? err.message : String(err)); + statusClass = 'error'; + render(); + }); + } + function renderList() { listEl.innerHTML = ''; if (captures.length === 0) { @@ -521,6 +569,16 @@ createNoteFromCapture(capture); } })); + if (capture.url) { + actionButtons.push(el('button', { + className: 'browser-inbox-btn', + 'data-browser-inbox-action': 'create-link', + textContent: 'Create Link', + onClick: function () { + createLinkFromCapture(capture); + } + })); + } } actionButtons.push(el('button', { className: 'browser-inbox-btn danger', diff --git a/scripts/smoke-browser-inbox-plugin.js b/scripts/smoke-browser-inbox-plugin.js index 26741fb..6258494 100644 --- a/scripts/smoke-browser-inbox-plugin.js +++ b/scripts/smoke-browser-inbox-plugin.js @@ -481,6 +481,71 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w } component.unmount && component.unmount(failedConversionView.container); + const linkConversionApi = makeApi({ + 'captures:workspace:Project': [{ + captureId: 'convert-link', + capturedAt: '2026-06-29T01:20:00.000Z', + kind: 'link', + url: 'https://example.com/article', + title: 'Example Article', + domain: 'example.com', + workspaceRootPath: 'Project', + workspaceName: 'Project', + }], + }); + const linkConversionView = await mountWithApi(linkConversionApi); + const createLinkButton = walk(linkConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-link'); + if (!createLinkButton) throw new Error('create link button was not rendered'); + createLinkButton.click(); + await flush(); + if (linkConversionApi.fileWrites.length !== 1) throw new Error(`expected one link write, got ${linkConversionApi.fileWrites.length}`); + const linkWrite = linkConversionApi.fileWrites[0]; + if (linkWrite.relativePath !== 'Project/Links/Example_Article.url') { + throw new Error(`link path mismatch: ${linkWrite.relativePath}`); + } + if (linkWrite.options.createIfMissing !== true || linkWrite.options.overwrite !== false) { + throw new Error(`link write options mismatch: ${JSON.stringify(linkWrite.options)}`); + } + if (!linkWrite.content.includes('[InternetShortcut]')) throw new Error('link content missing InternetShortcut header'); + if (!linkWrite.content.includes('URL=https://example.com/article')) throw new Error('link content missing URL'); + if (linkConversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-link')) { + throw new Error('converted link capture was not removed from queue'); + } + const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); + if (!convertedLinkEvent) throw new Error('browser.capture.converted link event was not published'); + if (convertedLinkEvent.payload.conversionType !== 'link') throw new Error('converted link event conversionType mismatch'); + if (convertedLinkEvent.payload.linkPath !== 'Project/Links/Example_Article.url') throw new Error('converted link event linkPath mismatch'); + component.unmount && component.unmount(linkConversionView.container); + + const failedLinkApi = makeApi({ + 'captures:workspace:Project': [{ + captureId: 'convert-link-conflict', + capturedAt: '2026-06-29T01:30:00.000Z', + kind: 'link', + url: 'https://example.com/existing-link', + title: 'Existing Link', + domain: 'example.com', + workspaceRootPath: 'Project', + workspaceName: 'Project', + }], + }); + const failedLinkView = await mountWithApi(failedLinkApi); + const failedCreateLinkButton = walk(failedLinkView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-link'); + if (!failedCreateLinkButton) throw new Error('create link button for failed conversion was not rendered'); + failedLinkApi.failNextWrite('link already exists'); + failedCreateLinkButton.click(); + await flush(); + if (!failedLinkApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-link-conflict')) { + throw new Error('failed link conversion removed capture from queue'); + } + if (!failedLinkView.container.textContent.includes('Could not create link')) { + throw new Error('failed link conversion did not render an error status'); + } + if (failedLinkApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) { + throw new Error('failed link conversion published converted event'); + } + component.unmount && component.unmount(failedLinkView.container); + console.log('browser inbox plugin smoke passed'); })().catch((err) => { console.error(err);