feat: unify frontend capture pipeline

This commit is contained in:
2026-06-05 07:41:15 +08:00
parent 9e70e36f7f
commit c1dfc456ec
4 changed files with 325 additions and 70 deletions
+124 -17
View File
@@ -142,13 +142,12 @@ async function runReadyScenario(cdp, url) {
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('Manual Root Item')`, 'inbox: manual root is hidden')
await setClipboardText(cdp, 'https://example.test/from-clipboard')
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
await assertText(cdp, 'https://example.test/from-clipboard', 'inbox: clipboard URL captured')
await assertText(cdp, 'example.test', 'inbox: clipboard URL captured')
await assertText(cdp, 'Ссылка', 'inbox: clipboard URL kind visible')
await emitDroppedFiles(cdp, ['/tmp/smoke-drop-folder'])
await assertText(cdp, 'smoke-drop-folder', 'inbox: dropped folder captured')
await assertText(cdp, 'Перетаскивание', 'inbox: dropped source visible')
await setClipboardImage(cdp, 'pasted-smoke.png', 'image/png', 'c21va2UtaW1hZ2U=')
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
await dispatchPasteImage(cdp, 'pasted-smoke.png', 'image/png', 'c21va2UtaW1hZ2U=')
await assertText(cdp, 'pasted-smoke.png', 'inbox: clipboard image captured')
await assertText(cdp, 'Изображение', 'inbox: clipboard image kind visible')
await clickInboxItemButton(cdp, 'smoke-drop-folder', 'Разложить')
@@ -379,6 +378,24 @@ async function setClipboardImage(cdp, name, type, base64) {
})
}
async function dispatchPasteImage(cdp, name, type, base64) {
await cdp.send('Runtime.evaluate', {
expression: `
(() => {
const bytes = Uint8Array.from(atob(${JSON.stringify(base64)}), c => c.charCodeAt(0));
const file = new File([bytes], ${JSON.stringify(name)}, { type: ${JSON.stringify(type)} });
const data = new DataTransfer();
data.items.add(file);
const event = new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data });
window.dispatchEvent(event);
})();
`,
awaitPromise: true,
returnByValue: true,
})
await sleep(300)
}
async function emitDroppedFiles(cdp, paths) {
await cdp.send('Runtime.evaluate', {
expression: `window.__VERSTAK_GUI_SMOKE__.dropFiles(${JSON.stringify(paths)})`,
@@ -684,6 +701,9 @@ function wailsMockSource() {
worklog: {
'node-project': [{ id: 'wl-1', nodeId: 'node-project', summary: 'Manual smoke entry', details: 'Smoke details', minutes: 45, billable: true, approximate: false, source: 'manual', createdAt: now, date: '2026-06-04' }],
},
links: {
'node-project': [],
},
};
const fileNodeDetails = {
@@ -718,6 +738,50 @@ function wailsMockSource() {
return node?.children || [];
}
function parseCaptureContext(contextJSON) {
let raw = {};
try { raw = JSON.parse(contextJSON || '{}') || {}; } catch {}
const out = {};
if (raw.contextType === 'node' && raw.nodeId) {
out.captureContextType = 'node';
out.captureContextNodeId = raw.nodeId;
out.suggestedTargetNodeId = raw.suggestedTargetNodeId || raw.nodeId;
out.captureContextLabel = findNode(raw.nodeId)?.title || raw.nodeId;
out.suggestedTargetLabel = findNode(out.suggestedTargetNodeId)?.title || out.suggestedTargetNodeId;
} else if (raw.contextType === 'section') {
out.captureContextType = 'section';
out.captureContextSection = raw.section || 'root';
out.captureContextLabel = out.captureContextSection;
} else {
out.captureContextType = 'global';
out.captureContextSection = raw.section || 'root';
}
return out;
}
function hostnameForURL(value) {
try { return new URL(value).hostname; } catch { return ''; }
}
function inboxDTO(node) {
return {
...node,
captureKind: node.captureKind || '',
sourceKind: node.sourceKind || node.captureKind || '',
captureSource: node.captureSource || '',
captureStatus: node.captureStatus || 'unresolved',
captureContextType: node.captureContextType || 'global',
captureContextNodeId: node.captureContextNodeId || '',
captureContextSection: node.captureContextSection || '',
suggestedTargetNodeId: node.suggestedTargetNodeId || '',
captureContextLabel: node.captureContextLabel || '',
suggestedTargetLabel: node.suggestedTargetLabel || '',
capturedAt: node.capturedAt || node.createdAt || '',
url: node.url || '',
hostname: node.hostname || '',
};
}
function detachNode(id, items = state.nodes) {
const idx = items.findIndex((node) => node.id === id);
if (idx >= 0) {
@@ -783,33 +847,55 @@ function wailsMockSource() {
],
ListWorkspaceTree: async () => clone(state.nodes.filter((node) => node.captureInbox !== true)),
ListWorkspaceChildren: async (id) => clone(childrenOf(id)),
ListInboxNodes: async () => clone(state.nodes.filter((node) => !node.parent_id && node.captureInbox === true).map((node) => ({ ...node, captureKind: node.captureKind || '', captureSource: node.captureSource || '' }))),
CaptureText: async (text) => {
const node = { id: 'node-capture-text-' + Date.now(), title: String(text || '').trim().split('\\n').find(Boolean) || 'Captured text', type: 'note', section: '', captureInbox: true, captureKind: 'text', captureSource: 'clipboard', createdAt: now, has_children: false, children: [] };
ListInboxNodes: async () => clone(state.nodes.filter((node) => !node.parent_id && node.captureInbox === true).map(inboxDTO)),
ListInboxNodesForTarget: async (nodeId) => clone(state.nodes.filter((node) => node.captureInbox === true && (node.captureContextNodeId === nodeId || node.suggestedTargetNodeId === nodeId)).map(inboxDTO)),
CaptureTextWithContext: async (text, source, contextJSON) => {
const ctx = parseCaptureContext(contextJSON);
const node = { id: 'node-capture-text-' + Date.now(), title: String(text || '').trim().split('\\n').find(Boolean) || 'Captured text', type: 'note', section: '', captureInbox: true, captureKind: 'text', sourceKind: 'text', captureSource: source || 'paste', captureStatus: 'unresolved', createdAt: now, capturedAt: now, has_children: false, children: [], ...ctx };
state.nodes.push(node);
return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource });
return clone(inboxDTO(node));
},
CaptureURL: async (url, title) => {
const node = { id: 'node-capture-url-' + Date.now(), title: title || url, type: 'note', section: '', captureInbox: true, captureKind: 'url', captureSource: 'clipboard', createdAt: now, has_children: false, children: [] };
CaptureText: async (text) => App.CaptureTextWithContext(text, 'clipboard', '{}'),
CaptureURLWithContext: async (url, title, source, contextJSON) => {
const ctx = parseCaptureContext(contextJSON);
const hostname = hostnameForURL(url);
const node = { id: 'node-capture-url-' + Date.now(), title: title || hostname || url, type: 'link', section: '', captureInbox: true, captureKind: 'url', sourceKind: 'url', captureSource: source || 'paste', captureStatus: 'unresolved', url, hostname, createdAt: now, capturedAt: now, has_children: false, children: [], ...ctx };
state.nodes.push(node);
return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource });
return clone(inboxDTO(node));
},
CapturePath: async (sourcePath) => {
CaptureURL: async (url, title) => App.CaptureURLWithContext(url, title, 'clipboard', '{}'),
CapturePathWithContext: async (sourcePath, source, contextJSON) => {
const ctx = parseCaptureContext(contextJSON);
const title = String(sourcePath || '').split('/').filter(Boolean).pop() || 'Dropped file';
const kind = title.includes('folder') ? 'folder' : 'file';
const node = { id: 'node-capture-path-' + Date.now(), title, type: kind === 'folder' ? 'folder' : 'file', section: '', captureInbox: true, captureKind: kind, captureSource: 'drop', createdAt: now, has_children: false, children: [] };
const node = { id: 'node-capture-path-' + Date.now(), title, type: kind === 'folder' ? 'folder' : 'file', section: '', captureInbox: true, captureKind: kind, sourceKind: kind, captureSource: source || 'drop', captureStatus: 'unresolved', createdAt: now, capturedAt: now, has_children: false, children: [], ...ctx };
state.nodes.push(node);
return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource });
return clone(inboxDTO(node));
},
CaptureFileData: async (filename) => {
const node = { id: 'node-capture-data-' + Date.now(), title: filename, type: 'file', section: '', captureInbox: true, captureKind: filename.endsWith('.png') ? 'image' : 'file', captureSource: 'clipboard', createdAt: now, has_children: false, children: [] };
CapturePath: async (sourcePath) => App.CapturePathWithContext(sourcePath, 'drop', '{}'),
CaptureFileDataWithContext: async (filename, _dataBase64, source, contextJSON) => {
const ctx = parseCaptureContext(contextJSON);
const node = { id: 'node-capture-data-' + Date.now(), title: filename, type: 'file', section: '', captureInbox: true, captureKind: filename.endsWith('.png') ? 'image' : 'file', sourceKind: filename.endsWith('.png') ? 'image' : 'file', captureSource: source || 'paste', captureStatus: 'unresolved', createdAt: now, capturedAt: now, has_children: false, children: [], ...ctx };
state.nodes.push(node);
return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource });
return clone(inboxDTO(node));
},
AssignInboxNode: async (nodeId, targetParentId) => {
CaptureFileData: async (filename, dataBase64) => App.CaptureFileDataWithContext(filename, dataBase64, 'clipboard', '{}'),
ReadClipboardText: async () => window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ || '',
CaptureClipboardTextWithContext: async (contextJSON) => {
const text = String(window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ || '').trim();
if (!text) throw new Error('clipboard is empty');
if (/^https?:\\/\\//.test(text)) return App.CaptureURLWithContext(text, '', 'clipboard_button', contextJSON);
return App.CaptureTextWithContext(text, 'clipboard_button', contextJSON);
},
ResolveInboxNode: async (nodeId, targetParentId) => {
const node = detachNode(nodeId);
const parent = findNode(targetParentId);
if (!node || !parent) throw new Error('assign target not found');
if (node.sourceKind === 'url' || node.captureKind === 'url') {
state.links[targetParentId] = state.links[targetParentId] || [];
state.links[targetParentId].push({ id: 'link-' + Date.now(), nodeId: targetParentId, title: node.title, url: node.url, hostname: node.hostname || hostnameForURL(node.url), note: '', source: node.captureSource, capturedAt: node.capturedAt || now, createdAt: now, updatedAt: now });
return clone(parent);
}
node.captureInbox = false;
node.captureKind = '';
node.captureSource = '';
@@ -819,11 +905,32 @@ function wailsMockSource() {
parent.has_children = true;
return clone(node);
},
ResolveInboxNodeHere: async (nodeId) => {
const node = findNode(nodeId);
if (!node?.suggestedTargetNodeId) throw new Error('suggested target is required');
return App.ResolveInboxNode(nodeId, node.suggestedTargetNodeId);
},
AssignInboxNode: async (nodeId, targetParentId) => App.ResolveInboxNode(nodeId, targetParentId),
DeleteInboxNode: async (nodeId) => {
const node = detachNode(nodeId);
if (!node) throw new Error('inbox node not found');
return true;
},
ListLinks: async (nodeId) => clone(state.links[nodeId] || []),
UpdateLink: async (id, title, url, note) => {
const list = Object.values(state.links).flat();
const link = list.find((item) => item.id === id);
if (!link) throw new Error('link not found');
Object.assign(link, { title, url, note, hostname: hostnameForURL(url), updatedAt: now });
return clone(link);
},
DeleteLink: async (id) => {
for (const key of Object.keys(state.links)) {
state.links[key] = state.links[key].filter((link) => link.id !== id);
}
return true;
},
OpenLink: async () => true,
ListTrash: async () => clone({
trashPath: '/tmp/verstak-smoke-vault/.verstak/trash',
nodes: [{ id: 'node-trash', title: 'Trash Smoke Folder', type: 'folder', fsPath: 'Trash Smoke Folder', deletedAt: now }],