feat: assign and delete inbox artifacts

This commit is contained in:
mirivlad 2026-06-05 02:15:27 +08:00
parent a96a316883
commit 6eaa4cda49
12 changed files with 324 additions and 8 deletions

View File

@ -1,5 +1,7 @@
package main package main
import "fmt"
type InboxNodeDTO struct { type InboxNodeDTO struct {
NodeDTO NodeDTO
CaptureKind string `json:"captureKind"` CaptureKind string `json:"captureKind"`
@ -32,6 +34,42 @@ func (a *App) ListInboxNodes() ([]InboxNodeDTO, error) {
return dtos, nil return dtos, nil
} }
func (a *App) AssignInboxNode(nodeID, targetParentID string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
if !a.isInboxCaptureNode(nodeID) {
return nil, fmt.Errorf("node is not an inbox artifact")
}
if targetParentID == "" {
return nil, fmt.Errorf("target parent is required")
}
if err := a.MoveNode(nodeID, targetParentID); err != nil {
return nil, err
}
if err := a.clearCaptureMeta(nodeID); err != nil {
return nil, err
}
dto, err := a.GetNodeDetail(nodeID)
if err != nil {
return nil, err
}
return dto, nil
}
func (a *App) DeleteInboxNode(nodeID string) error {
if err := a.requireVault(); err != nil {
return err
}
if !a.isInboxCaptureNode(nodeID) {
return fmt.Errorf("node is not an inbox artifact")
}
if err := a.DeleteNode(nodeID); err != nil {
return err
}
return a.clearCaptureMeta(nodeID)
}
func (a *App) filterInboxCaptureNodes(list []NodeDTO) []NodeDTO { func (a *App) filterInboxCaptureNodes(list []NodeDTO) []NodeDTO {
out := make([]NodeDTO, 0, len(list)) out := make([]NodeDTO, 0, len(list))
for _, item := range list { for _, item := range list {
@ -46,3 +84,8 @@ func (a *App) isInboxCaptureNode(nodeID string) bool {
v, ok, err := a.nodes.MetaGet(nodeID, "capture.inbox") v, ok, err := a.nodes.MetaGet(nodeID, "capture.inbox")
return err == nil && ok && v == "true" return err == nil && ok && v == "true"
} }
func (a *App) clearCaptureMeta(nodeID string) error {
_, err := a.db.Exec(`DELETE FROM node_meta WHERE node_id = ? AND key LIKE 'capture.%'`, nodeID)
return err
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -16,8 +16,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-D-CBbIRx.js"></script> <script type="module" crossorigin src="/assets/main-B7pwitR5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BgORPqqS.css"> <link rel="stylesheet" crossorigin href="/assets/main-CnGHmBj4.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -1,6 +1,11 @@
package main package main
import "testing" import (
"errors"
"testing"
"verstak/internal/core/nodes"
)
func TestListInboxNodesReturnsOnlyCapturedArtifacts(t *testing.T) { func TestListInboxNodesReturnsOnlyCapturedArtifacts(t *testing.T) {
app, _ := setupTestApp(t) app, _ := setupTestApp(t)
@ -76,3 +81,72 @@ func TestListInboxNodesReturnsOnlyCapturedArtifacts(t *testing.T) {
} }
} }
} }
func TestAssignInboxNodeMovesArtifactIntoCase(t *testing.T) {
app, _ := setupTestApp(t)
parent, err := app.CreateNodeFromTemplate("", "Target Case", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
captured, err := app.CaptureText("Captured task material")
if err != nil {
t.Fatalf("CaptureText: %v", err)
}
moved, err := app.AssignInboxNode(captured.ID, parent.ID)
if err != nil {
t.Fatalf("AssignInboxNode: %v", err)
}
if moved.ParentID == nil || *moved.ParentID != parent.ID {
t.Fatalf("ParentID = %v, want %q", moved.ParentID, parent.ID)
}
inbox, err := app.ListInboxNodes()
if err != nil {
t.Fatalf("ListInboxNodes: %v", err)
}
for _, item := range inbox {
if item.ID == captured.ID {
t.Fatal("assigned artifact should leave inbox")
}
}
notes, err := app.ListNotes(parent.ID)
if err != nil {
t.Fatalf("ListNotes: %v", err)
}
var found bool
for _, note := range notes {
if note.ID == captured.ID {
found = true
}
}
if !found {
t.Fatal("assigned text artifact missing from target case notes")
}
}
func TestDeleteInboxNodeRemovesArtifactFromInbox(t *testing.T) {
app, _ := setupTestApp(t)
captured, err := app.CaptureText("Delete this captured material")
if err != nil {
t.Fatalf("CaptureText: %v", err)
}
if err := app.DeleteInboxNode(captured.ID); err != nil {
t.Fatalf("DeleteInboxNode: %v", err)
}
inbox, err := app.ListInboxNodes()
if err != nil {
t.Fatalf("ListInboxNodes: %v", err)
}
for _, item := range inbox {
if item.ID == captured.ID {
t.Fatal("deleted artifact should leave inbox")
}
}
if _, err := app.nodes.GetActive(captured.ID); !errors.Is(err, nodes.ErrNotFound) {
t.Fatalf("GetActive err = %v, want ErrNotFound", err)
}
}

View File

@ -151,6 +151,12 @@
let renameId = '' let renameId = ''
let renameValue = '' let renameValue = ''
let renameError = '' let renameError = ''
let assignInboxItem = null
let inboxAssignQuery = ''
let inboxAssignResults = []
let inboxAssignTarget = null
let inboxAssignSearching = false
let inboxAssignTimer
// ===== Sync state ===== // ===== Sync state =====
let syncStatus = null let syncStatus = null
@ -1504,6 +1510,76 @@
.filter(Boolean) .filter(Boolean)
if (paths.length > 0) await captureDroppedPaths(paths) if (paths.length > 0) await captureDroppedPaths(paths)
} }
function openAssignInbox(item) {
assignInboxItem = item
inboxAssignQuery = ''
inboxAssignResults = []
inboxAssignTarget = null
}
function closeAssignInbox() {
assignInboxItem = null
inboxAssignQuery = ''
inboxAssignResults = []
inboxAssignTarget = null
inboxAssignSearching = false
}
function isAssignTarget(result) {
return ['case', 'client', 'project', 'folder', 'document', 'recipe'].includes(result.type)
}
async function searchInboxAssignTargets() {
const q = inboxAssignQuery.trim()
if (!q || q.length < 2) {
inboxAssignResults = []
return
}
inboxAssignSearching = true
try {
const results = await wailsCall('SearchNodes', q) || []
inboxAssignResults = results.filter(result => isAssignTarget(result) && result.id !== assignInboxItem?.id)
} catch (e) {
inboxAssignResults = []
} finally {
inboxAssignSearching = false
}
}
function onInboxAssignSearchInput(e) {
inboxAssignQuery = e.target.value
inboxAssignTarget = null
clearTimeout(inboxAssignTimer)
inboxAssignTimer = setTimeout(searchInboxAssignTargets, 200)
}
function selectInboxAssignTarget(result) {
inboxAssignTarget = result
inboxAssignQuery = result.path || result.title
inboxAssignResults = []
}
async function submitAssignInbox() {
if (!assignInboxItem || !inboxAssignTarget) return
try {
await wailsCall('AssignInboxNode', assignInboxItem.id, inboxAssignTarget.id)
inboxNodes = inboxNodes.filter(item => item.id !== assignInboxItem.id)
await reloadTreePreservingExpanded()
closeAssignInbox()
} catch (e) {
error = String(e)
}
}
function confirmDeleteInbox(item) {
openConfirm({
title: t('inbox.deleteTitle'),
message: t('inbox.deleteConfirm', { title: item.title }),
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
try {
await wailsCall('DeleteInboxNode', item.id)
inboxNodes = inboxNodes.filter(existing => existing.id !== item.id)
} catch (e) {
error = String(e)
}
}
})
}
function pluralize(n, one, few, many) { function pluralize(n, one, few, many) {
n = Math.abs(n) % 100 n = Math.abs(n) % 100
if (n >= 5 && n <= 20) return many if (n >= 5 && n <= 20) return many
@ -2166,8 +2242,10 @@
</span> </span>
</div> </div>
<div class="inbox-item-actions"> <div class="inbox-item-actions">
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => openAssignInbox(item)}>{t('inbox.assign')}</button>
<button class="btn btn-sm" on:click|stopPropagation={() => openNodeById(item.id)}>{t('common.open')}</button> <button class="btn btn-sm" on:click|stopPropagation={() => openNodeById(item.id)}>{t('common.open')}</button>
<button class="btn btn-sm" on:click|stopPropagation={() => openNodeFolder(item)}>{t('file.showInExplorer')}</button> <button class="btn btn-sm" on:click|stopPropagation={() => openNodeFolder(item)}>{t('file.showInExplorer')}</button>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => confirmDeleteInbox(item)}>{t('common.delete')}</button>
</div> </div>
</div> </div>
{/each} {/each}
@ -2783,6 +2861,39 @@
</div> </div>
{/if} {/if}
{#if assignInboxItem}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={closeAssignInbox} on:keydown={onKeyActivate(closeAssignInbox)}>
<div class="modal">
<h3>{t('inbox.assignTitle')}</h3>
<div class="create-context">{assignInboxItem.title}</div>
<div class="form-group assign-search">
<label><span class="label-text">{t('inbox.assignTarget')}</span>
<input type="text" placeholder={t('inbox.assignSearchPlaceholder')} bind:value={inboxAssignQuery}
on:input={onInboxAssignSearchInput}
on:keydown={(e) => e.key === 'Enter' && inboxAssignTarget && submitAssignInbox()} />
</label>
{#if inboxAssignResults.length > 0}
<div class="assign-search-results">
{#each inboxAssignResults as result}
<button class="assign-search-result" on:click={() => selectInboxAssignTarget(result)}>
<span>{result.path || result.title}</span>
<span>{nodeKindLabel(result.type)}</span>
</button>
{/each}
</div>
{/if}
</div>
{#if inboxAssignSearching}
<div class="assign-status">{t('common.loading')}</div>
{/if}
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitAssignInbox} disabled={!inboxAssignTarget}>{t('inbox.assign')}</button>
<button class="btn" on:click={closeAssignInbox}>{t('common.cancel')}</button>
</div>
</div>
</div>
{/if}
{#if showConfirm} {#if showConfirm}
<ConfirmModal <ConfirmModal
title={confirmTitle} title={confirmTitle}
@ -3058,6 +3169,12 @@
.form-group select { appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 32px; } .form-group select { appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 32px; }
.form-group input:focus, .form-group select:focus { outline: none; border-color: #6366f1; } .form-group input:focus, .form-group select:focus { outline: none; border-color: #6366f1; }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; } .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.assign-search { position: relative; }
.assign-search-results { position: absolute; left: 0; right: 0; top: 100%; z-index: 110; margin-top: 4px; background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 6px; max-height: 220px; overflow-y: auto; }
.assign-search-result { width: 100%; display: flex; justify-content: space-between; gap: 12px; padding: 8px 10px; border: 0; border-bottom: 1px solid #24243a; background: transparent; color: #e4e4ef; text-align: left; cursor: pointer; font-family: inherit; }
.assign-search-result:hover { background: #222238; }
.assign-search-result span:last-child { color: #8888a0; font-size: 12px; flex-shrink: 0; }
.assign-status { color: #8888a0; font-size: 12px; }
/* Buttons */ /* Buttons */
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; } .btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; }

View File

@ -30,6 +30,12 @@ export default {
'inbox.captured': 'Added', 'inbox.captured': 'Added',
'inbox.clipboardEmpty': 'Clipboard is empty', 'inbox.clipboardEmpty': 'Clipboard is empty',
'inbox.clipboardUnavailable': 'Clipboard is unavailable', 'inbox.clipboardUnavailable': 'Clipboard is unavailable',
'inbox.assign': 'Assign',
'inbox.assignTitle': 'Assign material',
'inbox.assignTarget': 'Case',
'inbox.assignSearchPlaceholder': 'Find case',
'inbox.deleteTitle': 'Delete material',
'inbox.deleteConfirm': 'Delete "{title}" from inbox?',
'capture.kind.text': 'Text', 'capture.kind.text': 'Text',
'capture.kind.url': 'Link', 'capture.kind.url': 'Link',
'capture.kind.file': 'File', 'capture.kind.file': 'File',

View File

@ -31,6 +31,12 @@ export default {
'inbox.captured': 'Добавлено', 'inbox.captured': 'Добавлено',
'inbox.clipboardEmpty': 'Буфер обмена пуст', 'inbox.clipboardEmpty': 'Буфер обмена пуст',
'inbox.clipboardUnavailable': 'Буфер обмена недоступен', 'inbox.clipboardUnavailable': 'Буфер обмена недоступен',
'inbox.assign': 'Разложить',
'inbox.assignTitle': 'Разложить материал',
'inbox.assignTarget': 'Дело',
'inbox.assignSearchPlaceholder': 'Найти дело',
'inbox.deleteTitle': 'Удалить материал',
'inbox.deleteConfirm': 'Удалить «{title}» из неразобранного?',
'capture.kind.text': 'Текст', 'capture.kind.text': 'Текст',
'capture.kind.url': 'Ссылка', 'capture.kind.url': 'Ссылка',

View File

@ -98,6 +98,14 @@ export function CaptureFileData(arg1, arg2) {
return window['go']['main']['App']['CaptureFileData'](arg1, arg2); return window['go']['main']['App']['CaptureFileData'](arg1, arg2);
} }
export function AssignInboxNode(arg1, arg2) {
return window['go']['main']['App']['AssignInboxNode'](arg1, arg2);
}
export function DeleteInboxNode(arg1) {
return window['go']['main']['App']['DeleteInboxNode'](arg1);
}
export function ListTrash() { export function ListTrash() {
return window['go']['main']['App']['ListTrash'](); return window['go']['main']['App']['ListTrash']();
} }

View File

@ -151,6 +151,18 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера') 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 waitForSelector(cdp, '.modal input[type="text"]')
await setInputValue(cdp, '.modal input[type="text"]', 'Smoke')
await waitForSelector(cdp, '.assign-search-result')
await assertEval(cdp, `document.querySelector('.assign-search-result')?.innerText.includes('Smoke Project')`, 'inbox: assign target search works')
await clickText(cdp, '.assign-search-result', 'Smoke Project')
await clickText(cdp, '.modal-actions .btn', 'Разложить')
await waitForGone(cdp, '.modal-overlay')
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('smoke-drop-folder')`, 'inbox: assigned item leaves inbox')
await clickInboxItemButton(cdp, 'pasted-smoke.png', 'Удалить')
await clickText(cdp, '.overlay .btn', 'Удалить')
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('pasted-smoke.png')`, 'inbox: deleted item leaves inbox')
await screenshot(cdp, 'inbox.png') await screenshot(cdp, 'inbox.png')
await clickText(cdp, '.inbox-item', 'Inbox Smoke Item') await clickText(cdp, '.inbox-item', 'Inbox Smoke Item')
await assertText(cdp, 'Inbox Smoke Item', 'inbox: item opens from list') await assertText(cdp, 'Inbox Smoke Item', 'inbox: item opens from list')
@ -392,6 +404,24 @@ async function clickFolderOpenButton(cdp, name) {
await sleep(300) await sleep(300)
} }
async function clickInboxItemButton(cdp, title, buttonText) {
const ok = await evalValue(cdp, `
(() => {
const norm = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const row = [...document.querySelectorAll('.inbox-item')]
.find((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(title)}));
if (!row) return false;
const btn = [...row.querySelectorAll('button')]
.find((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(buttonText)}));
if (!btn) return false;
btn.click();
return true;
})()
`)
if (!ok) throw new Error(`Inbox item button not found: ${title} -> ${buttonText}`)
await sleep(250)
}
async function evalValue(cdp, expression) { async function evalValue(cdp, expression) {
const result = await cdp.send('Runtime.evaluate', { const result = await cdp.send('Runtime.evaluate', {
expression, expression,
@ -688,6 +718,20 @@ function wailsMockSource() {
return node?.children || []; return node?.children || [];
} }
function detachNode(id, items = state.nodes) {
const idx = items.findIndex((node) => node.id === id);
if (idx >= 0) {
const [node] = items.splice(idx, 1);
return node;
}
for (const item of items) {
if (!item.children) continue;
const found = detachNode(id, item.children);
if (found) return found;
}
return null;
}
function readyStatus() { function readyStatus() {
return { return {
status: 'ready', status: 'ready',
@ -762,6 +806,24 @@ function wailsMockSource() {
state.nodes.push(node); state.nodes.push(node);
return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource }); return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource });
}, },
AssignInboxNode: async (nodeId, targetParentId) => {
const node = detachNode(nodeId);
const parent = findNode(targetParentId);
if (!node || !parent) throw new Error('assign target not found');
node.captureInbox = false;
node.captureKind = '';
node.captureSource = '';
node.parent_id = targetParentId;
parent.children = parent.children || [];
parent.children.push(node);
parent.has_children = true;
return clone(node);
},
DeleteInboxNode: async (nodeId) => {
const node = detachNode(nodeId);
if (!node) throw new Error('inbox node not found');
return 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 }],
@ -773,7 +835,7 @@ function wailsMockSource() {
SetTemplateEnabled: async () => true, SetTemplateEnabled: async () => true,
GetNodeDetail: async (id) => clone(findNode(id) || fileNodeDetails[id] || null), GetNodeDetail: async (id) => clone(findNode(id) || fileNodeDetails[id] || null),
GetNodeTitle: async (id) => findNode(id)?.title || '', GetNodeTitle: async (id) => findNode(id)?.title || '',
SearchNodes: async (query) => allNodes().filter((node) => node.title.toLowerCase().includes(String(query || '').toLowerCase())).map((node) => ({ id: node.id, title: node.title, path: '/Smoke/' + node.title })), SearchNodes: async (query) => allNodes().filter((node) => node.title.toLowerCase().includes(String(query || '').toLowerCase())).map((node) => ({ id: node.id, title: node.title, path: '/Smoke/' + node.title, type: node.type })),
CreateNodeFromTemplate: async (parentId, title, templateId) => { CreateNodeFromTemplate: async (parentId, title, templateId) => {
const id = 'node-created-' + Date.now(); const id = 'node-created-' + Date.now();
const parent = parentId ? findNode(parentId) : null; const parent = parentId ? findNode(parentId) : null;