From 50c4fc5e687c35c3e7cf9d6e90fc7996f9a43aa8 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sun, 12 Jul 2026 20:39:09 +0800 Subject: [PATCH] feat: open browser links and resolve filename collisions --- plugins/browser-inbox/frontend/src/index.js | 51 ++++++++++++++++++--- scripts/smoke-browser-inbox-plugin.js | 29 ++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/plugins/browser-inbox/frontend/src/index.js b/plugins/browser-inbox/frontend/src/index.js index b642bfb..423a773 100644 --- a/plugins/browser-inbox/frontend/src/index.js +++ b/plugins/browser-inbox/frontend/src/index.js @@ -183,6 +183,12 @@ return safeNoteFilename(title).replace(/\.md$/, '.url'); } + function numberedLinkFilename(title, number) { + var filename = safeLinkFilename(title); + if (number <= 1) return filename; + return filename.replace(/\.url$/, ' (' + number + ').url'); + } + function safeFileFilename(name) { var base = text(name).trim() .replace(/[\\/:*?"<>|\r\n\t]+/g, '_') @@ -686,14 +692,24 @@ 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 () { + function writeLink(number) { + var linkPath = capture.workspaceRootPath + '/Links/' + numberedLinkFilename(title, number); + return api.files.writeText(linkPath, captureToUrlShortcut(capture), { + createIfMissing: true, + overwrite: false + }).then(function () { + return linkPath; + }).catch(function (error) { + if (number < 99 && /^conflict:/.test(text(error && error.message ? error.message : error))) { + return writeLink(number + 1); + } + throw error; + }); + } + return writeLink(1).then(function (linkPath) { if (api.events && typeof api.events.publish === 'function') { return api.events.publish('browser.capture.converted', { captureId: capture.captureId, @@ -703,11 +719,13 @@ title: title, url: capture.url || '', sourcePluginId: PLUGIN_ID + }).then(function () { + return linkPath; }); } - return undefined; + return linkPath; }).then(function () { - statusText = 'Created link: ' + linkPath; + statusText = 'Created link'; statusClass = ''; return archiveCapture(capture.captureId); }).catch(function (err) { @@ -717,6 +735,15 @@ }); } + function openCaptureURL(capture) { + if (!capture || !capture.url || !api || !api.files || typeof api.files.openURL !== 'function') return Promise.resolve(); + return api.files.openURL(capture.url).catch(function (err) { + statusText = 'Could not open link: ' + (err && err.message ? err.message : String(err)); + statusClass = 'error'; + render(); + }); + } + function createFileFromCapture(capture) { if (!capture || !capture.workspaceRootPath || capture.kind !== 'file' || !capture.fileName || (!capture.fileText && !capture.fileDataBase64)) return Promise.resolve(); if (!api || !api.files || (capture.fileDataBase64 ? typeof api.files.writeBytes !== 'function' : typeof api.files.writeText !== 'function')) { @@ -879,6 +906,16 @@ setProcessed(capture.captureId, !capture.processed); } })); + if (capture.url) { + actionButtons.push(el('button', { + className: 'browser-inbox-btn', + 'data-browser-inbox-action': 'open-link', + textContent: 'Open link', + onClick: function () { + openCaptureURL(capture); + } + })); + } if (capture.workspaceRootPath) { actionButtons.push(el('button', { className: 'browser-inbox-btn', diff --git a/scripts/smoke-browser-inbox-plugin.js b/scripts/smoke-browser-inbox-plugin.js index 84e4c7e..53717b7 100644 --- a/scripts/smoke-browser-inbox-plugin.js +++ b/scripts/smoke-browser-inbox-plugin.js @@ -142,6 +142,7 @@ function makeApi(initialSettings = {}) { const unsubscribed = []; const fileWrites = []; const fileByteWrites = []; + const openedURLs = []; const publishedEvents = []; const workspaceEntries = [ { name: 'ClientA', relativePath: 'ClientA', type: 'folder' }, @@ -202,6 +203,7 @@ function makeApi(initialSettings = {}) { unsubscribed, fileWrites, fileByteWrites, + openedURLs, publishedEvents, failNextWrite(message) { nextWriteError = new Error(message || 'write failed'); @@ -247,6 +249,9 @@ function makeApi(initialSettings = {}) { } fileByteWrites.push({ relativePath, dataBase64, options }); }, + openURL: async (url) => { + openedURLs.push(url); + }, }, browserReceiver: { pairing: async () => ({ ...receiverPairing }), @@ -756,6 +761,11 @@ async function mountSettingsWithApi(api, document = makeDocument()) { 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'); + const openLinkButton = walk(linkConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'open-link'); + if (!openLinkButton) throw new Error('open link action was not rendered'); + openLinkButton.click(); + await flush(); + if (linkConversionApi.openedURLs.join(',') !== 'https://example.com/article') throw new Error('open link did not use browser URL capability'); createLinkButton.click(); await flush(); if (linkConversionApi.fileWrites.length !== 1) throw new Error(`expected one link write, got ${linkConversionApi.fileWrites.length}`); @@ -777,6 +787,25 @@ async function mountSettingsWithApi(api, document = makeDocument()) { if (convertedLinkEvent.payload.linkPath !== 'Project/Links/Example_Article.url') throw new Error('converted link event linkPath mismatch'); component.unmount && component.unmount(linkConversionView.container); + const collisionLinkApi = makeApi({ + 'captures:workspace:Project': [{ + captureId: 'convert-link-collision', + capturedAt: '2026-06-29T01:25:00.000Z', + kind: 'link', + url: 'https://example.com/collision', + title: 'Example Article', + workspaceRootPath: 'Project', + }], + }); + collisionLinkApi.failNextWrite('conflict: Project/Links/Example_Article.url'); + const collisionLinkView = await mountWithApi(collisionLinkApi); + walk(collisionLinkView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-link').click(); + await flush(); + if (collisionLinkApi.fileWrites.length !== 1 || collisionLinkApi.fileWrites[0].relativePath !== 'Project/Links/Example_Article (2).url') { + throw new Error('link filename collision did not use the numbered suffix'); + } + component.unmount && component.unmount(collisionLinkView.container); + const failedLinkApi = makeApi({ 'captures:workspace:Project': [{ captureId: 'convert-link-conflict',