fix: make browser inbox mutations durable
This commit is contained in:
parent
fe97c5ddcc
commit
69a1938ff3
|
|
@ -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];
|
||||
});
|
||||
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 = '';
|
||||
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 () {
|
||||
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 = '';
|
||||
return persist().then(render);
|
||||
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 = '';
|
||||
return persist().then(render);
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -152,6 +152,48 @@ function makeApi(initialSettings = {}) {
|
|||
receiverToken: 'initial-browser-token',
|
||||
};
|
||||
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 {
|
||||
settings,
|
||||
handlers,
|
||||
|
|
@ -165,9 +207,13 @@ function makeApi(initialSettings = {}) {
|
|||
events: {
|
||||
publish: async (name, payload) => {
|
||||
publishedEvents.push({ name, payload });
|
||||
if (name === 'browser-inbox.storage.mutate') backendMutate(payload || {});
|
||||
},
|
||||
subscribe: async (name, handler) => {
|
||||
handlers[name] = handler;
|
||||
handlers[name] = async (event) => {
|
||||
if (name.startsWith('browser.capture.')) backendAppend(event);
|
||||
return handler(event);
|
||||
};
|
||||
return () => {
|
||||
unsubscribed.push(name);
|
||||
delete handlers[name];
|
||||
|
|
@ -214,7 +260,7 @@ function makeApi(initialSettings = {}) {
|
|||
}
|
||||
|
||||
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()) {
|
||||
|
|
@ -237,6 +283,14 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
|||
const settingsComponents = loadComponents(makeDocument());
|
||||
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 settingsView = await mountSettingsWithApi(makeApi());
|
||||
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')) {
|
||||
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);
|
||||
|
||||
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('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')) {
|
||||
if (conversionApi.getStoredCaptures(globalKey).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');
|
||||
|
|
@ -639,7 +696,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
|||
failedConversionApi.failNextWrite('file already exists');
|
||||
failedCreateNoteButton.click();
|
||||
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');
|
||||
}
|
||||
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('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');
|
||||
}
|
||||
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');
|
||||
failedCreateLinkButton.click();
|
||||
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');
|
||||
}
|
||||
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) {
|
||||
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');
|
||||
}
|
||||
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');
|
||||
failedCreateFileButton.click();
|
||||
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');
|
||||
}
|
||||
if (!failedFileView.container.textContent.includes('Could not create file')) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue