fix: make browser inbox mutations durable

This commit is contained in:
2026-07-11 23:30:43 +08:00
parent fe97c5ddcc
commit 69a1938ff3
2 changed files with 177 additions and 56 deletions
+112 -48
View File
@@ -12,6 +12,7 @@
var LEGACY_KEY = 'captures';
var GLOBAL_KEY = 'captures:global';
var WORKSPACE_PREFIX = 'captures:workspace:';
var MUTATION_EVENT = 'browser-inbox.storage.mutate';
function injectStyles() {
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-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-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-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)}',
@@ -473,11 +476,32 @@
});
}
function persist() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(GLOBAL_KEY, storageCaptures(sortCaptures(captures))).catch(function (err) {
function publishMutation(action, payload, verify, verifySettings) {
if (!api || !api.events || typeof api.events.publish !== 'function') {
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));
statusClass = 'error';
render();
return false;
});
}
@@ -486,30 +510,14 @@
captureIds.forEach(function (captureId) {
ids[captureId] = true;
});
captures = captures.filter(function (item) {
return !ids[item.captureId];
});
if (ids[selectedId]) selectedId = '';
if (!api || !api.settings || typeof api.settings.read !== 'function' || typeof api.settings.write !== 'function') {
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 () {
return publishMutation('delete', { captureIds: captureIds }, function () {
return !captures.some(function (capture) { return ids[capture.captureId]; });
}).then(function (saved) {
if (!saved) return;
if (ids[selectedId]) selectedId = '';
statusText = successText;
statusClass = '';
}).catch(function (err) {
statusText = 'Could not update inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
render();
});
}
@@ -534,11 +542,13 @@
return item.captureId === capture.captureId;
});
if (existing) return Promise.resolve();
captures = sortCaptures([capture].concat(captures));
selectedId = capture.captureId;
statusText = 'Capture received';
statusClass = '';
return persist().then(render);
statusText = 'Could not load received capture from storage';
statusClass = 'error';
render();
return Promise.resolve();
}
function applyDomainBinding(capture) {
@@ -552,17 +562,20 @@
function assignWorkspace(captureId, workspaceRoot) {
workspaceRoot = cleanWorkspace(workspaceRoot);
captures = captures.map(function (capture) {
if (capture.captureId !== captureId) return capture;
return Object.assign({}, capture, {
workspaceRootPath: workspaceRoot,
workspaceName: workspaceRoot
return publishMutation('assign', {
captureId: captureId,
workspaceRootPath: workspaceRoot
}, function () {
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) {
@@ -570,13 +583,19 @@
}
function setProcessed(captureId, processed) {
captures = captures.map(function (capture) {
if (capture.captureId !== captureId) return capture;
return Object.assign({}, capture, { processed: processed === true });
return publishMutation('processed', {
captureId: captureId,
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) {
@@ -884,7 +903,7 @@
renderDetail();
}
function loadStored() {
function loadStored(skipMigration) {
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
return api.settings.read().then(function (settings) {
domainBindings = normalizeDomainBindings((settings || {}).domainBindings);
@@ -896,10 +915,28 @@
if (key !== GLOBAL_KEY && stored.length > 0) hasLegacyCaptures = true;
all = all.concat(stored);
});
captures = sortCaptures(all);
captures = sortCaptures(all).map(applyDomainBinding);
if (!selectedId && captures[0]) selectedId = captures[0].captureId;
// Keep legacy records readable, then mirror the canonical state into the global queue.
return hasLegacyCaptures ? persist() : undefined;
// Read legacy records once, then ask the backend to migrate them atomically.
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) {
statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
@@ -925,7 +962,34 @@
if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve();
return Promise.all(CAPTURE_EVENTS.map(function (eventName) {
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) {
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
});