feat: open browser links and resolve filename collisions

This commit is contained in:
mirivlad 2026-07-12 20:39:09 +08:00
parent b45a56b09d
commit 50c4fc5e68
2 changed files with 73 additions and 7 deletions

View File

@ -183,6 +183,12 @@
return safeNoteFilename(title).replace(/\.md$/, '.url'); 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) { function safeFileFilename(name) {
var base = text(name).trim() var base = text(name).trim()
.replace(/[\\/:*?"<>|\r\n\t]+/g, '_') .replace(/[\\/:*?"<>|\r\n\t]+/g, '_')
@ -686,14 +692,24 @@
return Promise.resolve(); return Promise.resolve();
} }
var title = noteTitle(capture); var title = noteTitle(capture);
var linkPath = capture.workspaceRootPath + '/Links/' + safeLinkFilename(title);
statusText = 'Creating link...'; statusText = 'Creating link...';
statusClass = ''; statusClass = '';
render(); render();
return api.files.writeText(linkPath, captureToUrlShortcut(capture), { function writeLink(number) {
createIfMissing: true, var linkPath = capture.workspaceRootPath + '/Links/' + numberedLinkFilename(title, number);
overwrite: false return api.files.writeText(linkPath, captureToUrlShortcut(capture), {
}).then(function () { 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') { if (api.events && typeof api.events.publish === 'function') {
return api.events.publish('browser.capture.converted', { return api.events.publish('browser.capture.converted', {
captureId: capture.captureId, captureId: capture.captureId,
@ -703,11 +719,13 @@
title: title, title: title,
url: capture.url || '', url: capture.url || '',
sourcePluginId: PLUGIN_ID sourcePluginId: PLUGIN_ID
}).then(function () {
return linkPath;
}); });
} }
return undefined; return linkPath;
}).then(function () { }).then(function () {
statusText = 'Created link: ' + linkPath; statusText = 'Created link';
statusClass = ''; statusClass = '';
return archiveCapture(capture.captureId); return archiveCapture(capture.captureId);
}).catch(function (err) { }).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) { function createFileFromCapture(capture) {
if (!capture || !capture.workspaceRootPath || capture.kind !== 'file' || !capture.fileName || (!capture.fileText && !capture.fileDataBase64)) return Promise.resolve(); 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')) { 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); 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) { if (capture.workspaceRootPath) {
actionButtons.push(el('button', { actionButtons.push(el('button', {
className: 'browser-inbox-btn', className: 'browser-inbox-btn',

View File

@ -142,6 +142,7 @@ function makeApi(initialSettings = {}) {
const unsubscribed = []; const unsubscribed = [];
const fileWrites = []; const fileWrites = [];
const fileByteWrites = []; const fileByteWrites = [];
const openedURLs = [];
const publishedEvents = []; const publishedEvents = [];
const workspaceEntries = [ const workspaceEntries = [
{ name: 'ClientA', relativePath: 'ClientA', type: 'folder' }, { name: 'ClientA', relativePath: 'ClientA', type: 'folder' },
@ -202,6 +203,7 @@ function makeApi(initialSettings = {}) {
unsubscribed, unsubscribed,
fileWrites, fileWrites,
fileByteWrites, fileByteWrites,
openedURLs,
publishedEvents, publishedEvents,
failNextWrite(message) { failNextWrite(message) {
nextWriteError = new Error(message || 'write failed'); nextWriteError = new Error(message || 'write failed');
@ -247,6 +249,9 @@ function makeApi(initialSettings = {}) {
} }
fileByteWrites.push({ relativePath, dataBase64, options }); fileByteWrites.push({ relativePath, dataBase64, options });
}, },
openURL: async (url) => {
openedURLs.push(url);
},
}, },
browserReceiver: { browserReceiver: {
pairing: async () => ({ ...receiverPairing }), pairing: async () => ({ ...receiverPairing }),
@ -756,6 +761,11 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
const linkConversionView = await mountWithApi(linkConversionApi); const linkConversionView = await mountWithApi(linkConversionApi);
const createLinkButton = walk(linkConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-link'); 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'); 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(); createLinkButton.click();
await flush(); await flush();
if (linkConversionApi.fileWrites.length !== 1) throw new Error(`expected one link write, got ${linkConversionApi.fileWrites.length}`); 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'); if (convertedLinkEvent.payload.linkPath !== 'Project/Links/Example_Article.url') throw new Error('converted link event linkPath mismatch');
component.unmount && component.unmount(linkConversionView.container); 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({ const failedLinkApi = makeApi({
'captures:workspace:Project': [{ 'captures:workspace:Project': [{
captureId: 'convert-link-conflict', captureId: 'convert-link-conflict',