feat: unify frontend capture pipeline

This commit is contained in:
mirivlad 2026-06-05 07:41:15 +08:00
parent 9e70e36f7f
commit c1dfc456ec
4 changed files with 325 additions and 70 deletions

View File

@ -138,6 +138,8 @@
let dragIds = []
let dropRootValid = false
let inboxDropValid = false
let captureDropActive = false
let captureDropLabel = ''
let showConfirm = false
let confirmTitle = ''
@ -219,6 +221,10 @@
}
window.addEventListener('keydown', handleKeydown)
window.addEventListener('paste', handleGlobalPaste)
window.addEventListener('dragover', handleGlobalDragOver)
window.addEventListener('dragleave', handleGlobalDragLeave)
window.addEventListener('drop', handleGlobalDrop)
loading = false
loadSyncStatus()
@ -227,6 +233,10 @@
onDestroy(() => {
if (unlistenDrop) unlistenDrop()
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('paste', handleGlobalPaste)
window.removeEventListener('dragover', handleGlobalDragOver)
window.removeEventListener('dragleave', handleGlobalDragLeave)
window.removeEventListener('drop', handleGlobalDrop)
})
// ===== System view / Node selection =====
@ -544,6 +554,7 @@
// ===== Drag-and-drop =====
function onDragStart(e, id) {
e.stopPropagation()
const ids = selectedIds.includes(id) ? selectedIds : [id]
dragIds = ids
e.dataTransfer.effectAllowed = 'move'
@ -554,12 +565,14 @@
const item = fileItems.find(x => x.id === folderId)
if (item && item.type === 'folder') {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
}
}
async function onDrop(e, folderId) {
e.preventDefault()
e.stopPropagation()
if (dragIds.length === 0) return
for (const id of dragIds) {
try {
@ -573,14 +586,13 @@
// ===== Keyboard =====
function handleKeydown(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
function isEditableTarget(target) {
if (!target || !(target instanceof Element)) return false
return !!target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
}
if (selectedSection === 'inbox' && (e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V')) {
e.preventDefault()
captureClipboard()
return
}
function handleKeydown(e) {
if (isEditableTarget(e.target)) return
if (activeTab !== 'files') return
@ -814,6 +826,7 @@
async function handleDropRoot(e) {
e.preventDefault()
e.stopPropagation()
try {
const draggedId = e.dataTransfer.getData('text/plain')
if (!draggedId) return
@ -1342,16 +1355,7 @@
// ===== Drag-and-drop =====
async function onFilesDropped(paths) {
if (!paths || paths.length === 0) return
if (selectedSection === 'inbox') {
await captureDroppedPaths(paths)
return
}
if (!selectedNode) {
error = t('error.selectCaseFirst')
return
}
const path = paths[0]
await startImport(selectedNode.id, path)
await captureDroppedPaths(paths, 'drop')
}
// ===== Helpers =====
@ -1435,56 +1439,142 @@
reader.readAsDataURL(blob)
})
}
async function captureClipboardFile() {
if (!navigator.clipboard || typeof navigator.clipboard.read !== 'function') return false
const items = await navigator.clipboard.read()
for (const item of items || []) {
const type = (item.types || []).find(value => value.startsWith('image/'))
if (!type) continue
const blob = await item.getType(type)
const filename = blob.name || `clipboard.${extensionForMime(type)}`
const data = await blobToBase64(blob)
const captured = await wailsCall('CaptureFileData', filename, data)
addInboxCapture(captured)
inboxCaptureStatus = t('inbox.captured')
return true
function resolveCaptureContext() {
if (selectedNode && selectedNode.id) {
return {
contextType: 'node',
nodeId: selectedNode.id,
suggestedTargetNodeId: selectedNode.id,
}
}
if (selectedSection) {
return {
contextType: 'section',
section: selectedSection,
}
}
return {
contextType: 'global',
section: 'root',
}
return false
}
function captureContextJSON() {
return JSON.stringify(resolveCaptureContext())
}
function captureContextLabel() {
if (selectedNode && selectedNode.id) {
return t('capture.dropOverlayNode', { title: selectedNode.title })
}
return t('capture.dropOverlayGlobal')
}
async function refreshInboxAfterCapture(item = null) {
if (item) addInboxCapture(item)
if (selectedSection === 'inbox') {
inboxNodes = await wailsCall('ListInboxNodes') || inboxNodes
}
}
async function captureTextPayload(text, source) {
const value = String(text || '').trim()
if (!value) return null
const item = looksLikeURL(value)
? await wailsCall('CaptureURLWithContext', value, '', source, captureContextJSON())
: await wailsCall('CaptureTextWithContext', value, source, captureContextJSON())
await refreshInboxAfterCapture(item)
return item
}
async function captureUrlPayload(url, title, source) {
const value = String(url || '').trim()
if (!value) return null
const item = await wailsCall('CaptureURLWithContext', value, title || '', source, captureContextJSON())
await refreshInboxAfterCapture(item)
return item
}
async function captureFilePayload(file, source) {
if (!file) return null
const path = file.path || file.webkitRelativePath || ''
if (path) {
const item = await wailsCall('CapturePathWithContext', path, source, captureContextJSON())
await refreshInboxAfterCapture(item)
return item
}
const data = await blobToBase64(file)
const item = await wailsCall('CaptureFileDataWithContext', file.name || `clipboard.${extensionForMime(file.type)}`, data, source, captureContextJSON())
await refreshInboxAfterCapture(item)
return item
}
function parseMozURL(value) {
const lines = String(value || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean)
if (!lines.length) return null
return { url: lines[0], title: lines[1] || '' }
}
function parseURIList(value) {
const lines = String(value || '').split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#'))
return lines[0] || ''
}
async function captureTransferData(dataTransfer, source) {
if (!dataTransfer) return false
let captured = false
const filesList = Array.from(dataTransfer.files || [])
for (const file of filesList) {
await captureFilePayload(file, source)
captured = true
}
const moz = dataTransfer.getData?.('text/x-moz-url')
if (moz) {
const parsed = parseMozURL(moz)
if (parsed && looksLikeURL(parsed.url)) {
await captureUrlPayload(parsed.url, parsed.title, source)
return true
}
}
const uri = dataTransfer.getData?.('text/uri-list')
if (uri) {
const url = parseURIList(uri)
if (looksLikeURL(url)) {
await captureUrlPayload(url, '', source)
return true
}
}
const text = dataTransfer.getData?.('text/plain')
if (String(text || '').trim()) {
await captureTextPayload(text, source)
captured = true
}
return captured
}
async function captureClipboard() {
if (inboxCaptureBusy) return
inboxCaptureStatus = ''
if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') {
error = t('inbox.clipboardUnavailable')
return
}
inboxCaptureBusy = true
try {
if (await captureClipboardFile()) return
const text = (await navigator.clipboard.readText()).trim()
if (!text) {
inboxCaptureStatus = t('inbox.clipboardEmpty')
return
}
const item = looksLikeURL(text)
? await wailsCall('CaptureURL', text, '')
: await wailsCall('CaptureText', text)
addInboxCapture(item)
const item = await wailsCall('CaptureClipboardTextWithContext', captureContextJSON())
await refreshInboxAfterCapture(item)
inboxCaptureStatus = t('inbox.captured')
} catch (e) {
error = String(e)
error = String(e).includes('clipboard is empty') ? t('inbox.clipboardEmpty') : t('inbox.clipboardUnavailable')
} finally {
inboxCaptureBusy = false
}
}
async function captureDroppedPaths(paths) {
async function captureDroppedPaths(paths, source = 'drop') {
if (inboxCaptureBusy) return
inboxCaptureBusy = true
inboxCaptureStatus = ''
try {
for (const path of paths) {
const item = await wailsCall('CapturePath', path)
addInboxCapture(item)
const item = await wailsCall('CapturePathWithContext', path, source, captureContextJSON())
await refreshInboxAfterCapture(item)
}
inboxCaptureStatus = t('inbox.captured')
} catch (e) {
@ -1494,6 +1584,50 @@
inboxDropValid = false
}
}
async function handleGlobalPaste(e) {
if (showFirstRun || showRecovery) return
if (isEditableTarget(e.target)) return
if (!e.clipboardData) return
try {
const captured = await captureTransferData(e.clipboardData, 'paste')
if (captured) {
e.preventDefault()
inboxCaptureStatus = t('inbox.captured')
}
} catch (err) {
error = String(err)
}
}
function hasExternalCaptureData(dataTransfer) {
const types = Array.from(dataTransfer?.types || [])
return types.includes('Files') ||
types.includes('text/uri-list') ||
types.includes('text/x-moz-url') ||
(types.includes('text/plain') && !types.includes('application/x-verstak-node'))
}
function handleGlobalDragOver(e) {
if (!hasExternalCaptureData(e.dataTransfer)) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
captureDropLabel = captureContextLabel()
captureDropActive = true
}
function handleGlobalDragLeave(e) {
if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
captureDropActive = false
}
}
async function handleGlobalDrop(e) {
if (!hasExternalCaptureData(e.dataTransfer)) return
e.preventDefault()
captureDropActive = false
try {
const captured = await captureTransferData(e.dataTransfer, 'drop')
if (captured) inboxCaptureStatus = t('inbox.captured')
} catch (err) {
error = String(err)
}
}
function handleInboxDragOver(e) {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
@ -1504,11 +1638,10 @@
}
async function handleInboxDrop(e) {
e.preventDefault()
e.stopPropagation()
inboxDropValid = false
const paths = Array.from(e.dataTransfer?.files || [])
.map(file => file.path || file.webkitRelativePath || '')
.filter(Boolean)
if (paths.length > 0) await captureDroppedPaths(paths)
const captured = await captureTransferData(e.dataTransfer, 'drop')
if (captured) inboxCaptureStatus = t('inbox.captured')
}
function openAssignInbox(item) {
assignInboxItem = item
@ -1772,6 +1905,11 @@
<VaultRecovery vaultPath={startupStatus?.vaultPath || ''} onComplete={onRecoveryComplete} />
{:else}
<div class="app">
{#if captureDropActive}
<div class="capture-drop-overlay">
<div class="capture-drop-box">{captureDropLabel}</div>
</div>
{/if}
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-brand">
@ -2915,6 +3053,8 @@
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.app { display: flex; width: 100vw; height: 100vh; overflow: hidden; background: #13131f; color: #e4e4ef; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; }
.capture-drop-overlay { position: fixed; inset: 0; z-index: 120; pointer-events: none; display: flex; align-items: center; justify-content: center; background: rgba(19, 19, 31, 0.42); border: 2px dashed #818cf8; }
.capture-drop-box { max-width: min(520px, calc(100vw - 48px)); padding: 14px 18px; border: 1px solid #3a3a5c; border-radius: 8px; background: #1a1a28; color: #e4e4ef; font-size: 14px; font-weight: 600; box-shadow: 0 12px 32px rgba(0,0,0,0.35); text-align: center; }
/* Sidebar */
.sidebar { width: 260px; min-width: 200px; height: 100vh; display: flex; flex-direction: column; background: #1a1a28; border-right: 1px solid #2a2a3c; flex-shrink: 0; overflow: hidden; }

View File

@ -42,9 +42,13 @@ export default {
'capture.kind.folder': 'Folder',
'capture.kind.image': 'Image',
'capture.source.clipboard': 'Clipboard',
'capture.source.clipboard_button': 'Clipboard button',
'capture.source.drop': 'Drop',
'capture.source.paste': 'Paste',
'capture.source.browser': 'Browser',
'capture.source.manual': 'Manual',
'capture.dropOverlayNode': 'Will be added to Inbox for: {title}',
'capture.dropOverlayGlobal': 'Will be added to global Inbox',
'trash.openFolder': 'Open trash folder',
'trash.empty': 'Trash is empty',
'trash.deletedNodes': 'Deleted items',

View File

@ -44,9 +44,13 @@ export default {
'capture.kind.folder': 'Папка',
'capture.kind.image': 'Изображение',
'capture.source.clipboard': 'Буфер обмена',
'capture.source.clipboard_button': 'Кнопка буфера',
'capture.source.drop': 'Перетаскивание',
'capture.source.paste': 'Вставка',
'capture.source.browser': 'Браузер',
'capture.source.manual': 'Вручную',
'capture.dropOverlayNode': 'Будет добавлено в Неразобранное для: {title}',
'capture.dropOverlayGlobal': 'Будет добавлено в глобальное Неразобранное',
'trash.openFolder': 'Открыть папку корзины',
'trash.empty': 'Корзина пуста',

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 }],