feat: convert browser captures to notes
This commit is contained in:
parent
fdc5bd0f46
commit
8cadc957cf
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue