feat: add browser inbox archive flow

This commit is contained in:
mirivlad 2026-07-12 17:43:17 +08:00
parent 0db9f896ce
commit b45a56b09d
2 changed files with 119 additions and 26 deletions

View File

@ -238,8 +238,10 @@
fileDataBase64: text(payload.fileDataBase64).trim(), fileDataBase64: text(payload.fileDataBase64).trim(),
source: text(payload.source).trim(), source: text(payload.source).trim(),
browserName: text(payload.browserName).trim(), browserName: text(payload.browserName).trim(),
// The receiver provides the active workspace. Untagged captures remain unassigned. workspaceRootPath: workspaceFromPayload(payload),
workspaceRootPath: workspaceFromPayload(payload) workspaceId: text(payload.workspaceId).trim(),
workspaceState: text(payload.workspaceState || 'unassigned').trim(),
workspaceTrashId: text(payload.workspaceTrashId).trim()
}; };
} }
@ -258,7 +260,6 @@
return value.filter(function (item) { return value.filter(function (item) {
return item && typeof item === 'object' && item.captureId; return item && typeof item === 'object' && item.captureId;
}).map(function (item) { }).map(function (item) {
// Workspace root paths are the current stable identifiers; core has no immutable workspace ID yet.
var workspaceRootPath = cleanWorkspace(item.workspaceRootPath) || workspaceFromStorageKey(storageKey); var workspaceRootPath = cleanWorkspace(item.workspaceRootPath) || workspaceFromStorageKey(storageKey);
return { return {
captureId: text(item.captureId), captureId: text(item.captureId),
@ -278,6 +279,10 @@
browserName: text(item.browserName), browserName: text(item.browserName),
workspaceRootPath: workspaceRootPath, workspaceRootPath: workspaceRootPath,
workspaceName: cleanWorkspace(item.workspaceName || workspaceRootPath), workspaceName: cleanWorkspace(item.workspaceName || workspaceRootPath),
workspaceId: text(item.workspaceId),
workspaceState: text(item.workspaceState || (workspaceRootPath ? 'active' : 'unassigned')),
workspaceTrashId: text(item.workspaceTrashId),
globalState: text(item.globalState || 'inbox') === 'archived' ? 'archived' : 'inbox',
processed: item.processed === true, processed: item.processed === true,
_storageKey: storageKey || '' _storageKey: storageKey || ''
}; };
@ -304,6 +309,10 @@
browserName: item.browserName, browserName: item.browserName,
workspaceRootPath: item.workspaceRootPath, workspaceRootPath: item.workspaceRootPath,
workspaceName: item.workspaceName || item.workspaceRootPath || '', workspaceName: item.workspaceName || item.workspaceRootPath || '',
workspaceId: item.workspaceId || '',
workspaceState: item.workspaceState || (item.workspaceRootPath ? 'active' : 'unassigned'),
workspaceTrashId: item.workspaceTrashId || '',
globalState: item.globalState === 'archived' ? 'archived' : 'inbox',
processed: item.processed === true processed: item.processed === true
}; };
}); });
@ -381,7 +390,8 @@
el('option', { value: 'all', textContent: tr('ui.allCaptures', null, 'All captures') }), el('option', { value: 'all', textContent: tr('ui.allCaptures', null, 'All captures') }),
el('option', { value: 'unassigned', textContent: tr('ui.unassigned', null, 'Unassigned') }), el('option', { value: 'unassigned', textContent: tr('ui.unassigned', null, 'Unassigned') }),
el('option', { value: 'unprocessed', textContent: tr('ui.unprocessed', null, 'Unprocessed') }), el('option', { value: 'unprocessed', textContent: tr('ui.unprocessed', null, 'Unprocessed') }),
el('option', { value: 'processed', textContent: tr('ui.processed', null, 'Processed') }) el('option', { value: 'processed', textContent: tr('ui.processed', null, 'Processed') }),
el('option', { value: 'archived', textContent: tr('ui.archive', null, 'Archive') })
]); ]);
var workspaceFilterEl = el('select', { var workspaceFilterEl = el('select', {
className: 'browser-inbox-select', className: 'browser-inbox-select',
@ -468,6 +478,8 @@
var workspaceRoot = cleanWorkspace(capture && capture.workspaceRootPath); var workspaceRoot = cleanWorkspace(capture && capture.workspaceRootPath);
if (scope.mode === 'workspace' && workspaceRoot !== scope.workspaceRoot) return false; if (scope.mode === 'workspace' && workspaceRoot !== scope.workspaceRoot) return false;
if (scope.mode === 'global' && workspaceFilter && workspaceRoot !== workspaceFilter) return false; if (scope.mode === 'global' && workspaceFilter && workspaceRoot !== workspaceFilter) return false;
if (statusFilter === 'archived' && capture.globalState !== 'archived') return false;
if (statusFilter !== 'archived' && capture.globalState === 'archived') return false;
if (statusFilter === 'unassigned' && workspaceRoot) return false; if (statusFilter === 'unassigned' && workspaceRoot) return false;
if (statusFilter === 'unprocessed' && capture.processed === true) return false; if (statusFilter === 'unprocessed' && capture.processed === true) return false;
if (statusFilter === 'processed' && capture.processed !== true) return false; if (statusFilter === 'processed' && capture.processed !== true) return false;
@ -505,13 +517,15 @@
}); });
} }
function removeCaptures(captureIds, successText) { function archiveCaptures(captureIds, successText) {
var ids = {}; var ids = {};
captureIds.forEach(function (captureId) { captureIds.forEach(function (captureId) {
ids[captureId] = true; ids[captureId] = true;
}); });
return publishMutation('delete', { captureIds: captureIds }, function () { return publishMutation('archive', { captureIds: captureIds }, function () {
return !captures.some(function (capture) { return ids[capture.captureId]; }); return captures.every(function (capture) {
return !ids[capture.captureId] || capture.globalState === 'archived';
});
}).then(function (saved) { }).then(function (saved) {
if (!saved) return; if (!saved) return;
if (ids[selectedId]) selectedId = ''; if (ids[selectedId]) selectedId = '';
@ -525,7 +539,7 @@
var ids = scope.mode === 'global' var ids = scope.mode === 'global'
? captures.map(function (capture) { return capture.captureId; }) ? captures.map(function (capture) { return capture.captureId; })
: captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).map(function (capture) { return capture.captureId; }); : captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).map(function (capture) { return capture.captureId; });
return removeCaptures(ids, scope.mode === 'global' ? 'Inbox cleared' : 'Workspace captures cleared'); return archiveCaptures(ids, scope.mode === 'global' ? 'Inbox archived' : 'Workspace captures archived');
} }
function selectedCapture() { function selectedCapture() {
@ -578,8 +592,33 @@
}); });
} }
function removeCapture(captureId) { function archiveCapture(captureId) {
return removeCaptures([captureId], 'Capture deleted'); return archiveCaptures([captureId], 'Capture archived');
}
function restoreCapture(captureId) {
return publishMutation('restore', { captureId: captureId }, function () {
return captures.some(function (capture) {
return capture.captureId === captureId && capture.globalState === 'inbox';
});
}).then(function (saved) {
if (!saved) return;
statusText = 'Capture restored to Inbox';
statusClass = '';
render();
});
}
function permanentlyDeleteCapture(captureId) {
return publishMutation('delete', { captureId: captureId, permanent: true }, function () {
return !captures.some(function (capture) { return capture.captureId === captureId; });
}).then(function (saved) {
if (!saved) return;
if (selectedId === captureId) selectedId = '';
statusText = 'Capture permanently deleted';
statusClass = '';
render();
});
} }
function setProcessed(captureId, processed) { function setProcessed(captureId, processed) {
@ -630,7 +669,7 @@
}).then(function () { }).then(function () {
statusText = 'Created note: ' + notePath; statusText = 'Created note: ' + notePath;
statusClass = ''; statusClass = '';
return removeCapture(capture.captureId); return archiveCapture(capture.captureId);
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not create note: ' + (err && err.message ? err.message : String(err)); statusText = 'Could not create note: ' + (err && err.message ? err.message : String(err));
statusClass = 'error'; statusClass = 'error';
@ -670,7 +709,7 @@
}).then(function () { }).then(function () {
statusText = 'Created link: ' + linkPath; statusText = 'Created link: ' + linkPath;
statusClass = ''; statusClass = '';
return removeCapture(capture.captureId); return archiveCapture(capture.captureId);
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not create link: ' + (err && err.message ? err.message : String(err)); statusText = 'Could not create link: ' + (err && err.message ? err.message : String(err));
statusClass = 'error'; statusClass = 'error';
@ -717,7 +756,7 @@
}).then(function () { }).then(function () {
statusText = 'Created file: ' + filePath; statusText = 'Created file: ' + filePath;
statusClass = ''; statusClass = '';
return removeCapture(capture.captureId); return archiveCapture(capture.captureId);
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not create file: ' + (err && err.message ? err.message : String(err)); statusText = 'Could not create file: ' + (err && err.message ? err.message : String(err));
statusClass = 'error'; statusClass = 'error';
@ -759,7 +798,8 @@
el('span', { el('span', {
className: 'browser-inbox-badge' + (capture.processed ? ' processed' : ''), className: 'browser-inbox-badge' + (capture.processed ? ' processed' : ''),
textContent: capture.processed ? 'Processed' : 'Unprocessed' textContent: capture.processed ? 'Processed' : 'Unprocessed'
}) }),
capture.globalState === 'archived' ? el('span', { className: 'browser-inbox-badge', textContent: 'Archived' }) : null
])); ]));
if (capture.text) { if (capture.text) {
row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: capture.text })); row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: capture.text }));
@ -869,12 +909,31 @@
})); }));
} }
} }
if (capture.globalState === 'archived') {
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'restore',
textContent: 'Restore to Inbox',
onClick: function () {
restoreCapture(capture.captureId);
}
}));
} else {
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'archive',
textContent: 'Archive',
onClick: function () {
archiveCapture(capture.captureId);
}
}));
}
actionButtons.push(el('button', { actionButtons.push(el('button', {
className: 'browser-inbox-btn danger', className: 'browser-inbox-btn danger',
'data-browser-inbox-action': 'remove', 'data-browser-inbox-action': 'delete-permanently',
textContent: tr('ui.delete', null, 'Delete'), textContent: 'Delete permanently',
onClick: function () { onClick: function () {
removeCapture(capture.captureId); permanentlyDeleteCapture(capture.captureId);
} }
})); }));
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, actionButtons)); detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, actionButtons));

View File

@ -182,6 +182,8 @@ function makeApi(initialSettings = {}) {
const next = captures.flatMap((capture) => { const next = captures.flatMap((capture) => {
if (!ids.has(capture.captureId)) return [capture]; if (!ids.has(capture.captureId)) return [capture];
if (payload.action === 'delete') return []; if (payload.action === 'delete') return [];
if (payload.action === 'archive') return [{ ...capture, globalState: 'archived' }];
if (payload.action === 'restore') return [{ ...capture, globalState: 'inbox' }];
if (payload.action === 'assign') return [{ ...capture, workspaceRootPath: payload.workspaceRootPath || '', workspaceName: payload.workspaceRootPath || '' }]; if (payload.action === 'assign') return [{ ...capture, workspaceRootPath: payload.workspaceRootPath || '', workspaceName: payload.workspaceRootPath || '' }];
if (payload.action === 'processed') return [{ ...capture, processed: payload.processed === true }]; if (payload.action === 'processed') return [{ ...capture, processed: payload.processed === true }];
return [capture]; return [capture];
@ -424,8 +426,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!clearButton) throw new Error('clear button not found'); if (!clearButton) throw new Error('clear button not found');
clearButton.click(); clearButton.click();
await flush(); await flush();
if (api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-1')) { if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-1' && capture.globalState === 'archived')) {
throw new Error('workspace clear action did not remove the Project capture'); throw new Error('workspace clear action did not archive the Project capture');
} }
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) { if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) {
throw new Error('workspace clear action removed a capture from another workspace'); throw new Error('workspace clear action removed a capture from another workspace');
@ -632,7 +634,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === false)) { if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === false)) {
throw new Error('mark unprocessed did not persist state'); throw new Error('mark unprocessed did not persist state');
} }
const deleteButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'remove'); const deleteButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'delete-permanently');
if (!deleteButton) throw new Error('permanent delete action was not rendered');
deleteButton.click(); deleteButton.click();
await flush(); await flush();
if (assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned')) { if (assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned')) {
@ -640,6 +643,37 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
} }
component.unmount && component.unmount(assignmentView.container); component.unmount && component.unmount(assignmentView.container);
const archiveApi = makeApi({
'captures:global': [{
captureId: 'archived-capture',
capturedAt: '2026-07-12T10:00:00.000Z',
kind: 'page',
url: 'https://example.com/archive',
title: 'Archived capture',
workspaceRootPath: 'Project',
globalState: 'archived',
}],
});
const archiveView = await mountWithApi(archiveApi, {});
if (walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'archived-capture')) {
throw new Error('archived capture leaked into the active inbox');
}
const archiveFilter = walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-filter') === 'status');
archiveFilter.value = 'archived';
archiveFilter.dispatchEvent('change');
await flush();
if (!walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'archived-capture')) {
throw new Error('archive filter did not reveal archived capture');
}
const restoreButton = walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'restore');
if (!restoreButton) throw new Error('restore archived capture action was not rendered');
restoreButton.click();
await flush();
if (!archiveApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'archived-capture' && capture.globalState === 'inbox')) {
throw new Error('restore archived capture did not return it to Inbox');
}
component.unmount && component.unmount(archiveView.container);
const conversionApi = makeApi({ const conversionApi = makeApi({
'captures:workspace:Project': [{ 'captures:workspace:Project': [{
captureId: 'convert-selection', captureId: 'convert-selection',
@ -669,8 +703,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!noteWrite.content.includes('# Example Article')) throw new Error('note content missing heading'); 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('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 (!noteWrite.content.includes('Selected text from the page')) throw new Error('note content missing selected text');
if (conversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-selection')) { if (!conversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-selection' && capture.globalState === 'archived')) {
throw new Error('converted capture was not removed from queue'); throw new Error('converted capture was not archived');
} }
const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); 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) throw new Error('browser.capture.converted event was not published');
@ -734,8 +768,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
} }
if (!linkWrite.content.includes('[InternetShortcut]')) throw new Error('link content missing InternetShortcut header'); 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 (!linkWrite.content.includes('URL=https://example.com/article')) throw new Error('link content missing URL');
if (linkConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link')) { if (!linkConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link' && capture.globalState === 'archived')) {
throw new Error('converted link capture was not removed from queue'); throw new Error('converted link capture was not archived');
} }
const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); 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) throw new Error('browser.capture.converted link event was not published');
@ -802,8 +836,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (fileWrite.options.createIfMissing !== true || fileWrite.options.overwrite !== false) { if (fileWrite.options.createIfMissing !== true || fileWrite.options.overwrite !== false) {
throw new Error(`file write options mismatch: ${JSON.stringify(fileWrite.options)}`); throw new Error(`file write options mismatch: ${JSON.stringify(fileWrite.options)}`);
} }
if (fileConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file')) { if (!fileConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file' && capture.globalState === 'archived')) {
throw new Error('converted file capture was not removed from queue'); throw new Error('converted file capture was not archived');
} }
const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedFileEvent) throw new Error('browser.capture.converted file event was not published'); if (!convertedFileEvent) throw new Error('browser.capture.converted file event was not published');