feat: make browser inbox globally assignable
This commit is contained in:
parent
c31ee7e0ce
commit
eb19cce2a4
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Browser Inbox
|
||||||
|
|
||||||
|
Browser Inbox is one global queue of browser captures. New and changed records are
|
||||||
|
stored in `captures:global`; previous `captures` and `captures:workspace:*` keys
|
||||||
|
remain readable for migration compatibility.
|
||||||
|
|
||||||
|
Each capture contains `workspaceRootPath` and `processed`. An empty
|
||||||
|
`workspaceRootPath` means the capture is **Unassigned**. The global view can filter
|
||||||
|
all captures by assignment, workspace, processed state, and text search. A capture
|
||||||
|
can be assigned, reassigned, made unassigned, marked processed or unprocessed, and
|
||||||
|
deleted.
|
||||||
|
|
||||||
|
The local browser receiver adds the currently active workspace before publishing a
|
||||||
|
capture event. When it has no active workspace, the capture remains unassigned. The
|
||||||
|
frontend never assigns an untagged capture merely because a workspace view happens
|
||||||
|
to be open.
|
||||||
|
|
||||||
|
Workspace assignment currently uses the top-level vault folder path as the
|
||||||
|
identifier because the core workspace model has no separate immutable ID. In the
|
||||||
|
current model that path is also the displayed workspace name, so a workspace rename
|
||||||
|
requires a later reassignment of existing captures.
|
||||||
|
|
||||||
|
Workspace Inbox and Overview show only captures whose `workspaceRootPath` exactly
|
||||||
|
matches the selected workspace. Unassigned captures remain visible only in the
|
||||||
|
global Browser Inbox.
|
||||||
|
|
@ -26,6 +26,10 @@
|
||||||
'.browser-inbox-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-shrink:0;flex-wrap:wrap}',
|
'.browser-inbox-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-shrink:0;flex-wrap:wrap}',
|
||||||
'.browser-inbox-title{font-size:.82rem;font-weight:600;color:var(--vt-color-text-primary,#f4f7fb)}',
|
'.browser-inbox-title{font-size:.82rem;font-weight:600;color:var(--vt-color-text-primary,#f4f7fb)}',
|
||||||
'.browser-inbox-count{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
|
'.browser-inbox-count{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
|
||||||
|
'.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-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)}',
|
||||||
|
|
@ -44,6 +48,9 @@
|
||||||
'.browser-inbox-row-title{font-size:.86rem;color:var(--vt-color-text-primary,#f4f7fb);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
'.browser-inbox-row-title{font-size:.86rem;color:var(--vt-color-text-primary,#f4f7fb);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
||||||
'.browser-inbox-row-url{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
'.browser-inbox-row-url{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
||||||
'.browser-inbox-row-text{font-size:.76rem;color:var(--vt-color-text-secondary,#b7c0d4);line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}',
|
'.browser-inbox-row-text{font-size:.76rem;color:var(--vt-color-text-secondary,#b7c0d4);line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}',
|
||||||
|
'.browser-inbox-row-meta{display:flex;align-items:center;gap:.35rem;min-width:0;font-size:.68rem;color:var(--vt-color-text-muted,#7f8aa3)}',
|
||||||
|
'.browser-inbox-badge{display:inline-flex;align-items:center;min-height:1.1rem;padding:0 .3rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-sm,4px);white-space:nowrap}',
|
||||||
|
'.browser-inbox-badge.unassigned{border-color:rgba(240,180,75,.5);color:#ffd37a}.browser-inbox-badge.processed{border-color:rgba(116,190,148,.45);color:#9fe0bc}',
|
||||||
'.browser-inbox-detail{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:auto;padding:1rem;gap:.75rem}',
|
'.browser-inbox-detail{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:auto;padding:1rem;gap:.75rem}',
|
||||||
'.browser-inbox-detail-empty{margin:auto;color:var(--vt-color-text-muted,#7f8aa3);font-size:.86rem}',
|
'.browser-inbox-detail-empty{margin:auto;color:var(--vt-color-text-muted,#7f8aa3);font-size:.86rem}',
|
||||||
'.browser-inbox-detail-title{font-size:1rem;font-weight:600;color:var(--vt-color-text-primary,#f4f7fb);word-break:break-word}',
|
'.browser-inbox-detail-title{font-size:1rem;font-weight:600;color:var(--vt-color-text-primary,#f4f7fb);word-break:break-word}',
|
||||||
|
|
@ -52,6 +59,8 @@
|
||||||
'.browser-inbox-meta-value{color:var(--vt-color-text-secondary,#b7c0d4);min-width:0;overflow-wrap:anywhere}',
|
'.browser-inbox-meta-value{color:var(--vt-color-text-secondary,#b7c0d4);min-width:0;overflow-wrap:anywhere}',
|
||||||
'.browser-inbox-text{border:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface,#15152c);border-radius:var(--vt-radius-lg,8px);padding:.75rem;font-size:.85rem;line-height:1.5;color:var(--vt-color-text-primary,#f4f7fb);white-space:pre-wrap;overflow-wrap:anywhere}',
|
'.browser-inbox-text{border:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface,#15152c);border-radius:var(--vt-radius-lg,8px);padding:.75rem;font-size:.85rem;line-height:1.5;color:var(--vt-color-text-primary,#f4f7fb);white-space:pre-wrap;overflow-wrap:anywhere}',
|
||||||
'.browser-inbox-detail-actions{display:flex;gap:.5rem;flex-wrap:wrap}',
|
'.browser-inbox-detail-actions{display:flex;gap:.5rem;flex-wrap:wrap}',
|
||||||
|
'.browser-inbox-assignment{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}',
|
||||||
|
'.browser-inbox-detail-note{font-size:.76rem;color:var(--vt-color-text-muted,#7f8aa3);line-height:1.4}',
|
||||||
'.browser-inbox-settings{display:grid;gap:.85rem;padding:1rem;max-width:560px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:var(--vt-color-text-primary,#f4f7fb)}',
|
'.browser-inbox-settings{display:grid;gap:.85rem;padding:1rem;max-width:560px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:var(--vt-color-text-primary,#f4f7fb)}',
|
||||||
'.browser-inbox-settings-field{display:grid;gap:.3rem}',
|
'.browser-inbox-settings-field{display:grid;gap:.3rem}',
|
||||||
'.browser-inbox-settings-label{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3)}',
|
'.browser-inbox-settings-label{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3)}',
|
||||||
|
|
@ -204,7 +213,7 @@
|
||||||
return event.payload;
|
return event.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureFromEvent(event, scope) {
|
function captureFromEvent(event) {
|
||||||
var payload = eventPayload(event);
|
var payload = eventPayload(event);
|
||||||
var captureId = text(payload.captureId).trim();
|
var captureId = text(payload.captureId).trim();
|
||||||
if (!captureId) {
|
if (!captureId) {
|
||||||
|
|
@ -226,15 +235,28 @@
|
||||||
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(),
|
||||||
workspaceRootPath: workspaceFromPayload(payload) || (scope && scope.workspaceRoot) || ''
|
// The receiver provides the active workspace. Untagged captures remain unassigned.
|
||||||
|
workspaceRootPath: workspaceFromPayload(payload)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workspaceFromStorageKey(storageKey) {
|
||||||
|
var key = text(storageKey);
|
||||||
|
if (key.indexOf(WORKSPACE_PREFIX) !== 0) return '';
|
||||||
|
try {
|
||||||
|
return cleanWorkspace(decodeURIComponent(key.slice(WORKSPACE_PREFIX.length)));
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStoredCaptures(value, storageKey) {
|
function normalizeStoredCaptures(value, storageKey) {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
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);
|
||||||
return {
|
return {
|
||||||
captureId: text(item.captureId),
|
captureId: text(item.captureId),
|
||||||
capturedAt: text(item.capturedAt),
|
capturedAt: text(item.capturedAt),
|
||||||
|
|
@ -251,8 +273,9 @@
|
||||||
fileDataBase64: text(item.fileDataBase64).trim(),
|
fileDataBase64: text(item.fileDataBase64).trim(),
|
||||||
source: text(item.source),
|
source: text(item.source),
|
||||||
browserName: text(item.browserName),
|
browserName: text(item.browserName),
|
||||||
workspaceRootPath: cleanWorkspace(item.workspaceRootPath),
|
workspaceRootPath: workspaceRootPath,
|
||||||
workspaceName: cleanWorkspace(item.workspaceName || item.workspaceRootPath),
|
workspaceName: cleanWorkspace(item.workspaceName || workspaceRootPath),
|
||||||
|
processed: item.processed === true,
|
||||||
_storageKey: storageKey || ''
|
_storageKey: storageKey || ''
|
||||||
};
|
};
|
||||||
}).slice(0, MAX_CAPTURES);
|
}).slice(0, MAX_CAPTURES);
|
||||||
|
|
@ -277,7 +300,8 @@
|
||||||
source: item.source,
|
source: item.source,
|
||||||
browserName: item.browserName,
|
browserName: item.browserName,
|
||||||
workspaceRootPath: item.workspaceRootPath,
|
workspaceRootPath: item.workspaceRootPath,
|
||||||
workspaceName: item.workspaceName || item.workspaceRootPath || ''
|
workspaceName: item.workspaceName || item.workspaceRootPath || '',
|
||||||
|
processed: item.processed === true
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -296,7 +320,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function globalCaptureKeys(settings) {
|
function globalCaptureKeys(settings) {
|
||||||
var keys = [LEGACY_KEY, GLOBAL_KEY];
|
var keys = [GLOBAL_KEY, LEGACY_KEY];
|
||||||
Object.keys(settings || {}).forEach(function (key) {
|
Object.keys(settings || {}).forEach(function (key) {
|
||||||
if (key.indexOf(WORKSPACE_PREFIX) === 0 && keys.indexOf(key) === -1) keys.push(key);
|
if (key.indexOf(WORKSPACE_PREFIX) === 0 && keys.indexOf(key) === -1) keys.push(key);
|
||||||
});
|
});
|
||||||
|
|
@ -326,27 +350,73 @@
|
||||||
var disposed = false;
|
var disposed = false;
|
||||||
var unsubscribers = [];
|
var unsubscribers = [];
|
||||||
var domainBindings = {};
|
var domainBindings = {};
|
||||||
|
var workspaceOptions = [];
|
||||||
|
var statusFilter = 'all';
|
||||||
|
var workspaceFilter = '';
|
||||||
|
var searchQuery = '';
|
||||||
|
|
||||||
var toolbar = el('div', { className: 'browser-inbox-toolbar' });
|
var toolbar = el('div', { className: 'browser-inbox-toolbar' });
|
||||||
var titleEl = el('span', { className: 'browser-inbox-title', textContent: scope.mode === 'global' ? 'Browser Inbox' : 'Browser Inbox · ' + scope.label });
|
var titleEl = el('span', { className: 'browser-inbox-title', textContent: scope.mode === 'global' ? 'Browser Inbox' : 'Browser Inbox · ' + scope.label });
|
||||||
var countEl = el('span', { className: 'browser-inbox-count' });
|
var countEl = el('span', { className: 'browser-inbox-count' });
|
||||||
var statusEl = el('span', { className: 'browser-inbox-status' });
|
var statusEl = el('span', { className: 'browser-inbox-status' });
|
||||||
|
var filtersEl = el('div', { className: 'browser-inbox-filters' });
|
||||||
|
var statusFilterEl = el('select', {
|
||||||
|
className: 'browser-inbox-select',
|
||||||
|
'data-browser-inbox-filter': 'status',
|
||||||
|
'aria-label': 'Capture status filter',
|
||||||
|
onChange: function (event) {
|
||||||
|
statusFilter = text(event && event.target && event.target.value) || 'all';
|
||||||
|
selectedId = '';
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
el('option', { value: 'all', textContent: 'All captures' }),
|
||||||
|
el('option', { value: 'unassigned', textContent: 'Unassigned' }),
|
||||||
|
el('option', { value: 'unprocessed', textContent: 'Unprocessed' }),
|
||||||
|
el('option', { value: 'processed', textContent: 'Processed' })
|
||||||
|
]);
|
||||||
|
var workspaceFilterEl = el('select', {
|
||||||
|
className: 'browser-inbox-select',
|
||||||
|
'data-browser-inbox-filter': 'workspace',
|
||||||
|
'aria-label': 'Workspace filter',
|
||||||
|
onChange: function (event) {
|
||||||
|
workspaceFilter = cleanWorkspace(event && event.target && event.target.value);
|
||||||
|
selectedId = '';
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var searchInput = el('input', {
|
||||||
|
className: 'browser-inbox-input',
|
||||||
|
type: 'search',
|
||||||
|
placeholder: 'Search captures',
|
||||||
|
'data-browser-inbox-filter': 'search',
|
||||||
|
'aria-label': 'Search captures',
|
||||||
|
onInput: function (event) {
|
||||||
|
searchQuery = text(event && event.target && event.target.value).trim().toLowerCase();
|
||||||
|
selectedId = '';
|
||||||
|
renderList();
|
||||||
|
renderDetail();
|
||||||
|
renderCount();
|
||||||
|
}
|
||||||
|
});
|
||||||
var clearBtn = el('button', {
|
var clearBtn = el('button', {
|
||||||
className: 'browser-inbox-btn danger',
|
className: 'browser-inbox-btn danger',
|
||||||
'data-browser-inbox-action': 'clear',
|
'data-browser-inbox-action': 'clear',
|
||||||
textContent: 'Clear',
|
textContent: 'Clear',
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
if (scope.mode === 'global') {
|
clearScope().then(render);
|
||||||
clearGlobal().then(render);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
captures = [];
|
|
||||||
selectedId = '';
|
|
||||||
persist().then(render);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
toolbar.appendChild(titleEl);
|
toolbar.appendChild(titleEl);
|
||||||
toolbar.appendChild(countEl);
|
toolbar.appendChild(countEl);
|
||||||
|
filtersEl.appendChild(statusFilterEl);
|
||||||
|
if (scope.mode === 'global') {
|
||||||
|
filtersEl.appendChild(workspaceFilterEl);
|
||||||
|
} else {
|
||||||
|
filtersEl.appendChild(el('span', { className: 'browser-inbox-count', textContent: 'Assigned to this workspace' }));
|
||||||
|
}
|
||||||
|
filtersEl.appendChild(searchInput);
|
||||||
|
toolbar.appendChild(filtersEl);
|
||||||
toolbar.appendChild(el('span', { className: 'browser-inbox-spacer' }));
|
toolbar.appendChild(el('span', { className: 'browser-inbox-spacer' }));
|
||||||
toolbar.appendChild(statusEl);
|
toolbar.appendChild(statusEl);
|
||||||
toolbar.appendChild(clearBtn);
|
toolbar.appendChild(clearBtn);
|
||||||
|
|
@ -359,68 +429,106 @@
|
||||||
containerEl.appendChild(toolbar);
|
containerEl.appendChild(toolbar);
|
||||||
containerEl.appendChild(body);
|
containerEl.appendChild(body);
|
||||||
|
|
||||||
function persist() {
|
function option(value, label) {
|
||||||
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
return el('option', { value: value, textContent: label });
|
||||||
if (scope.mode === 'global') {
|
}
|
||||||
var grouped = {};
|
|
||||||
captures.forEach(function (item) {
|
function workspaceRoots() {
|
||||||
var key = item._storageKey || GLOBAL_KEY;
|
var roots = workspaceOptions.slice();
|
||||||
grouped[key] = grouped[key] || [];
|
captures.forEach(function (capture) {
|
||||||
grouped[key].push(item);
|
var root = cleanWorkspace(capture && capture.workspaceRootPath);
|
||||||
|
if (root && roots.indexOf(root) === -1) roots.push(root);
|
||||||
});
|
});
|
||||||
return Promise.all(Object.keys(grouped).map(function (key) {
|
if (scope.workspaceRoot && roots.indexOf(scope.workspaceRoot) === -1) roots.push(scope.workspaceRoot);
|
||||||
return api.settings.write(key, storageCaptures(sortCaptures(grouped[key])));
|
return roots.sort(function (a, b) {
|
||||||
})).catch(function (err) {
|
return a.localeCompare(b, undefined, { sensitivity: 'base' });
|
||||||
statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err));
|
|
||||||
statusClass = 'error';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var toStore = captures;
|
|
||||||
return api.settings.write(scope.key, storageCaptures(toStore)).catch(function (err) {
|
function renderWorkspaceFilterOptions() {
|
||||||
|
if (scope.mode !== 'global') return;
|
||||||
|
workspaceFilterEl.innerHTML = '';
|
||||||
|
workspaceFilterEl.appendChild(option('', 'All workspaces'));
|
||||||
|
workspaceRoots().forEach(function (root) {
|
||||||
|
workspaceFilterEl.appendChild(option(root, root));
|
||||||
|
});
|
||||||
|
workspaceFilterEl.value = workspaceFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleCaptures() {
|
||||||
|
return captures.filter(function (capture) {
|
||||||
|
var workspaceRoot = cleanWorkspace(capture && capture.workspaceRootPath);
|
||||||
|
if (scope.mode === 'workspace' && workspaceRoot !== scope.workspaceRoot) return false;
|
||||||
|
if (scope.mode === 'global' && workspaceFilter && workspaceRoot !== workspaceFilter) return false;
|
||||||
|
if (statusFilter === 'unassigned' && workspaceRoot) return false;
|
||||||
|
if (statusFilter === 'unprocessed' && capture.processed === true) return false;
|
||||||
|
if (statusFilter === 'processed' && capture.processed !== true) return false;
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
return [displayTitle(capture), capture.url, capture.domain, capture.text, workspaceRoot].join('\n').toLowerCase().indexOf(searchQuery) !== -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
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';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearGlobal() {
|
function removeCaptures(captureIds, successText) {
|
||||||
|
var ids = {};
|
||||||
|
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') {
|
if (!api || !api.settings || typeof api.settings.read !== 'function' || typeof api.settings.write !== 'function') {
|
||||||
captures = [];
|
return persist().then(function () {
|
||||||
selectedId = '';
|
statusText = successText;
|
||||||
return Promise.resolve();
|
statusClass = '';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return api.settings.read().then(function (settings) {
|
return api.settings.read().then(function (settings) {
|
||||||
var keys = globalCaptureKeys(settings || {});
|
var keys = globalCaptureKeys(settings || {});
|
||||||
captures = [];
|
|
||||||
selectedId = '';
|
|
||||||
return Promise.all(keys.map(function (key) {
|
return Promise.all(keys.map(function (key) {
|
||||||
return api.settings.write(key, []);
|
var next = normalizeStoredCaptures((settings || {})[key], key).filter(function (item) {
|
||||||
|
return !ids[item.captureId];
|
||||||
|
});
|
||||||
|
return api.settings.write(key, storageCaptures(next));
|
||||||
}));
|
}));
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
statusText = 'Inbox cleared';
|
statusText = successText;
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = 'Could not clear inbox: ' + (err && err.message ? err.message : String(err));
|
statusText = 'Could not update inbox: ' + (err && err.message ? err.message : String(err));
|
||||||
statusClass = 'error';
|
statusClass = 'error';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedCapture() {
|
function clearScope() {
|
||||||
for (var i = 0; i < captures.length; i += 1) {
|
var ids = scope.mode === 'global'
|
||||||
if (captures[i].captureId === selectedId) return captures[i];
|
? captures.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 captures[0] || null;
|
|
||||||
|
function selectedCapture() {
|
||||||
|
var visible = visibleCaptures();
|
||||||
|
for (var i = 0; i < visible.length; i += 1) {
|
||||||
|
if (visible[i].captureId === selectedId) return visible[i];
|
||||||
|
}
|
||||||
|
return visible[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addCapture(capture) {
|
function addCapture(capture) {
|
||||||
capture = applyDomainBinding(capture);
|
capture = applyDomainBinding(capture);
|
||||||
if (scope.mode === 'workspace' && capture.workspaceRootPath && capture.workspaceRootPath !== scope.workspaceRoot) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
var existing = captures.some(function (item) {
|
var existing = captures.some(function (item) {
|
||||||
return item.captureId === capture.captureId;
|
return item.captureId === capture.captureId;
|
||||||
});
|
});
|
||||||
if (existing) return Promise.resolve();
|
if (existing) return Promise.resolve();
|
||||||
capture._storageKey = storageKeyForCapture(capture);
|
|
||||||
captures = sortCaptures([capture].concat(captures));
|
captures = sortCaptures([capture].concat(captures));
|
||||||
selectedId = capture.captureId;
|
selectedId = capture.captureId;
|
||||||
statusText = 'Capture received';
|
statusText = 'Capture received';
|
||||||
|
|
@ -437,27 +545,33 @@
|
||||||
return capture;
|
return capture;
|
||||||
}
|
}
|
||||||
|
|
||||||
function storageKeyForCapture(capture) {
|
function assignWorkspace(captureId, workspaceRoot) {
|
||||||
var workspaceRoot = cleanWorkspace(capture && capture.workspaceRootPath);
|
workspaceRoot = cleanWorkspace(workspaceRoot);
|
||||||
if (workspaceRoot) return WORKSPACE_PREFIX + encodeKey(workspaceRoot);
|
captures = captures.map(function (capture) {
|
||||||
return scope.key;
|
if (capture.captureId !== captureId) return capture;
|
||||||
|
return Object.assign({}, capture, {
|
||||||
|
workspaceRootPath: workspaceRoot,
|
||||||
|
workspaceName: workspaceRoot
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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) {
|
||||||
captures = captures.filter(function (item) {
|
return removeCaptures([captureId], 'Capture deleted');
|
||||||
return item.captureId !== captureId;
|
}
|
||||||
|
|
||||||
|
function setProcessed(captureId, processed) {
|
||||||
|
captures = captures.map(function (capture) {
|
||||||
|
if (capture.captureId !== captureId) return capture;
|
||||||
|
return Object.assign({}, capture, { processed: processed === true });
|
||||||
});
|
});
|
||||||
if (selectedId === captureId) selectedId = captures[0] ? captures[0].captureId : '';
|
statusText = processed ? 'Capture marked processed' : 'Capture marked unprocessed';
|
||||||
if (scope.mode !== 'global') return persist().then(render);
|
statusClass = '';
|
||||||
if (!api || !api.settings || typeof api.settings.read !== 'function' || typeof api.settings.write !== 'function') return persist().then(render);
|
return persist().then(render);
|
||||||
return api.settings.read().then(function (settings) {
|
|
||||||
return Promise.all(globalCaptureKeys(settings || {}).map(function (key) {
|
|
||||||
var next = normalizeStoredCaptures((settings || {})[key], key).filter(function (item) {
|
|
||||||
return item.captureId !== captureId;
|
|
||||||
});
|
|
||||||
return api.settings.write(key, storageCaptures(next));
|
|
||||||
}));
|
|
||||||
}).then(render);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNoteFromCapture(capture) {
|
function createNoteFromCapture(capture) {
|
||||||
|
|
@ -589,13 +703,18 @@
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
if (captures.length === 0) {
|
var visible = visibleCaptures();
|
||||||
listEl.appendChild(el('div', { className: 'browser-inbox-empty', textContent: 'No browser captures yet. Keep this view open, then send a page, selection, or link from the extension.' }));
|
if (visible.length === 0) {
|
||||||
|
var emptyText = captures.length === 0
|
||||||
|
? 'No browser captures yet. Keep this view open, then send a page, selection, or link from the extension.'
|
||||||
|
: 'No captures match the current filters.';
|
||||||
|
listEl.appendChild(el('div', { className: 'browser-inbox-empty', textContent: emptyText }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
captures.forEach(function (capture) {
|
visible.forEach(function (capture) {
|
||||||
|
var workspaceRoot = cleanWorkspace(capture.workspaceRootPath);
|
||||||
var row = el('div', {
|
var row = el('div', {
|
||||||
className: 'browser-inbox-row' + (capture.captureId === selectedId ? ' selected' : ''),
|
className: 'browser-inbox-row' + (capture.captureId === selectedId ? ' selected' : '') + (capture.processed ? ' processed' : ''),
|
||||||
'data-browser-capture-id': capture.captureId,
|
'data-browser-capture-id': capture.captureId,
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
selectedId = capture.captureId;
|
selectedId = capture.captureId;
|
||||||
|
|
@ -608,6 +727,16 @@
|
||||||
]),
|
]),
|
||||||
el('div', { className: 'browser-inbox-row-url', textContent: capture.url || capture.domain || capture.captureId })
|
el('div', { className: 'browser-inbox-row-url', textContent: capture.url || capture.domain || capture.captureId })
|
||||||
]);
|
]);
|
||||||
|
row.appendChild(el('div', { className: 'browser-inbox-row-meta' }, [
|
||||||
|
el('span', {
|
||||||
|
className: 'browser-inbox-badge' + (workspaceRoot ? '' : ' unassigned'),
|
||||||
|
textContent: workspaceRoot || 'Unassigned'
|
||||||
|
}),
|
||||||
|
el('span', {
|
||||||
|
className: 'browser-inbox-badge' + (capture.processed ? ' processed' : ''),
|
||||||
|
textContent: capture.processed ? 'Processed' : 'Unprocessed'
|
||||||
|
})
|
||||||
|
]));
|
||||||
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 }));
|
||||||
}
|
}
|
||||||
|
|
@ -634,8 +763,43 @@
|
||||||
el('div', { className: 'browser-inbox-meta-label', textContent: 'Captured' }),
|
el('div', { className: 'browser-inbox-meta-label', textContent: 'Captured' }),
|
||||||
el('div', { className: 'browser-inbox-meta-value', textContent: formatDate(capture.capturedAt) || '-' }),
|
el('div', { className: 'browser-inbox-meta-value', textContent: formatDate(capture.capturedAt) || '-' }),
|
||||||
el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }),
|
el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }),
|
||||||
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' })
|
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }),
|
||||||
|
el('div', { className: 'browser-inbox-meta-label', textContent: 'Workspace' }),
|
||||||
|
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }),
|
||||||
|
el('div', { className: 'browser-inbox-meta-label', textContent: 'Status' }),
|
||||||
|
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' })
|
||||||
]));
|
]));
|
||||||
|
var assignmentSelect = el('select', {
|
||||||
|
className: 'browser-inbox-select',
|
||||||
|
'data-browser-inbox-assignment': capture.captureId,
|
||||||
|
'aria-label': 'Assign capture workspace',
|
||||||
|
onChange: function (event) {
|
||||||
|
assignWorkspace(capture.captureId, event && event.target && event.target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assignmentSelect.appendChild(option('', 'Unassigned'));
|
||||||
|
workspaceRoots().forEach(function (workspaceRoot) {
|
||||||
|
assignmentSelect.appendChild(option(workspaceRoot, workspaceRoot));
|
||||||
|
});
|
||||||
|
assignmentSelect.value = capture.workspaceRootPath || '';
|
||||||
|
var assignmentControls = [
|
||||||
|
el('span', { className: 'browser-inbox-meta-label', textContent: 'Assign workspace' }),
|
||||||
|
assignmentSelect
|
||||||
|
];
|
||||||
|
if (capture.workspaceRootPath) {
|
||||||
|
assignmentControls.push(el('button', {
|
||||||
|
className: 'browser-inbox-btn',
|
||||||
|
'data-browser-inbox-action': 'clear-assignment',
|
||||||
|
textContent: 'Clear assignment',
|
||||||
|
onClick: function () {
|
||||||
|
assignWorkspace(capture.captureId, '');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
detailEl.appendChild(el('div', { className: 'browser-inbox-assignment' }, assignmentControls));
|
||||||
|
if (!capture.workspaceRootPath) {
|
||||||
|
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-note', textContent: 'Assign a workspace before creating a note, link, or file.' }));
|
||||||
|
}
|
||||||
if (capture.text) {
|
if (capture.text) {
|
||||||
detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.text }));
|
detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.text }));
|
||||||
}
|
}
|
||||||
|
|
@ -643,6 +807,14 @@
|
||||||
detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.fileText }));
|
detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.fileText }));
|
||||||
}
|
}
|
||||||
var actionButtons = [];
|
var actionButtons = [];
|
||||||
|
actionButtons.push(el('button', {
|
||||||
|
className: 'browser-inbox-btn',
|
||||||
|
'data-browser-inbox-action': 'toggle-processed',
|
||||||
|
textContent: capture.processed ? 'Mark Unprocessed' : 'Mark Processed',
|
||||||
|
onClick: function () {
|
||||||
|
setProcessed(capture.captureId, !capture.processed);
|
||||||
|
}
|
||||||
|
}));
|
||||||
if (capture.workspaceRootPath) {
|
if (capture.workspaceRootPath) {
|
||||||
actionButtons.push(el('button', {
|
actionButtons.push(el('button', {
|
||||||
className: 'browser-inbox-btn',
|
className: 'browser-inbox-btn',
|
||||||
|
|
@ -676,7 +848,7 @@
|
||||||
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': 'remove',
|
||||||
textContent: 'Remove',
|
textContent: 'Delete',
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
removeCapture(capture.captureId);
|
removeCapture(capture.captureId);
|
||||||
}
|
}
|
||||||
|
|
@ -684,9 +856,23 @@
|
||||||
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, actionButtons));
|
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, actionButtons));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCount() {
|
||||||
|
var visibleCount = visibleCaptures().length;
|
||||||
|
var total = captures.length;
|
||||||
|
countEl.textContent = visibleCount === total
|
||||||
|
? total + ' item' + (total === 1 ? '' : 's')
|
||||||
|
: visibleCount + ' of ' + total + ' items';
|
||||||
|
var scopeCount = scope.mode === 'global'
|
||||||
|
? total
|
||||||
|
: captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).length;
|
||||||
|
clearBtn.disabled = scopeCount === 0;
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
countEl.textContent = captures.length + ' item' + (captures.length === 1 ? '' : 's');
|
statusFilterEl.value = statusFilter;
|
||||||
clearBtn.disabled = captures.length === 0;
|
searchInput.value = searchQuery;
|
||||||
|
renderWorkspaceFilterOptions();
|
||||||
|
renderCount();
|
||||||
statusEl.textContent = statusText;
|
statusEl.textContent = statusText;
|
||||||
statusEl.className = 'browser-inbox-status' + (statusClass ? ' ' + statusClass : '');
|
statusEl.className = 'browser-inbox-status' + (statusClass ? ' ' + statusClass : '');
|
||||||
renderList();
|
renderList();
|
||||||
|
|
@ -695,34 +881,38 @@
|
||||||
|
|
||||||
function loadStored() {
|
function loadStored() {
|
||||||
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
|
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
|
||||||
if (scope.mode === 'global') {
|
|
||||||
return api.settings.read().then(function (settings) {
|
return api.settings.read().then(function (settings) {
|
||||||
domainBindings = normalizeDomainBindings((settings || {}).domainBindings);
|
domainBindings = normalizeDomainBindings((settings || {}).domainBindings);
|
||||||
|
var keys = globalCaptureKeys(settings || {});
|
||||||
var all = [];
|
var all = [];
|
||||||
globalCaptureKeys(settings || {}).forEach(function (key) {
|
var hasLegacyCaptures = false;
|
||||||
all = all.concat(normalizeStoredCaptures((settings || {})[key], key));
|
keys.forEach(function (key) {
|
||||||
|
var stored = normalizeStoredCaptures((settings || {})[key], key);
|
||||||
|
if (key !== GLOBAL_KEY && stored.length > 0) hasLegacyCaptures = true;
|
||||||
|
all = all.concat(stored);
|
||||||
});
|
});
|
||||||
captures = sortCaptures(all);
|
captures = sortCaptures(all);
|
||||||
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.
|
||||||
|
return hasLegacyCaptures ? persist() : undefined;
|
||||||
}).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';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return api.settings.read().then(function (settings) {
|
|
||||||
domainBindings = normalizeDomainBindings((settings || {}).domainBindings);
|
function loadWorkspaceOptions() {
|
||||||
var scopedCaptures = normalizeStoredCaptures((settings || {})[scope.key], scope.key);
|
if (!api || !api.files || typeof api.files.list !== 'function') return Promise.resolve();
|
||||||
var globalCaptures = normalizeStoredCaptures((settings || {})[GLOBAL_KEY], GLOBAL_KEY).filter(function (item) {
|
return api.files.list('').then(function (entries) {
|
||||||
return item.workspaceRootPath === scope.workspaceRoot;
|
workspaceOptions = (Array.isArray(entries) ? entries : []).filter(function (entry) {
|
||||||
|
return text(entry && entry.type).toLowerCase() === 'folder';
|
||||||
|
}).map(function (entry) {
|
||||||
|
return cleanWorkspace(entry.relativePath || entry.name);
|
||||||
|
}).filter(function (workspaceRoot) {
|
||||||
|
return workspaceRoot && workspaceRoot.indexOf('/') === -1;
|
||||||
});
|
});
|
||||||
var legacyCaptures = normalizeStoredCaptures((settings || {})[LEGACY_KEY], LEGACY_KEY).filter(function (item) {
|
}).catch(function () {
|
||||||
return item.workspaceRootPath === scope.workspaceRoot;
|
workspaceOptions = [];
|
||||||
});
|
|
||||||
captures = sortCaptures(scopedCaptures.concat(globalCaptures, legacyCaptures));
|
|
||||||
if (!selectedId && captures[0]) selectedId = captures[0].captureId;
|
|
||||||
}).catch(function (err) {
|
|
||||||
statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err));
|
|
||||||
statusClass = 'error';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -730,9 +920,7 @@
|
||||||
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) {
|
||||||
var eventWorkspace = workspaceFromPayload(eventPayload(event));
|
return addCapture(captureFromEvent(event));
|
||||||
if (scope.mode === 'workspace' && eventWorkspace && eventWorkspace !== scope.workspaceRoot) return Promise.resolve();
|
|
||||||
return addCapture(captureFromEvent(event, scope));
|
|
||||||
}).then(function (unsubscribe) {
|
}).then(function (unsubscribe) {
|
||||||
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
|
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
|
||||||
});
|
});
|
||||||
|
|
@ -746,7 +934,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
render();
|
render();
|
||||||
loadStored().then(function () {
|
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
render();
|
render();
|
||||||
return subscribeEvents();
|
return subscribeEvents();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"name": "Browser Inbox",
|
"name": "Browser Inbox",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "0.1.0",
|
"apiVersion": "0.1.0",
|
||||||
"description": "Workspace-scoped inbox for browser captures delivered through the local receiver event protocol.",
|
"description": "Global browser capture queue with explicit workspace assignment delivered through the local receiver event protocol.",
|
||||||
"source": "official",
|
"source": "official",
|
||||||
"icon": "inbox",
|
"icon": "inbox",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"events.subscribe",
|
"events.subscribe",
|
||||||
"events.publish",
|
"events.publish",
|
||||||
"browser.receiver.manage",
|
"browser.receiver.manage",
|
||||||
|
"files.read",
|
||||||
"files.write",
|
"files.write",
|
||||||
"storage.namespace",
|
"storage.namespace",
|
||||||
"ui.register"
|
"ui.register"
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,10 @@ function makeApi(initialSettings = {}) {
|
||||||
const fileWrites = [];
|
const fileWrites = [];
|
||||||
const fileByteWrites = [];
|
const fileByteWrites = [];
|
||||||
const publishedEvents = [];
|
const publishedEvents = [];
|
||||||
|
const workspaceEntries = [
|
||||||
|
{ name: 'ClientA', relativePath: 'ClientA', type: 'folder' },
|
||||||
|
{ name: 'Project', relativePath: 'Project', type: 'folder' },
|
||||||
|
];
|
||||||
const receiverPairing = {
|
const receiverPairing = {
|
||||||
receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures',
|
receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures',
|
||||||
receiverToken: 'initial-browser-token',
|
receiverToken: 'initial-browser-token',
|
||||||
|
|
@ -178,6 +182,7 @@ function makeApi(initialSettings = {}) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
|
list: async () => workspaceEntries.map((entry) => ({ ...entry })),
|
||||||
writeText: async (relativePath, content, options = {}) => {
|
writeText: async (relativePath, content, options = {}) => {
|
||||||
if (nextWriteError) {
|
if (nextWriteError) {
|
||||||
const err = nextWriteError;
|
const err = nextWriteError;
|
||||||
|
|
@ -269,6 +274,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
||||||
domain: 'example.com',
|
domain: 'example.com',
|
||||||
text: 'Selected text from the page',
|
text: 'Selected text from the page',
|
||||||
browserName: 'Firefox',
|
browserName: 'Firefox',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
|
|
@ -276,10 +282,10 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
||||||
const projectKey = 'captures:workspace:Project';
|
const projectKey = 'captures:workspace:Project';
|
||||||
const clientKey = 'captures:workspace:ClientA';
|
const clientKey = 'captures:workspace:ClientA';
|
||||||
const globalKey = 'captures:global';
|
const globalKey = 'captures:global';
|
||||||
const captures = api.getStoredCaptures(projectKey);
|
const captures = api.getStoredCaptures(globalKey);
|
||||||
if (captures.length !== 1) throw new Error(`expected one stored capture, got ${captures.length}`);
|
if (captures.length !== 1) throw new Error(`expected one stored capture, got ${captures.length}`);
|
||||||
if (captures[0].captureId !== 'capture-1') throw new Error('stored capture id mismatch');
|
if (captures[0].captureId !== 'capture-1') throw new Error('stored capture id mismatch');
|
||||||
if (api.getStoredCaptures(globalKey).length !== 0) throw new Error('workspace capture leaked into global storage');
|
if (api.getStoredCaptures(projectKey).length !== 0) throw new Error('workspace capture was stored in a legacy workspace key');
|
||||||
|
|
||||||
const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1');
|
const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1');
|
||||||
if (!row) throw new Error('capture row was not rendered');
|
if (!row) throw new Error('capture row was not rendered');
|
||||||
|
|
@ -298,10 +304,11 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
||||||
title: 'Example Article',
|
title: 'Example Article',
|
||||||
domain: 'example.com',
|
domain: 'example.com',
|
||||||
text: 'Duplicate selected text',
|
text: 'Duplicate selected text',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
if (api.getStoredCaptures(projectKey).length !== 1) throw new Error('duplicate capture was stored');
|
if (api.getStoredCaptures(globalKey).length !== 1) throw new Error('duplicate capture was stored');
|
||||||
|
|
||||||
const clientView = await mountWithApi(api, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' });
|
const clientView = await mountWithApi(api, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' });
|
||||||
if (walk(clientView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1')) {
|
if (walk(clientView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1')) {
|
||||||
|
|
@ -321,7 +328,9 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
if (api.getStoredCaptures(clientKey).length !== 1) throw new Error('ClientA capture was not stored under ClientA workspace key');
|
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) {
|
||||||
|
throw new Error('ClientA capture was not stored in the global queue');
|
||||||
|
}
|
||||||
if (!walk(clientView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-2')) {
|
if (!walk(clientView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-2')) {
|
||||||
throw new Error('ClientA capture was not rendered');
|
throw new Error('ClientA capture was not rendered');
|
||||||
}
|
}
|
||||||
|
|
@ -334,16 +343,42 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
||||||
if (!walk(globalView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-2')) {
|
if (!walk(globalView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-2')) {
|
||||||
throw new Error('global browser inbox did not aggregate ClientA capture');
|
throw new Error('global browser inbox did not aggregate ClientA capture');
|
||||||
}
|
}
|
||||||
|
await api.handlers['browser.capture.page']({
|
||||||
|
name: 'browser.capture.page',
|
||||||
|
timestamp: '2026-06-27T00:20:00Z',
|
||||||
|
payload: {
|
||||||
|
captureId: 'capture-unassigned',
|
||||||
|
capturedAt: '2026-06-27T00:20:00.000Z',
|
||||||
|
kind: 'page',
|
||||||
|
url: 'https://inbox.example.com/unassigned',
|
||||||
|
title: 'Unassigned Page',
|
||||||
|
domain: 'inbox.example.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-unassigned' && !capture.workspaceRootPath)) {
|
||||||
|
throw new Error('untagged receiver event was not retained as unassigned');
|
||||||
|
}
|
||||||
|
const unassignedWorkspace = await mountWithApi(api, { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' });
|
||||||
|
if (walk(unassignedWorkspace.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-unassigned')) {
|
||||||
|
throw new Error('unassigned capture leaked into a workspace inbox');
|
||||||
|
}
|
||||||
|
component.unmount && component.unmount(unassignedWorkspace.container);
|
||||||
component.unmount && component.unmount(globalView.container);
|
component.unmount && component.unmount(globalView.container);
|
||||||
|
|
||||||
const clearButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'clear');
|
const clearButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'clear');
|
||||||
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(projectKey).length !== 0) throw new Error('clear action did not empty stored captures');
|
if (api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-1')) {
|
||||||
|
throw new Error('workspace clear action did not remove the Project capture');
|
||||||
|
}
|
||||||
|
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) {
|
||||||
|
throw new Error('workspace clear action removed a capture from another workspace');
|
||||||
|
}
|
||||||
|
|
||||||
component.unmount && component.unmount(container);
|
component.unmount && component.unmount(container);
|
||||||
if (api.unsubscribed.length !== 12) throw new Error('component did not unsubscribe all capture handlers');
|
if (api.unsubscribed.length !== 16) throw new Error('component did not unsubscribe all capture handlers');
|
||||||
|
|
||||||
const persistedApi = makeApi({ 'captures:workspace:Project': [captures[0]] });
|
const persistedApi = makeApi({ 'captures:workspace:Project': [captures[0]] });
|
||||||
const persisted = await mountWithApi(persistedApi);
|
const persisted = await mountWithApi(persistedApi);
|
||||||
|
|
@ -431,11 +466,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
if (bindingApi.getStoredCaptures('captures:workspace:ClientA').length !== 1) {
|
if (!bindingApi.getStoredCaptures('captures:global').some((capture) => capture.captureId === 'bound-client-capture' && capture.workspaceRootPath === 'ClientA')) {
|
||||||
throw new Error('domain-bound capture was not stored under ClientA workspace key');
|
throw new Error('domain-bound capture was not stored with its ClientA assignment');
|
||||||
}
|
|
||||||
if (bindingApi.getStoredCaptures('captures:global').length !== 0) {
|
|
||||||
throw new Error('domain-bound capture was stored in global queue');
|
|
||||||
}
|
}
|
||||||
const bindingClient = await mountWithApi(bindingApi, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' });
|
const bindingClient = await mountWithApi(bindingApi, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' });
|
||||||
if (!walk(bindingClient.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'bound-client-capture')) {
|
if (!walk(bindingClient.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'bound-client-capture')) {
|
||||||
|
|
@ -460,16 +492,97 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
if (!bindingApi.getStoredCaptures('captures:workspace:Project').some((capture) => capture.captureId === 'explicit-project-capture')) {
|
if (!bindingApi.getStoredCaptures('captures:global').some((capture) => capture.captureId === 'explicit-project-capture' && capture.workspaceRootPath === 'Project')) {
|
||||||
throw new Error('explicit workspace capture was not stored under its payload workspace');
|
throw new Error('explicit workspace capture was not stored with its payload workspace');
|
||||||
}
|
}
|
||||||
if (bindingApi.getStoredCaptures('captures:workspace:ClientA').some((capture) => capture.captureId === 'explicit-project-capture')) {
|
if (bindingApi.getStoredCaptures('captures:global').some((capture) => capture.captureId === 'explicit-project-capture' && capture.workspaceRootPath === 'ClientA')) {
|
||||||
throw new Error('domain binding overrode explicit workspaceRootPath');
|
throw new Error('domain binding overrode explicit workspaceRootPath');
|
||||||
}
|
}
|
||||||
component.unmount && component.unmount(bindingGlobal.container);
|
component.unmount && component.unmount(bindingGlobal.container);
|
||||||
component.unmount && component.unmount(bindingClient.container);
|
component.unmount && component.unmount(bindingClient.container);
|
||||||
component.unmount && component.unmount(bindingAggregate.container);
|
component.unmount && component.unmount(bindingAggregate.container);
|
||||||
|
|
||||||
|
const assignmentApi = makeApi({
|
||||||
|
'captures:global': [
|
||||||
|
{
|
||||||
|
captureId: 'assignment-unassigned',
|
||||||
|
capturedAt: '2026-06-29T00:20:00.000Z',
|
||||||
|
kind: 'page',
|
||||||
|
url: 'https://inbox.example.com/unassigned',
|
||||||
|
title: 'Unassigned capture',
|
||||||
|
domain: 'inbox.example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
captureId: 'assignment-client-processed',
|
||||||
|
capturedAt: '2026-06-29T00:10:00.000Z',
|
||||||
|
kind: 'page',
|
||||||
|
url: 'https://inbox.example.com/client',
|
||||||
|
title: 'Processed client capture',
|
||||||
|
domain: 'inbox.example.com',
|
||||||
|
workspaceRootPath: 'ClientA',
|
||||||
|
processed: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const assignmentView = await mountWithApi(assignmentApi, {});
|
||||||
|
const statusFilter = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-filter') === 'status');
|
||||||
|
if (!statusFilter) throw new Error('global status filter was not rendered');
|
||||||
|
statusFilter.value = 'unassigned';
|
||||||
|
statusFilter.dispatchEvent('change');
|
||||||
|
await flush();
|
||||||
|
if (!walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'assignment-unassigned')) {
|
||||||
|
throw new Error('unassigned filter did not render the unassigned capture');
|
||||||
|
}
|
||||||
|
if (walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'assignment-client-processed')) {
|
||||||
|
throw new Error('unassigned filter leaked an assigned capture');
|
||||||
|
}
|
||||||
|
statusFilter.value = 'all';
|
||||||
|
statusFilter.dispatchEvent('change');
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const assignmentSelect = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-assignment') === 'assignment-unassigned');
|
||||||
|
if (!assignmentSelect) throw new Error('workspace assignment control was not rendered');
|
||||||
|
assignmentSelect.value = 'ClientA';
|
||||||
|
assignmentSelect.dispatchEvent('change');
|
||||||
|
await flush();
|
||||||
|
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.workspaceRootPath === 'ClientA')) {
|
||||||
|
throw new Error('assignment did not persist ClientA workspace root path');
|
||||||
|
}
|
||||||
|
const reassignmentSelect = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-assignment') === 'assignment-unassigned');
|
||||||
|
reassignmentSelect.value = 'Project';
|
||||||
|
reassignmentSelect.dispatchEvent('change');
|
||||||
|
await flush();
|
||||||
|
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.workspaceRootPath === 'Project')) {
|
||||||
|
throw new Error('reassignment did not persist Project workspace root path');
|
||||||
|
}
|
||||||
|
const clearAssignmentButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'clear-assignment');
|
||||||
|
if (!clearAssignmentButton) throw new Error('clear assignment action was not rendered');
|
||||||
|
clearAssignmentButton.click();
|
||||||
|
await flush();
|
||||||
|
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && !capture.workspaceRootPath)) {
|
||||||
|
throw new Error('clear assignment did not make the capture unassigned');
|
||||||
|
}
|
||||||
|
const processedButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'toggle-processed');
|
||||||
|
if (!processedButton) throw new Error('processed state action was not rendered');
|
||||||
|
processedButton.click();
|
||||||
|
await flush();
|
||||||
|
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === true)) {
|
||||||
|
throw new Error('mark processed did not persist state');
|
||||||
|
}
|
||||||
|
const unprocessedButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'toggle-processed');
|
||||||
|
unprocessedButton.click();
|
||||||
|
await flush();
|
||||||
|
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === false)) {
|
||||||
|
throw new Error('mark unprocessed did not persist state');
|
||||||
|
}
|
||||||
|
const deleteButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'remove');
|
||||||
|
deleteButton.click();
|
||||||
|
await flush();
|
||||||
|
if (assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned')) {
|
||||||
|
throw new Error('delete did not remove the capture from global storage');
|
||||||
|
}
|
||||||
|
component.unmount && component.unmount(assignmentView.container);
|
||||||
|
|
||||||
const conversionApi = makeApi({
|
const conversionApi = makeApi({
|
||||||
'captures:workspace:Project': [{
|
'captures:workspace:Project': [{
|
||||||
captureId: 'convert-selection',
|
captureId: 'convert-selection',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue