feat: convert browser captures to notes

This commit is contained in:
mirivlad 2026-06-29 09:36:53 +08:00
parent fdc5bd0f46
commit 8cadc957cf
3 changed files with 174 additions and 5 deletions

View File

@ -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() {

View File

@ -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"
],

View File

@ -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);