fix: make browser inbox mutations durable

This commit is contained in:
mirivlad 2026-07-11 23:30:43 +08:00
parent fe97c5ddcc
commit 69a1938ff3
2 changed files with 177 additions and 56 deletions

View File

@ -12,6 +12,7 @@
var LEGACY_KEY = 'captures'; var LEGACY_KEY = 'captures';
var GLOBAL_KEY = 'captures:global'; var GLOBAL_KEY = 'captures:global';
var WORKSPACE_PREFIX = 'captures:workspace:'; var WORKSPACE_PREFIX = 'captures:workspace:';
var MUTATION_EVENT = 'browser-inbox.storage.mutate';
function injectStyles() { function injectStyles() {
if (document.getElementById('browser-inbox-style-injected')) return; if (document.getElementById('browser-inbox-style-injected')) return;
@ -29,7 +30,9 @@
'.browser-inbox-filters{display:flex;align-items:center;gap:.35rem;min-width:0;flex:1;flex-wrap:wrap}', '.browser-inbox-filters{display:flex;align-items:center;gap:.35rem;min-width:0;flex:1;flex-wrap:wrap}',
'.browser-inbox-input,.browser-inbox-select{box-sizing:border-box;min-height:1.85rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-secondary,#b7c0d4);font:inherit;font-size:.76rem;padding:.25rem .42rem}', '.browser-inbox-input,.browser-inbox-select{box-sizing:border-box;min-height:1.85rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-secondary,#b7c0d4);font:inherit;font-size:.76rem;padding:.25rem .42rem}',
'.browser-inbox-input{width:min(15rem,100%)}', '.browser-inbox-input{width:min(15rem,100%)}',
'.browser-inbox-select{max-width:12rem}', '.browser-inbox-select{max-width:12rem;appearance:none;background-color:var(--vt-color-surface,#15152c);background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.65rem}',
'.browser-inbox-select option{background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb)}',
'.browser-inbox-input:focus,.browser-inbox-select:focus{outline:none;border-color:var(--vt-color-accent,#4ecca3);box-shadow:0 0 0 1px var(--vt-color-accent,#4ecca3)}',
'.browser-inbox-spacer{flex:1}', '.browser-inbox-spacer{flex:1}',
'.browser-inbox-btn{font-size:.78rem;padding:.32rem .65rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);cursor:pointer}', '.browser-inbox-btn{font-size:.78rem;padding:.32rem .65rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);cursor:pointer}',
'.browser-inbox-btn:hover{background:var(--vt-color-surface-hover,#1b2440);border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}', '.browser-inbox-btn:hover{background:var(--vt-color-surface-hover,#1b2440);border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}',
@ -473,11 +476,32 @@
}); });
} }
function persist() { function publishMutation(action, payload, verify, verifySettings) {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); if (!api || !api.events || typeof api.events.publish !== 'function') {
return api.settings.write(GLOBAL_KEY, storageCaptures(sortCaptures(captures))).catch(function (err) { statusText = 'Could not save inbox: events API unavailable';
statusClass = 'error';
render();
return Promise.resolve(false);
}
return api.events.publish(MUTATION_EVENT, Object.assign({ action: action }, payload || {})).then(function () {
if (!api.settings || typeof api.settings.read !== 'function') throw new Error('settings API unavailable');
return api.settings.read();
}).then(function (settings) {
settings = settings || {};
if (typeof verifySettings === 'function' && !verifySettings(settings)) {
throw new Error('the stored inbox did not reach the expected state');
}
return loadStored(true);
}).then(function () {
if (typeof verify === 'function' && !verify()) {
throw new Error('the stored capture did not change');
}
return true;
}).catch(function (err) {
statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err)); statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error'; statusClass = 'error';
render();
return false;
}); });
} }
@ -486,30 +510,14 @@
captureIds.forEach(function (captureId) { captureIds.forEach(function (captureId) {
ids[captureId] = true; ids[captureId] = true;
}); });
captures = captures.filter(function (item) { return publishMutation('delete', { captureIds: captureIds }, function () {
return !ids[item.captureId]; return !captures.some(function (capture) { return ids[capture.captureId]; });
}); }).then(function (saved) {
if (ids[selectedId]) selectedId = ''; if (!saved) return;
if (!api || !api.settings || typeof api.settings.read !== 'function' || typeof api.settings.write !== 'function') { if (ids[selectedId]) selectedId = '';
return persist().then(function () {
statusText = successText;
statusClass = '';
});
}
return api.settings.read().then(function (settings) {
var keys = globalCaptureKeys(settings || {});
return Promise.all(keys.map(function (key) {
var next = normalizeStoredCaptures((settings || {})[key], key).filter(function (item) {
return !ids[item.captureId];
});
return api.settings.write(key, storageCaptures(next));
}));
}).then(function () {
statusText = successText; statusText = successText;
statusClass = ''; statusClass = '';
}).catch(function (err) { render();
statusText = 'Could not update inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}); });
} }
@ -534,11 +542,13 @@
return item.captureId === capture.captureId; return item.captureId === capture.captureId;
}); });
if (existing) return Promise.resolve(); if (existing) return Promise.resolve();
captures = sortCaptures([capture].concat(captures));
selectedId = capture.captureId; selectedId = capture.captureId;
statusText = 'Capture received'; statusText = 'Capture received';
statusClass = ''; statusClass = '';
return persist().then(render); statusText = 'Could not load received capture from storage';
statusClass = 'error';
render();
return Promise.resolve();
} }
function applyDomainBinding(capture) { function applyDomainBinding(capture) {
@ -552,17 +562,20 @@
function assignWorkspace(captureId, workspaceRoot) { function assignWorkspace(captureId, workspaceRoot) {
workspaceRoot = cleanWorkspace(workspaceRoot); workspaceRoot = cleanWorkspace(workspaceRoot);
captures = captures.map(function (capture) { return publishMutation('assign', {
if (capture.captureId !== captureId) return capture; captureId: captureId,
return Object.assign({}, capture, { workspaceRootPath: workspaceRoot
workspaceRootPath: workspaceRoot, }, function () {
workspaceName: workspaceRoot return captures.some(function (capture) {
return capture.captureId === captureId && cleanWorkspace(capture.workspaceRootPath) === workspaceRoot;
}); });
}).then(function (saved) {
if (!saved) return;
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
statusText = workspaceRoot ? 'Capture assigned to ' + workspaceRoot : 'Capture is unassigned';
statusClass = '';
render();
}); });
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
statusText = workspaceRoot ? 'Capture assigned to ' + workspaceRoot : 'Capture is unassigned';
statusClass = '';
return persist().then(render);
} }
function removeCapture(captureId) { function removeCapture(captureId) {
@ -570,13 +583,19 @@
} }
function setProcessed(captureId, processed) { function setProcessed(captureId, processed) {
captures = captures.map(function (capture) { return publishMutation('processed', {
if (capture.captureId !== captureId) return capture; captureId: captureId,
return Object.assign({}, capture, { processed: processed === true }); processed: processed === true
}, function () {
return captures.some(function (capture) {
return capture.captureId === captureId && capture.processed === (processed === true);
});
}).then(function (saved) {
if (!saved) return;
statusText = processed ? 'Capture marked processed' : 'Capture marked unprocessed';
statusClass = '';
render();
}); });
statusText = processed ? 'Capture marked processed' : 'Capture marked unprocessed';
statusClass = '';
return persist().then(render);
} }
function createNoteFromCapture(capture) { function createNoteFromCapture(capture) {
@ -884,7 +903,7 @@
renderDetail(); renderDetail();
} }
function loadStored() { function loadStored(skipMigration) {
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve(); if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
return api.settings.read().then(function (settings) { return api.settings.read().then(function (settings) {
domainBindings = normalizeDomainBindings((settings || {}).domainBindings); domainBindings = normalizeDomainBindings((settings || {}).domainBindings);
@ -896,10 +915,28 @@
if (key !== GLOBAL_KEY && stored.length > 0) hasLegacyCaptures = true; if (key !== GLOBAL_KEY && stored.length > 0) hasLegacyCaptures = true;
all = all.concat(stored); all = all.concat(stored);
}); });
captures = sortCaptures(all); captures = sortCaptures(all).map(applyDomainBinding);
if (!selectedId && captures[0]) selectedId = captures[0].captureId; if (!selectedId && captures[0]) selectedId = captures[0].captureId;
// Keep legacy records readable, then mirror the canonical state into the global queue. // Read legacy records once, then ask the backend to migrate them atomically.
return hasLegacyCaptures ? persist() : undefined; if (!hasLegacyCaptures || skipMigration) return undefined;
var expectedIds = captures.map(function (capture) { return capture.captureId; });
return publishMutation('migrate', {}, function () {
return expectedIds.every(function (captureId) {
return captures.some(function (capture) { return capture.captureId === captureId; });
});
}, function (migratedSettings) {
var canonicalIds = normalizeStoredCaptures(migratedSettings[GLOBAL_KEY], GLOBAL_KEY).map(function (capture) {
return capture.captureId;
});
var legacyIsEmpty = globalCaptureKeys(migratedSettings).filter(function (key) {
return key !== GLOBAL_KEY;
}).every(function (key) {
return normalizeStoredCaptures(migratedSettings[key], key).length === 0;
});
return legacyIsEmpty && expectedIds.every(function (captureId) {
return canonicalIds.indexOf(captureId) !== -1;
});
});
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err)); statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error'; statusClass = 'error';
@ -925,7 +962,34 @@
if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve(); if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve();
return Promise.all(CAPTURE_EVENTS.map(function (eventName) { return Promise.all(CAPTURE_EVENTS.map(function (eventName) {
return api.events.subscribe(eventName, function (event) { return api.events.subscribe(eventName, function (event) {
return addCapture(captureFromEvent(event)); var received = captureFromEvent(event);
return loadStored(true).then(function () {
var stored = captures.find(function (capture) {
return capture.captureId === received.captureId;
});
if (!stored) return addCapture(received);
if (!received.workspaceRootPath && stored.workspaceRootPath) {
return publishMutation('assign', {
captureId: stored.captureId,
workspaceRootPath: stored.workspaceRootPath
}, function () {
return captures.some(function (capture) {
return capture.captureId === stored.captureId && capture.workspaceRootPath === stored.workspaceRootPath;
});
}).then(function (saved) {
if (!saved) return;
selectedId = received.captureId;
statusText = 'Capture received';
statusClass = '';
render();
});
}
selectedId = received.captureId;
statusText = 'Capture received';
statusClass = '';
render();
return undefined;
});
}).then(function (unsubscribe) { }).then(function (unsubscribe) {
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe); if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
}); });

View File

@ -152,6 +152,48 @@ function makeApi(initialSettings = {}) {
receiverToken: 'initial-browser-token', receiverToken: 'initial-browser-token',
}; };
let nextWriteError = null; let nextWriteError = null;
function backendCaptures() {
const keys = ['captures:global', 'captures', ...Object.keys(settings).filter((key) => key.startsWith('captures:workspace:'))];
const seen = new Set();
const captures = [];
for (const key of keys) {
for (const original of Array.isArray(settings[key]) ? settings[key] : []) {
if (!original || !original.captureId || seen.has(original.captureId)) continue;
seen.add(original.captureId);
const capture = { ...original };
if (!capture.workspaceRootPath && key.startsWith('captures:workspace:')) {
capture.workspaceRootPath = decodeURIComponent(key.slice('captures:workspace:'.length));
capture.workspaceName = capture.workspaceRootPath;
}
captures.push(capture);
}
}
return { keys, captures };
}
function backendWriteCaptures(captures, keys) {
settings['captures:global'] = captures.slice(0, 100);
keys.forEach((key) => {
if (key !== 'captures:global') settings[key] = [];
});
}
function backendMutate(payload) {
const { keys, captures } = backendCaptures();
const ids = new Set([payload.captureId, ...(payload.captureIds || [])].filter(Boolean));
const next = captures.flatMap((capture) => {
if (!ids.has(capture.captureId)) return [capture];
if (payload.action === 'delete') return [];
if (payload.action === 'assign') return [{ ...capture, workspaceRootPath: payload.workspaceRootPath || '', workspaceName: payload.workspaceRootPath || '' }];
if (payload.action === 'processed') return [{ ...capture, processed: payload.processed === true }];
return [capture];
});
backendWriteCaptures(next, keys);
}
function backendAppend(event) {
const { keys, captures } = backendCaptures();
const payload = { ...(event.payload || {}) };
const next = [payload, ...captures.filter((capture) => capture.captureId !== payload.captureId)];
backendWriteCaptures(next, keys);
}
return { return {
settings, settings,
handlers, handlers,
@ -165,9 +207,13 @@ function makeApi(initialSettings = {}) {
events: { events: {
publish: async (name, payload) => { publish: async (name, payload) => {
publishedEvents.push({ name, payload }); publishedEvents.push({ name, payload });
if (name === 'browser-inbox.storage.mutate') backendMutate(payload || {});
}, },
subscribe: async (name, handler) => { subscribe: async (name, handler) => {
handlers[name] = handler; handlers[name] = async (event) => {
if (name.startsWith('browser.capture.')) backendAppend(event);
return handler(event);
};
return () => { return () => {
unsubscribed.push(name); unsubscribed.push(name);
delete handlers[name]; delete handlers[name];
@ -214,7 +260,7 @@ function makeApi(initialSettings = {}) {
} }
async function flush() { async function flush() {
for (let i = 0; i < 8; i += 1) await Promise.resolve(); for (let i = 0; i < 16; i += 1) await Promise.resolve();
} }
async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) { async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) {
@ -237,6 +283,14 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
const settingsComponents = loadComponents(makeDocument()); const settingsComponents = loadComponents(makeDocument());
if (!settingsComponents.BrowserInboxSettings) throw new Error('BrowserInboxSettings was not registered'); if (!settingsComponents.BrowserInboxSettings) throw new Error('BrowserInboxSettings was not registered');
const styleDocument = makeDocument();
const styledView = await mountWithApi(makeApi(), {}, styleDocument);
const injectedStyles = styleDocument.head.children.map((node) => node.textContent).join('\n');
if (!injectedStyles.includes('.browser-inbox-select{') || !injectedStyles.includes('appearance:none')) {
throw new Error('Browser Inbox selects do not use the application select styling');
}
styledView.component.unmount && styledView.component.unmount(styledView.container);
const api = makeApi(); const api = makeApi();
const settingsView = await mountSettingsWithApi(makeApi()); const settingsView = await mountSettingsWithApi(makeApi());
const receiverURLInput = walk(settingsView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-pairing-url') === ''); const receiverURLInput = walk(settingsView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-pairing-url') === '');
@ -414,6 +468,9 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!walk(legacyGlobal.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'legacy-project-capture')) { if (!walk(legacyGlobal.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'legacy-project-capture')) {
throw new Error('legacy workspace capture was not rendered in global view'); throw new Error('legacy workspace capture was not rendered in global view');
} }
if (legacyApi.getStoredCaptures('captures').length !== 0) {
throw new Error('legacy captures were not removed after canonical migration');
}
component.unmount && component.unmount(legacyGlobal.container); component.unmount && component.unmount(legacyGlobal.container);
const legacyProject = await mountWithApi(legacyApi, { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }); const legacyProject = await mountWithApi(legacyApi, { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' });
@ -612,7 +669,7 @@ 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(projectKey).some((capture) => capture.captureId === 'convert-selection')) { if (conversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-selection')) {
throw new Error('converted capture was not removed from queue'); throw new Error('converted capture was not removed from queue');
} }
const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
@ -639,7 +696,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
failedConversionApi.failNextWrite('file already exists'); failedConversionApi.failNextWrite('file already exists');
failedCreateNoteButton.click(); failedCreateNoteButton.click();
await flush(); await flush();
if (!failedConversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-conflict')) { if (!failedConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-conflict')) {
throw new Error('failed conversion removed capture from queue'); throw new Error('failed conversion removed capture from queue');
} }
if (!failedConversionView.container.textContent.includes('Could not create note')) { if (!failedConversionView.container.textContent.includes('Could not create note')) {
@ -677,7 +734,7 @@ 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(projectKey).some((capture) => capture.captureId === 'convert-link')) { if (linkConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link')) {
throw new Error('converted link capture was not removed from queue'); throw new Error('converted link capture was not removed from queue');
} }
const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
@ -704,7 +761,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
failedLinkApi.failNextWrite('link already exists'); failedLinkApi.failNextWrite('link already exists');
failedCreateLinkButton.click(); failedCreateLinkButton.click();
await flush(); await flush();
if (!failedLinkApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-link-conflict')) { if (!failedLinkApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link-conflict')) {
throw new Error('failed link conversion removed capture from queue'); throw new Error('failed link conversion removed capture from queue');
} }
if (!failedLinkView.container.textContent.includes('Could not create link')) { if (!failedLinkView.container.textContent.includes('Could not create link')) {
@ -745,7 +802,7 @@ 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(projectKey).some((capture) => capture.captureId === 'convert-file')) { if (fileConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file')) {
throw new Error('converted file capture was not removed from queue'); throw new Error('converted file capture was not removed from queue');
} }
const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
@ -811,7 +868,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
failedFileApi.failNextWrite('file already exists'); failedFileApi.failNextWrite('file already exists');
failedCreateFileButton.click(); failedCreateFileButton.click();
await flush(); await flush();
if (!failedFileApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-file-conflict')) { if (!failedFileApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file-conflict')) {
throw new Error('failed file conversion removed capture from queue'); throw new Error('failed file conversion removed capture from queue');
} }
if (!failedFileView.container.textContent.includes('Could not create file')) { if (!failedFileView.container.textContent.includes('Could not create file')) {