diff --git a/cmd/verstak-gui/bindings_capture.go b/cmd/verstak-gui/bindings_capture.go index 8df03c6..4eb6343 100644 --- a/cmd/verstak-gui/bindings_capture.go +++ b/cmd/verstak-gui/bindings_capture.go @@ -2,8 +2,10 @@ package main import ( "encoding/base64" + "encoding/json" "fmt" "mime" + "net/url" "os" "path/filepath" "strings" @@ -17,7 +19,18 @@ import ( "verstak/internal/core/util" ) +type CaptureContextDTO struct { + ContextType string `json:"contextType"` + NodeID string `json:"nodeId,omitempty"` + Section string `json:"section,omitempty"` + SuggestedTargetNodeID string `json:"suggestedTargetNodeId,omitempty"` +} + func (a *App) CaptureText(text string) (*InboxNodeDTO, error) { + return a.CaptureTextWithContext(text, "clipboard", "") +} + +func (a *App) CaptureTextWithContext(text, source, contextJSON string) (*InboxNodeDTO, error) { if err := a.requireVault(); err != nil { return nil, err } @@ -27,10 +40,15 @@ func (a *App) CaptureText(text string) (*InboxNodeDTO, error) { } title := firstLineTitle(text, "Captured text") content := "# " + title + "\n\n" + text + "\n" - return a.createCaptureNote(title, content, "text", "clipboard") + ctx := parseCaptureContext(contextJSON) + return a.createCaptureNote(title, content, "text", source, ctx) } func (a *App) CaptureURL(rawURL, title string) (*InboxNodeDTO, error) { + return a.CaptureURLWithContext(rawURL, title, "clipboard", "") +} + +func (a *App) CaptureURLWithContext(rawURL, title, source, contextJSON string) (*InboxNodeDTO, error) { if err := a.requireVault(); err != nil { return nil, err } @@ -44,10 +62,26 @@ func (a *App) CaptureURL(rawURL, title string) (*InboxNodeDTO, error) { } title = firstLineTitle(title, rawURL) content := "# " + title + "\n\n" + rawURL + "\n" - return a.createCaptureNote(title, content, "url", "clipboard") + ctx := parseCaptureContext(contextJSON) + dto, err := a.createCaptureNote(title, content, "url", source, ctx) + if err != nil { + return nil, err + } + _ = a.nodes.MetaSet(dto.ID, "capture.url", rawURL) + _ = a.nodes.MetaSet(dto.ID, "capture.title", title) + _ = a.nodes.MetaSet(dto.ID, "capture.hostname", hostnameForURL(rawURL)) + node, err := a.nodes.GetActive(dto.ID) + if err != nil { + return nil, err + } + return a.inboxNodeDTO(node) } func (a *App) CapturePath(sourcePath string) (*InboxNodeDTO, error) { + return a.CapturePathWithContext(sourcePath, "drop", "") +} + +func (a *App) CapturePathWithContext(sourcePath, source, contextJSON string) (*InboxNodeDTO, error) { if err := a.requireVault(); err != nil { return nil, err } @@ -98,7 +132,8 @@ func (a *App) CapturePath(sourcePath string) (*InboxNodeDTO, error) { } } - if err := a.setCaptureMeta(node.ID, kind, "drop"); err != nil { + ctx := parseCaptureContext(contextJSON) + if err := a.setCaptureMeta(node.ID, kind, source, ctx); err != nil { return nil, err } target := activity.TargetFile @@ -115,6 +150,10 @@ func (a *App) CapturePath(sourcePath string) (*InboxNodeDTO, error) { } func (a *App) CaptureFileData(filename, dataBase64 string) (*InboxNodeDTO, error) { + return a.CaptureFileDataWithContext(filename, dataBase64, "clipboard", "") +} + +func (a *App) CaptureFileDataWithContext(filename, dataBase64, source, contextJSON string) (*InboxNodeDTO, error) { if err := a.requireVault(); err != nil { return nil, err } @@ -167,7 +206,8 @@ func (a *App) CaptureFileData(filename, dataBase64 string) (*InboxNodeDTO, error fileRec.Size, fileRec.MIME, fileRec.CreatedAt.Format(time.RFC3339), fileRec.UpdatedAt.Format(time.RFC3339)); err != nil { return nil, fmt.Errorf("insert capture file data: %w", err) } - if err := a.setCaptureMeta(node.ID, captureKindForFilename(filename), "clipboard"); err != nil { + ctx := parseCaptureContext(contextJSON) + if err := a.setCaptureMeta(node.ID, captureKindForFilename(filename), source, ctx); err != nil { return nil, err } _ = a.activity.Record("", activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, `{"capture":true}`) @@ -175,7 +215,7 @@ func (a *App) CaptureFileData(filename, dataBase64 string) (*InboxNodeDTO, error return a.inboxNodeDTO(node) } -func (a *App) createCaptureNote(title, content, kind, source string) (*InboxNodeDTO, error) { +func (a *App) createCaptureNote(title, content, kind, source string, ctx CaptureContextDTO) (*InboxNodeDTO, error) { node, err := a.nodes.Create(nil, nodes.TypeNote, title, 0, "", "") if err != nil { return nil, fmt.Errorf("create node: %w", err) @@ -213,7 +253,7 @@ func (a *App) createCaptureNote(title, content, kind, source string) (*InboxNode if _, err := a.db.Exec(`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, node.ID, fileRec.ID, "markdown"); err != nil { return nil, fmt.Errorf("insert capture note: %w", err) } - if err := a.setCaptureMeta(node.ID, kind, source); err != nil { + if err := a.setCaptureMeta(node.ID, kind, source, ctx); err != nil { return nil, err } @@ -233,16 +273,44 @@ func (a *App) captureStagingDir(node *nodes.Node) (string, string, error) { return rel, abs, nil } -func (a *App) setCaptureMeta(nodeID, kind, source string) error { +func (a *App) setCaptureMeta(nodeID, kind, source string, ctx CaptureContextDTO) error { + if source == "" { + source = "clipboard" + } + ctx = normalizeCaptureContext(ctx) if err := a.nodes.MetaSet(nodeID, "capture.inbox", "true"); err != nil { return err } + if err := a.nodes.MetaSet(nodeID, "capture.status", "unresolved"); err != nil { + return err + } if err := a.nodes.MetaSet(nodeID, "capture.kind", kind); err != nil { return err } + if err := a.nodes.MetaSet(nodeID, "capture.source_kind", kind); err != nil { + return err + } if err := a.nodes.MetaSet(nodeID, "capture.source", source); err != nil { return err } + if err := a.nodes.MetaSet(nodeID, "capture.context_type", ctx.ContextType); err != nil { + return err + } + if ctx.NodeID != "" { + if err := a.nodes.MetaSet(nodeID, "capture.context_node_id", ctx.NodeID); err != nil { + return err + } + } + if ctx.Section != "" { + if err := a.nodes.MetaSet(nodeID, "capture.context_section", ctx.Section); err != nil { + return err + } + } + if ctx.SuggestedTargetNodeID != "" { + if err := a.nodes.MetaSet(nodeID, "capture.suggested_target_node_id", ctx.SuggestedTargetNodeID); err != nil { + return err + } + } return a.nodes.MetaSet(nodeID, "capture.created_at", time.Now().UTC().Format(time.RFC3339)) } @@ -254,9 +322,113 @@ func (a *App) inboxNodeDTO(n *nodes.Node) (*InboxNodeDTO, error) { if source, ok, err := a.nodes.MetaGet(n.ID, "capture.source"); err == nil && ok { dto.CaptureSource = source } + if status, ok, err := a.nodes.MetaGet(n.ID, "capture.status"); err == nil && ok { + dto.CaptureStatus = status + } + if dto.CaptureStatus == "" { + dto.CaptureStatus = "unresolved" + } + if kind, ok, err := a.nodes.MetaGet(n.ID, "capture.source_kind"); err == nil && ok { + dto.SourceKind = kind + } + if dto.SourceKind == "" { + dto.SourceKind = dto.CaptureKind + } + if contextType, ok, err := a.nodes.MetaGet(n.ID, "capture.context_type"); err == nil && ok { + dto.CaptureContextType = contextType + } + if dto.CaptureContextType == "" { + dto.CaptureContextType = "global" + } + if nodeID, ok, err := a.nodes.MetaGet(n.ID, "capture.context_node_id"); err == nil && ok { + dto.CaptureContextNodeID = nodeID + dto.CaptureContextLabel = a.captureNodeLabel(nodeID) + } + if section, ok, err := a.nodes.MetaGet(n.ID, "capture.context_section"); err == nil && ok { + dto.CaptureContextSection = section + if dto.CaptureContextLabel == "" { + dto.CaptureContextLabel = section + } + } + if targetID, ok, err := a.nodes.MetaGet(n.ID, "capture.suggested_target_node_id"); err == nil && ok { + dto.SuggestedTargetNodeID = targetID + dto.SuggestedTargetLabel = a.captureNodeLabel(targetID) + } + if capturedAt, ok, err := a.nodes.MetaGet(n.ID, "capture.created_at"); err == nil && ok { + dto.CapturedAt = capturedAt + } + if rawURL, ok, err := a.nodes.MetaGet(n.ID, "capture.url"); err == nil && ok { + dto.URL = rawURL + } + if hostname, ok, err := a.nodes.MetaGet(n.ID, "capture.hostname"); err == nil && ok { + dto.Hostname = hostname + } return dto, nil } +func parseCaptureContext(contextJSON string) CaptureContextDTO { + var ctx CaptureContextDTO + if strings.TrimSpace(contextJSON) != "" { + _ = json.Unmarshal([]byte(contextJSON), &ctx) + } + return normalizeCaptureContext(ctx) +} + +func normalizeCaptureContext(ctx CaptureContextDTO) CaptureContextDTO { + ctx.ContextType = strings.TrimSpace(ctx.ContextType) + ctx.NodeID = strings.TrimSpace(ctx.NodeID) + ctx.Section = strings.TrimSpace(ctx.Section) + ctx.SuggestedTargetNodeID = strings.TrimSpace(ctx.SuggestedTargetNodeID) + switch ctx.ContextType { + case "node": + if ctx.NodeID == "" { + ctx.ContextType = "global" + break + } + if ctx.SuggestedTargetNodeID == "" { + ctx.SuggestedTargetNodeID = ctx.NodeID + } + case "section": + if ctx.Section == "" { + ctx.Section = "root" + } + case "global": + default: + if ctx.NodeID != "" { + ctx.ContextType = "node" + if ctx.SuggestedTargetNodeID == "" { + ctx.SuggestedTargetNodeID = ctx.NodeID + } + } else if ctx.Section != "" { + ctx.ContextType = "section" + } else { + ctx.ContextType = "global" + } + } + return ctx +} + +func (a *App) captureNodeLabel(nodeID string) string { + if nodeID == "" { + return "" + } + if p := a.nodes.Path(nodeID); p != "" { + return p + } + if n, err := a.nodes.GetActive(nodeID); err == nil { + return n.Title + } + return "" +} + +func hostnameForURL(rawURL string) string { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return "" + } + return u.Hostname() +} + func captureKindForFilename(filename string) string { if strings.HasPrefix(mimeForFilename(filename), "image/") { return "image" diff --git a/cmd/verstak-gui/bindings_inbox.go b/cmd/verstak-gui/bindings_inbox.go index fab0df6..f7a8a2d 100644 --- a/cmd/verstak-gui/bindings_inbox.go +++ b/cmd/verstak-gui/bindings_inbox.go @@ -4,8 +4,19 @@ import "fmt" type InboxNodeDTO struct { NodeDTO - CaptureKind string `json:"captureKind"` - CaptureSource string `json:"captureSource"` + CaptureKind string `json:"captureKind"` + CaptureSource string `json:"captureSource"` + CaptureStatus string `json:"captureStatus"` + CaptureContextType string `json:"captureContextType"` + CaptureContextNodeID string `json:"captureContextNodeId"` + CaptureContextSection string `json:"captureContextSection"` + SuggestedTargetNodeID string `json:"suggestedTargetNodeId"` + CaptureContextLabel string `json:"captureContextLabel"` + SuggestedTargetLabel string `json:"suggestedTargetLabel"` + CapturedAt string `json:"capturedAt"` + SourceKind string `json:"sourceKind"` + URL string `json:"url,omitempty"` + Hostname string `json:"hostname,omitempty"` } func (a *App) ListInboxNodes() ([]InboxNodeDTO, error) { @@ -34,6 +45,26 @@ func (a *App) ListInboxNodes() ([]InboxNodeDTO, error) { return dtos, nil } +func (a *App) ListInboxNodesForTarget(nodeID string) ([]InboxNodeDTO, error) { + if err := a.requireVault(); err != nil { + return nil, err + } + if nodeID == "" { + return []InboxNodeDTO{}, nil + } + list, err := a.ListInboxNodes() + if err != nil { + return nil, err + } + out := make([]InboxNodeDTO, 0, len(list)) + for _, item := range list { + if item.CaptureContextNodeID == nodeID || item.SuggestedTargetNodeID == nodeID { + out = append(out, item) + } + } + return out, nil +} + func (a *App) AssignInboxNode(nodeID, targetParentID string) (*NodeDTO, error) { if err := a.requireVault(); err != nil { return nil, err @@ -82,7 +113,14 @@ func (a *App) filterInboxCaptureNodes(list []NodeDTO) []NodeDTO { func (a *App) isInboxCaptureNode(nodeID string) bool { v, ok, err := a.nodes.MetaGet(nodeID, "capture.inbox") - return err == nil && ok && v == "true" + if err != nil || !ok || v != "true" { + return false + } + status, ok, err := a.nodes.MetaGet(nodeID, "capture.status") + if err != nil { + return false + } + return !ok || status == "" || status == "unresolved" } func (a *App) clearCaptureMeta(nodeID string) error { diff --git a/cmd/verstak-gui/capture_test.go b/cmd/verstak-gui/capture_test.go index 9e55376..1f38099 100644 --- a/cmd/verstak-gui/capture_test.go +++ b/cmd/verstak-gui/capture_test.go @@ -161,3 +161,83 @@ func TestCaptureFileDataCreatesImageInboxArtifact(t *testing.T) { t.Fatalf("records = %+v, want one png image", records) } } + +func TestCaptureTextWithSectionContextCreatesUnresolvedArtifact(t *testing.T) { + app, _ := setupTestApp(t) + + dto, err := app.CaptureTextWithContext("Dropped on today", "paste", `{"contextType":"section","section":"today"}`) + if err != nil { + t.Fatalf("CaptureTextWithContext: %v", err) + } + + if dto.CaptureStatus != "unresolved" { + t.Fatalf("CaptureStatus = %q, want unresolved", dto.CaptureStatus) + } + if dto.SourceKind != "text" { + t.Fatalf("SourceKind = %q, want text", dto.SourceKind) + } + if dto.CaptureSource != "paste" { + t.Fatalf("CaptureSource = %q, want paste", dto.CaptureSource) + } + if dto.CaptureContextType != "section" { + t.Fatalf("CaptureContextType = %q, want section", dto.CaptureContextType) + } + if dto.CaptureContextSection != "today" { + t.Fatalf("CaptureContextSection = %q, want today", dto.CaptureContextSection) + } + if dto.CaptureContextNodeID != "" { + t.Fatalf("CaptureContextNodeID = %q, want empty", dto.CaptureContextNodeID) + } + if dto.SuggestedTargetNodeID != "" { + t.Fatalf("SuggestedTargetNodeID = %q, want empty", dto.SuggestedTargetNodeID) + } +} + +func TestCapturePathWithNodeContextUsesNodeIDForLocalInbox(t *testing.T) { + app, _ := setupTestApp(t) + sourceDir := t.TempDir() + source := filepath.Join(sourceDir, "brief.pdf") + if err := os.WriteFile(source, []byte("pdf"), 0o640); err != nil { + t.Fatalf("write source: %v", err) + } + projectA, err := app.CreateNodeFromTemplate("", "DuckLM", "folder.default") + if err != nil { + t.Fatalf("create project A: %v", err) + } + projectB, err := app.CreateNodeFromTemplate("", "DuckLM", "folder.default") + if err != nil { + t.Fatalf("create project B: %v", err) + } + + ctx := `{"contextType":"node","nodeId":"` + projectA.ID + `","suggestedTargetNodeId":"` + projectA.ID + `"}` + dto, err := app.CapturePathWithContext(source, "drop", ctx) + if err != nil { + t.Fatalf("CapturePathWithContext: %v", err) + } + + if dto.CaptureContextType != "node" { + t.Fatalf("CaptureContextType = %q, want node", dto.CaptureContextType) + } + if dto.CaptureContextNodeID != projectA.ID { + t.Fatalf("CaptureContextNodeID = %q, want %q", dto.CaptureContextNodeID, projectA.ID) + } + if dto.SuggestedTargetNodeID != projectA.ID { + t.Fatalf("SuggestedTargetNodeID = %q, want %q", dto.SuggestedTargetNodeID, projectA.ID) + } + + localA, err := app.ListInboxNodesForTarget(projectA.ID) + if err != nil { + t.Fatalf("ListInboxNodesForTarget(A): %v", err) + } + if len(localA) != 1 || localA[0].ID != dto.ID { + t.Fatalf("local inbox A = %+v, want captured artifact", localA) + } + + localB, err := app.ListInboxNodesForTarget(projectB.ID) + if err != nil { + t.Fatalf("ListInboxNodesForTarget(B): %v", err) + } + if len(localB) != 0 { + t.Fatalf("local inbox B = %+v, want empty for same title different node", localB) + } +} diff --git a/docs/superpowers/plans/2026-06-05-unified-capture-inbox-links.md b/docs/superpowers/plans/2026-06-05-unified-capture-inbox-links.md new file mode 100644 index 0000000..af0b484 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-unified-capture-inbox-links.md @@ -0,0 +1,389 @@ +# Unified Capture Inbox Links Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build one predictable Capture / Inbox / Links pipeline for external drag-and-drop, paste, and clipboard-button input. + +**Architecture:** All external payloads become unresolved inbox artifacts first. Backend stores capture status/context/source metadata on artifact nodes, stages binary payloads under `.verstak/inbox`, resolves artifacts by source kind into Files, Notes, or a dedicated Links table, and exposes global/local inbox APIs. Frontend uses one `resolveCaptureContext()` and one capture dispatcher for drop, paste, and clipboard button instead of screen-specific handlers. + +**Tech Stack:** Go, SQLite migrations, Wails v2 bindings, Svelte 4, Vite 5, existing rendered GUI smoke harness. + +--- + +### Task 1: Backend Capture Metadata And Inbox Queries + +**Files:** +- Modify: `cmd/verstak-gui/bindings_capture.go` +- Modify: `cmd/verstak-gui/bindings_inbox.go` +- Modify: `cmd/verstak-gui/capture_test.go` +- Modify: `cmd/verstak-gui/inbox_test.go` + +- [ ] **Step 1: Write failing tests for context metadata** + +Add tests that capture text on a section and a file while a node is active. Assert: +- `captureStatus == "unresolved"` +- `captureContextType == "section"` or `"node"` +- `captureContextNodeId` equals the target node ID for node context +- `suggestedTargetNodeId` equals the node ID for node context +- local inbox lookup uses node ID, not title + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestCapture.*Context|TestListInboxNodesForTarget' -count=1 +``` + +Expected: fail because context-aware capture APIs and local inbox query do not exist yet. + +- [ ] **Step 3: Implement metadata helpers** + +Add a JSON context DTO: + +```go +type CaptureContextDTO struct { + ContextType string `json:"contextType"` + NodeID string `json:"nodeId,omitempty"` + Section string `json:"section,omitempty"` + SuggestedTargetNodeID string `json:"suggestedTargetNodeId,omitempty"` +} +``` + +Extend capture metadata with: + +```text +capture.status +capture.context_type +capture.context_node_id +capture.context_section +capture.suggested_target_node_id +capture.source_kind +capture.source +capture.created_at +capture.inbox +capture.kind +``` + +Keep old `CaptureText`, `CaptureURL`, `CapturePath`, and `CaptureFileData` as compatibility wrappers. + +- [ ] **Step 4: Implement local inbox API** + +Add: + +```go +func (a *App) ListInboxNodesForTarget(nodeID string) ([]InboxNodeDTO, error) +``` + +Return unresolved artifacts where `capture.context_node_id = nodeID OR capture.suggested_target_node_id = nodeID`. + +- [ ] **Step 5: Verify GREEN and commit** + +Run: + +```bash +env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestCapture.*Context|TestListInboxNodesForTarget|TestListInboxNodesReturnsOnlyCapturedArtifacts' -count=1 +git add cmd/verstak-gui/bindings_capture.go cmd/verstak-gui/bindings_inbox.go cmd/verstak-gui/capture_test.go cmd/verstak-gui/inbox_test.go docs/superpowers/plans/2026-06-05-unified-capture-inbox-links.md +git commit -m "feat: track capture context in inbox" +git push +``` + +### Task 2: Dedicated Link Artifacts And Resolve Routing + +**Files:** +- Create: `internal/core/storage/migrations_015.sql.go` +- Modify: `internal/core/storage/storage.go` +- Create: `cmd/verstak-gui/bindings_links.go` +- Modify: `cmd/verstak-gui/bindings_capture.go` +- Modify: `cmd/verstak-gui/bindings_inbox.go` +- Modify: `cmd/verstak-gui/inbox_test.go` + +- [ ] **Step 1: Write failing tests for URL capture and resolve** + +Add tests that capture URL, resolve it into Project A, and assert: +- unresolved URL uses `sourceKind == "url"` +- it appears in global and local inbox before resolve +- after resolve it disappears from inbox +- `ListLinks(ProjectA)` contains exactly the resolved link +- `ListLinks(ProjectB)` does not contain it + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestResolve.*URL|TestListLinks' -count=1 +``` + +Expected: fail because link table/API and URL routing do not exist. + +- [ ] **Step 3: Add links table and bindings** + +Create `links` table: + +```sql +CREATE TABLE IF NOT EXISTS links ( + id TEXT PRIMARY KEY, + node_id TEXT NOT NULL REFERENCES nodes(id), + title TEXT NOT NULL, + url TEXT NOT NULL, + hostname TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + captured_at TEXT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_links_node ON links(node_id); +``` + +Expose `ListLinks`, `UpdateLink`, `DeleteLink`, and `OpenLink`. + +- [ ] **Step 4: Implement resolve routing** + +Add: + +```go +func (a *App) ResolveInboxNode(nodeID, targetParentID string) (*NodeDTO, error) +func (a *App) ResolveInboxNodeHere(nodeID string) (*NodeDTO, error) +``` + +Route source kinds: +- `file`, `folder`, `image`: move node into target via existing `MoveNode` +- `text`: move note into target via existing `MoveNode` +- `url`: create a row in `links`, then remove/hide the unresolved artifact + +Keep `AssignInboxNode` as a compatibility alias to `ResolveInboxNode`. + +- [ ] **Step 5: Verify GREEN and commit** + +Run: + +```bash +env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestResolve.*URL|TestAssignInboxNode|TestDeleteInboxNode|TestListLinks' -count=1 +git add internal/core/storage/storage.go internal/core/storage/migrations_015.sql.go cmd/verstak-gui/bindings_links.go cmd/verstak-gui/bindings_capture.go cmd/verstak-gui/bindings_inbox.go cmd/verstak-gui/inbox_test.go +git commit -m "feat: resolve inbox links separately" +git push +``` + +### Task 3: Native Clipboard Bridge And Capture Bindings + +**Files:** +- Create: `cmd/verstak-gui/bindings_clipboard.go` +- Modify: `frontend/src/wailsjs/go/main/App.js` +- Modify: `cmd/verstak-gui/capture_test.go` + +- [ ] **Step 1: Write failing test for clipboard text routing** + +Add a focused test for a helper that classifies clipboard text as URL before plain text. + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestClassifyClipboardText' -count=1 +``` + +Expected: fail because helper does not exist. + +- [ ] **Step 3: Implement backend clipboard bridge** + +Expose: + +```go +func (a *App) ReadClipboardText() (string, error) +func (a *App) CaptureClipboardTextWithContext(contextJSON string) (*InboxNodeDTO, error) +``` + +Use Wails v2 `runtime.ClipboardGetText(a.ctx)`. Convert backend errors to a user-facing message; do not surface browser `NotAllowedError`. + +- [ ] **Step 4: Verify GREEN and commit** + +Run: + +```bash +env GOCACHE=/tmp/verstak-go-cache go test ./cmd/verstak-gui -run 'TestClassifyClipboardText|TestCapture.*' -count=1 +git add cmd/verstak-gui/bindings_clipboard.go cmd/verstak-gui/capture_test.go frontend/src/wailsjs/go/main/App.js +git commit -m "feat: add native clipboard capture bridge" +git push +``` + +### Task 4: Frontend Unified Capture Pipeline + +**Files:** +- Modify: `frontend/src/App.svelte` +- Modify: `frontend/src/wailsjs/go/main/App.js` +- Modify: `frontend/src/lib/i18n/locales/ru.js` +- Modify: `frontend/src/lib/i18n/locales/en.js` +- Modify: `scripts/check-gui-render.mjs` + +- [ ] **Step 1: Update smoke mock for new bindings** + +Add mock methods: +- `CaptureTextWithContext` +- `CaptureURLWithContext` +- `CapturePathWithContext` +- `CaptureFileDataWithContext` +- `CaptureClipboardTextWithContext` +- `ResolveInboxNode` +- `ResolveInboxNodeHere` +- `ListInboxNodesForTarget` +- `ListLinks` +- `UpdateLink` +- `DeleteLink` +- `OpenLink` + +- [ ] **Step 2: Refactor capture inputs** + +Implement: + +```js +function resolveCaptureContext() +async function captureTextPayload(text, source) +async function captureUrlPayload(url, title, source) +async function capturePathPayload(paths, source) +async function captureFilePayload(file, source) +async function handleGlobalPaste(event) +async function handleGlobalDrop(event) +``` + +Use `event.clipboardData` for Ctrl+V and Wails/native clipboard binding for the button. + +- [ ] **Step 3: Add drop overlay** + +Show: +- `Будет добавлено в Неразобранное для: ` when a node is selected +- `Будет добавлено в глобальное Неразобранное` on system/global sections + +- [ ] **Step 4: Verify frontend build and commit** + +Run: + +```bash +env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH npm run build +git add frontend/src/App.svelte frontend/src/wailsjs/go/main/App.js frontend/src/lib/i18n/locales/ru.js frontend/src/lib/i18n/locales/en.js scripts/check-gui-render.mjs +git commit -m "feat: unify frontend capture pipeline" +git push +``` + +### Task 5: Local Inbox And Links UI + +**Files:** +- Modify: `frontend/src/App.svelte` +- Modify: `frontend/src/lib/i18n/locales/ru.js` +- Modify: `frontend/src/lib/i18n/locales/en.js` +- Modify: `scripts/check-gui-render.mjs` + +- [ ] **Step 1: Add tabs** + +Add node tabs: +- `Неразобранное` +- `Ссылки` + +Local Inbox shows unresolved artifacts for the current node only. Links shows resolved link artifacts for the current node only. + +- [ ] **Step 2: Add link actions** + +Implement minimal actions: +- open external browser via `OpenLink` +- edit title / URL / note via modal +- delete via `DeleteLink` +- copy URL using `navigator.clipboard.writeText` only as an optional best-effort UI action + +- [ ] **Step 3: Verify GUI smoke and commit** + +Run: + +```bash +env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH GOCACHE=/tmp/verstak-go-cache ./scripts/check-gui.sh +git add frontend/src/App.svelte frontend/src/lib/i18n/locales/ru.js frontend/src/lib/i18n/locales/en.js scripts/check-gui-render.mjs +git commit -m "feat: add local inbox and links tabs" +git push +``` + +### Task 6: Editable Hotkey Guard + +**Files:** +- Modify: `frontend/src/App.svelte` +- Modify: `frontend/src/lib/FilePreviewModal.svelte` +- Modify: `frontend/src/lib/FirstRun.svelte` +- Modify: `scripts/check-gui-render.mjs` + +- [ ] **Step 1: Add failing smoke assertion** + +Open the add-action modal, type a title containing spaces, and assert the modal remains open until save/cancel. + +- [ ] **Step 2: Implement editable target guard** + +Use: + +```js +function isEditableTarget(target) { + if (!target || !(target instanceof Element)) return false + return !!target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') +} +``` + +Global hotkeys and `onKeyActivate` must return early for editable targets. + +- [ ] **Step 3: Verify and commit** + +Run: + +```bash +env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH GOCACHE=/tmp/verstak-go-cache ./scripts/check-gui.sh +git add frontend/src/App.svelte frontend/src/lib/FilePreviewModal.svelte frontend/src/lib/FirstRun.svelte scripts/check-gui-render.mjs +git commit -m "fix: ignore global hotkeys in editable fields" +git push +``` + +### Task 7: Full Verification And Documentation Alignment + +**Files:** +- Modify: `docs/01_Product_Spec.md` +- Modify: `docs/03_Data_Model_Storage.md` +- Modify: `docs/05_UI_UX.md` +- Modify: `docs/06_Roadmap.md` + +- [ ] **Step 1: Update docs** + +Document current behavior: +- all external capture creates unresolved inbox artifacts +- local/global inbox semantics +- capture context metadata by node ID +- Links tab and link artifact model +- backend clipboard bridge for button, `paste` event for Ctrl+V + +- [ ] **Step 2: Run full required checks** + +Run: + +```bash +env GOCACHE=/tmp/verstak-go-cache go test ./... +env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH npm run build +env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH node scripts/check-gui-render.mjs +env PATH=/home/mirivlad/.config/nvm/versions/node/v24.13.1/bin:$PATH node scripts/check-i18n.sh +``` + +If `scripts/check-i18n.sh` is not a Node script or does not exist, inspect scripts and run the repository’s actual i18n check. + +- [ ] **Step 3: Commit and push** + +```bash +git add docs/01_Product_Spec.md docs/03_Data_Model_Storage.md docs/05_UI_UX.md docs/06_Roadmap.md +git commit -m "docs: describe unified capture inbox flow" +git push +``` + +### Self-Review Checklist + +- [ ] External drop anywhere creates unresolved artifact, never direct case content. +- [ ] Ctrl+V uses `paste` event `clipboardData`. +- [ ] Clipboard button uses backend/native bridge. +- [ ] URL artifacts resolve into `links`, not notes. +- [ ] Local Inbox is metadata view by node ID. +- [ ] Global Inbox shows all unresolved artifacts. +- [ ] Files/folders/images are staged under `.verstak/inbox` and moved to canonical target filesystem path on resolve. +- [ ] Space inside editable fields never triggers global UI actions. +- [ ] GUI smoke covers capture, resolve, links, and modal-space regression.