feat: unify frontend capture pipeline
This commit is contained in:
parent
9e70e36f7f
commit
c1dfc456ec
|
|
@ -138,6 +138,8 @@
|
||||||
let dragIds = []
|
let dragIds = []
|
||||||
let dropRootValid = false
|
let dropRootValid = false
|
||||||
let inboxDropValid = false
|
let inboxDropValid = false
|
||||||
|
let captureDropActive = false
|
||||||
|
let captureDropLabel = ''
|
||||||
|
|
||||||
let showConfirm = false
|
let showConfirm = false
|
||||||
let confirmTitle = ''
|
let confirmTitle = ''
|
||||||
|
|
@ -219,6 +221,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeydown)
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
window.addEventListener('paste', handleGlobalPaste)
|
||||||
|
window.addEventListener('dragover', handleGlobalDragOver)
|
||||||
|
window.addEventListener('dragleave', handleGlobalDragLeave)
|
||||||
|
window.addEventListener('drop', handleGlobalDrop)
|
||||||
|
|
||||||
loading = false
|
loading = false
|
||||||
loadSyncStatus()
|
loadSyncStatus()
|
||||||
|
|
@ -227,6 +233,10 @@
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (unlistenDrop) unlistenDrop()
|
if (unlistenDrop) unlistenDrop()
|
||||||
window.removeEventListener('keydown', handleKeydown)
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
window.removeEventListener('paste', handleGlobalPaste)
|
||||||
|
window.removeEventListener('dragover', handleGlobalDragOver)
|
||||||
|
window.removeEventListener('dragleave', handleGlobalDragLeave)
|
||||||
|
window.removeEventListener('drop', handleGlobalDrop)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ===== System view / Node selection =====
|
// ===== System view / Node selection =====
|
||||||
|
|
@ -544,6 +554,7 @@
|
||||||
// ===== Drag-and-drop =====
|
// ===== Drag-and-drop =====
|
||||||
|
|
||||||
function onDragStart(e, id) {
|
function onDragStart(e, id) {
|
||||||
|
e.stopPropagation()
|
||||||
const ids = selectedIds.includes(id) ? selectedIds : [id]
|
const ids = selectedIds.includes(id) ? selectedIds : [id]
|
||||||
dragIds = ids
|
dragIds = ids
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
|
@ -554,12 +565,14 @@
|
||||||
const item = fileItems.find(x => x.id === folderId)
|
const item = fileItems.find(x => x.id === folderId)
|
||||||
if (item && item.type === 'folder') {
|
if (item && item.type === 'folder') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
e.dataTransfer.dropEffect = 'move'
|
e.dataTransfer.dropEffect = 'move'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDrop(e, folderId) {
|
async function onDrop(e, folderId) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
if (dragIds.length === 0) return
|
if (dragIds.length === 0) return
|
||||||
for (const id of dragIds) {
|
for (const id of dragIds) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -573,15 +586,14 @@
|
||||||
|
|
||||||
// ===== Keyboard =====
|
// ===== Keyboard =====
|
||||||
|
|
||||||
function handleKeydown(e) {
|
function isEditableTarget(target) {
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
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
|
if (activeTab !== 'files') return
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
|
@ -814,6 +826,7 @@
|
||||||
|
|
||||||
async function handleDropRoot(e) {
|
async function handleDropRoot(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
try {
|
try {
|
||||||
const draggedId = e.dataTransfer.getData('text/plain')
|
const draggedId = e.dataTransfer.getData('text/plain')
|
||||||
if (!draggedId) return
|
if (!draggedId) return
|
||||||
|
|
@ -1342,16 +1355,7 @@
|
||||||
// ===== Drag-and-drop =====
|
// ===== Drag-and-drop =====
|
||||||
async function onFilesDropped(paths) {
|
async function onFilesDropped(paths) {
|
||||||
if (!paths || paths.length === 0) return
|
if (!paths || paths.length === 0) return
|
||||||
if (selectedSection === 'inbox') {
|
await captureDroppedPaths(paths, 'drop')
|
||||||
await captureDroppedPaths(paths)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!selectedNode) {
|
|
||||||
error = t('error.selectCaseFirst')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const path = paths[0]
|
|
||||||
await startImport(selectedNode.id, path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Helpers =====
|
// ===== Helpers =====
|
||||||
|
|
@ -1435,56 +1439,142 @@
|
||||||
reader.readAsDataURL(blob)
|
reader.readAsDataURL(blob)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async function captureClipboardFile() {
|
|
||||||
if (!navigator.clipboard || typeof navigator.clipboard.read !== 'function') return false
|
function resolveCaptureContext() {
|
||||||
const items = await navigator.clipboard.read()
|
if (selectedNode && selectedNode.id) {
|
||||||
for (const item of items || []) {
|
return {
|
||||||
const type = (item.types || []).find(value => value.startsWith('image/'))
|
contextType: 'node',
|
||||||
if (!type) continue
|
nodeId: selectedNode.id,
|
||||||
const blob = await item.getType(type)
|
suggestedTargetNodeId: selectedNode.id,
|
||||||
const filename = blob.name || `clipboard.${extensionForMime(type)}`
|
}
|
||||||
const data = await blobToBase64(blob)
|
}
|
||||||
const captured = await wailsCall('CaptureFileData', filename, data)
|
if (selectedSection) {
|
||||||
addInboxCapture(captured)
|
return {
|
||||||
inboxCaptureStatus = t('inbox.captured')
|
contextType: 'section',
|
||||||
|
section: selectedSection,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
contextType: 'global',
|
||||||
|
section: 'root',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
return true
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
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() {
|
async function captureClipboard() {
|
||||||
if (inboxCaptureBusy) return
|
if (inboxCaptureBusy) return
|
||||||
inboxCaptureStatus = ''
|
inboxCaptureStatus = ''
|
||||||
if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') {
|
|
||||||
error = t('inbox.clipboardUnavailable')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inboxCaptureBusy = true
|
inboxCaptureBusy = true
|
||||||
try {
|
try {
|
||||||
if (await captureClipboardFile()) return
|
const item = await wailsCall('CaptureClipboardTextWithContext', captureContextJSON())
|
||||||
const text = (await navigator.clipboard.readText()).trim()
|
await refreshInboxAfterCapture(item)
|
||||||
if (!text) {
|
|
||||||
inboxCaptureStatus = t('inbox.clipboardEmpty')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const item = looksLikeURL(text)
|
|
||||||
? await wailsCall('CaptureURL', text, '')
|
|
||||||
: await wailsCall('CaptureText', text)
|
|
||||||
addInboxCapture(item)
|
|
||||||
inboxCaptureStatus = t('inbox.captured')
|
inboxCaptureStatus = t('inbox.captured')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e)
|
error = String(e).includes('clipboard is empty') ? t('inbox.clipboardEmpty') : t('inbox.clipboardUnavailable')
|
||||||
} finally {
|
} finally {
|
||||||
inboxCaptureBusy = false
|
inboxCaptureBusy = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function captureDroppedPaths(paths) {
|
async function captureDroppedPaths(paths, source = 'drop') {
|
||||||
if (inboxCaptureBusy) return
|
if (inboxCaptureBusy) return
|
||||||
inboxCaptureBusy = true
|
inboxCaptureBusy = true
|
||||||
inboxCaptureStatus = ''
|
inboxCaptureStatus = ''
|
||||||
try {
|
try {
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
const item = await wailsCall('CapturePath', path)
|
const item = await wailsCall('CapturePathWithContext', path, source, captureContextJSON())
|
||||||
addInboxCapture(item)
|
await refreshInboxAfterCapture(item)
|
||||||
}
|
}
|
||||||
inboxCaptureStatus = t('inbox.captured')
|
inboxCaptureStatus = t('inbox.captured')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -1494,6 +1584,50 @@
|
||||||
inboxDropValid = false
|
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) {
|
function handleInboxDragOver(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.dataTransfer.dropEffect = 'copy'
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
|
@ -1504,11 +1638,10 @@
|
||||||
}
|
}
|
||||||
async function handleInboxDrop(e) {
|
async function handleInboxDrop(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
inboxDropValid = false
|
inboxDropValid = false
|
||||||
const paths = Array.from(e.dataTransfer?.files || [])
|
const captured = await captureTransferData(e.dataTransfer, 'drop')
|
||||||
.map(file => file.path || file.webkitRelativePath || '')
|
if (captured) inboxCaptureStatus = t('inbox.captured')
|
||||||
.filter(Boolean)
|
|
||||||
if (paths.length > 0) await captureDroppedPaths(paths)
|
|
||||||
}
|
}
|
||||||
function openAssignInbox(item) {
|
function openAssignInbox(item) {
|
||||||
assignInboxItem = item
|
assignInboxItem = item
|
||||||
|
|
@ -1772,6 +1905,11 @@
|
||||||
<VaultRecovery vaultPath={startupStatus?.vaultPath || ''} onComplete={onRecoveryComplete} />
|
<VaultRecovery vaultPath={startupStatus?.vaultPath || ''} onComplete={onRecoveryComplete} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="app">
|
<div class="app">
|
||||||
|
{#if captureDropActive}
|
||||||
|
<div class="capture-drop-overlay">
|
||||||
|
<div class="capture-drop-box">{captureDropLabel}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
|
|
@ -2915,6 +3053,8 @@
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::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; }
|
.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 */
|
||||||
.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; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,13 @@ export default {
|
||||||
'capture.kind.folder': 'Folder',
|
'capture.kind.folder': 'Folder',
|
||||||
'capture.kind.image': 'Image',
|
'capture.kind.image': 'Image',
|
||||||
'capture.source.clipboard': 'Clipboard',
|
'capture.source.clipboard': 'Clipboard',
|
||||||
|
'capture.source.clipboard_button': 'Clipboard button',
|
||||||
'capture.source.drop': 'Drop',
|
'capture.source.drop': 'Drop',
|
||||||
|
'capture.source.paste': 'Paste',
|
||||||
'capture.source.browser': 'Browser',
|
'capture.source.browser': 'Browser',
|
||||||
'capture.source.manual': 'Manual',
|
'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.openFolder': 'Open trash folder',
|
||||||
'trash.empty': 'Trash is empty',
|
'trash.empty': 'Trash is empty',
|
||||||
'trash.deletedNodes': 'Deleted items',
|
'trash.deletedNodes': 'Deleted items',
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,13 @@ export default {
|
||||||
'capture.kind.folder': 'Папка',
|
'capture.kind.folder': 'Папка',
|
||||||
'capture.kind.image': 'Изображение',
|
'capture.kind.image': 'Изображение',
|
||||||
'capture.source.clipboard': 'Буфер обмена',
|
'capture.source.clipboard': 'Буфер обмена',
|
||||||
|
'capture.source.clipboard_button': 'Кнопка буфера',
|
||||||
'capture.source.drop': 'Перетаскивание',
|
'capture.source.drop': 'Перетаскивание',
|
||||||
|
'capture.source.paste': 'Вставка',
|
||||||
'capture.source.browser': 'Браузер',
|
'capture.source.browser': 'Браузер',
|
||||||
'capture.source.manual': 'Вручную',
|
'capture.source.manual': 'Вручную',
|
||||||
|
'capture.dropOverlayNode': 'Будет добавлено в Неразобранное для: {title}',
|
||||||
|
'capture.dropOverlayGlobal': 'Будет добавлено в глобальное Неразобранное',
|
||||||
|
|
||||||
'trash.openFolder': 'Открыть папку корзины',
|
'trash.openFolder': 'Открыть папку корзины',
|
||||||
'trash.empty': 'Корзина пуста',
|
'trash.empty': 'Корзина пуста',
|
||||||
|
|
|
||||||
|
|
@ -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 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 setClipboardText(cdp, 'https://example.test/from-clipboard')
|
||||||
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
|
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 assertText(cdp, 'Ссылка', 'inbox: clipboard URL kind visible')
|
||||||
await emitDroppedFiles(cdp, ['/tmp/smoke-drop-folder'])
|
await emitDroppedFiles(cdp, ['/tmp/smoke-drop-folder'])
|
||||||
await assertText(cdp, 'smoke-drop-folder', 'inbox: dropped folder captured')
|
await assertText(cdp, 'smoke-drop-folder', 'inbox: dropped folder captured')
|
||||||
await assertText(cdp, 'Перетаскивание', 'inbox: dropped source visible')
|
await assertText(cdp, 'Перетаскивание', 'inbox: dropped source visible')
|
||||||
await setClipboardImage(cdp, 'pasted-smoke.png', 'image/png', 'c21va2UtaW1hZ2U=')
|
await dispatchPasteImage(cdp, 'pasted-smoke.png', 'image/png', 'c21va2UtaW1hZ2U=')
|
||||||
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
|
|
||||||
await assertText(cdp, 'pasted-smoke.png', 'inbox: clipboard image captured')
|
await assertText(cdp, 'pasted-smoke.png', 'inbox: clipboard image captured')
|
||||||
await assertText(cdp, 'Изображение', 'inbox: clipboard image kind visible')
|
await assertText(cdp, 'Изображение', 'inbox: clipboard image kind visible')
|
||||||
await clickInboxItemButton(cdp, 'smoke-drop-folder', 'Разложить')
|
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) {
|
async function emitDroppedFiles(cdp, paths) {
|
||||||
await cdp.send('Runtime.evaluate', {
|
await cdp.send('Runtime.evaluate', {
|
||||||
expression: `window.__VERSTAK_GUI_SMOKE__.dropFiles(${JSON.stringify(paths)})`,
|
expression: `window.__VERSTAK_GUI_SMOKE__.dropFiles(${JSON.stringify(paths)})`,
|
||||||
|
|
@ -684,6 +701,9 @@ function wailsMockSource() {
|
||||||
worklog: {
|
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' }],
|
'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 = {
|
const fileNodeDetails = {
|
||||||
|
|
@ -718,6 +738,50 @@ function wailsMockSource() {
|
||||||
return node?.children || [];
|
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) {
|
function detachNode(id, items = state.nodes) {
|
||||||
const idx = items.findIndex((node) => node.id === id);
|
const idx = items.findIndex((node) => node.id === id);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
|
|
@ -783,33 +847,55 @@ function wailsMockSource() {
|
||||||
],
|
],
|
||||||
ListWorkspaceTree: async () => clone(state.nodes.filter((node) => node.captureInbox !== true)),
|
ListWorkspaceTree: async () => clone(state.nodes.filter((node) => node.captureInbox !== true)),
|
||||||
ListWorkspaceChildren: async (id) => clone(childrenOf(id)),
|
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 || '' }))),
|
ListInboxNodes: async () => clone(state.nodes.filter((node) => !node.parent_id && node.captureInbox === true).map(inboxDTO)),
|
||||||
CaptureText: async (text) => {
|
ListInboxNodesForTarget: async (nodeId) => clone(state.nodes.filter((node) => node.captureInbox === true && (node.captureContextNodeId === nodeId || node.suggestedTargetNodeId === nodeId)).map(inboxDTO)),
|
||||||
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: [] };
|
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);
|
state.nodes.push(node);
|
||||||
return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource });
|
return clone(inboxDTO(node));
|
||||||
},
|
},
|
||||||
CaptureURL: async (url, title) => {
|
CaptureText: async (text) => App.CaptureTextWithContext(text, 'clipboard', '{}'),
|
||||||
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: [] };
|
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);
|
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 title = String(sourcePath || '').split('/').filter(Boolean).pop() || 'Dropped file';
|
||||||
const kind = title.includes('folder') ? 'folder' : '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);
|
state.nodes.push(node);
|
||||||
return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource });
|
return clone(inboxDTO(node));
|
||||||
},
|
},
|
||||||
CaptureFileData: async (filename) => {
|
CapturePath: async (sourcePath) => App.CapturePathWithContext(sourcePath, 'drop', '{}'),
|
||||||
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: [] };
|
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);
|
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 node = detachNode(nodeId);
|
||||||
const parent = findNode(targetParentId);
|
const parent = findNode(targetParentId);
|
||||||
if (!node || !parent) throw new Error('assign target not found');
|
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.captureInbox = false;
|
||||||
node.captureKind = '';
|
node.captureKind = '';
|
||||||
node.captureSource = '';
|
node.captureSource = '';
|
||||||
|
|
@ -819,11 +905,32 @@ function wailsMockSource() {
|
||||||
parent.has_children = true;
|
parent.has_children = true;
|
||||||
return clone(node);
|
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) => {
|
DeleteInboxNode: async (nodeId) => {
|
||||||
const node = detachNode(nodeId);
|
const node = detachNode(nodeId);
|
||||||
if (!node) throw new Error('inbox node not found');
|
if (!node) throw new Error('inbox node not found');
|
||||||
return true;
|
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({
|
ListTrash: async () => clone({
|
||||||
trashPath: '/tmp/verstak-smoke-vault/.verstak/trash',
|
trashPath: '/tmp/verstak-smoke-vault/.verstak/trash',
|
||||||
nodes: [{ id: 'node-trash', title: 'Trash Smoke Folder', type: 'folder', fsPath: 'Trash Smoke Folder', deletedAt: now }],
|
nodes: [{ id: 'node-trash', title: 'Trash Smoke Folder', type: 'folder', fsPath: 'Trash Smoke Folder', deletedAt: now }],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue