feat: track capture context in inbox

This commit is contained in:
mirivlad 2026-06-05 07:30:00 +08:00
parent 6eaa4cda49
commit 336037d887
4 changed files with 689 additions and 10 deletions

View File

@ -2,8 +2,10 @@ package main
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"mime" "mime"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -17,7 +19,18 @@ import (
"verstak/internal/core/util" "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) { 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 { if err := a.requireVault(); err != nil {
return nil, err return nil, err
} }
@ -27,10 +40,15 @@ func (a *App) CaptureText(text string) (*InboxNodeDTO, error) {
} }
title := firstLineTitle(text, "Captured text") title := firstLineTitle(text, "Captured text")
content := "# " + title + "\n\n" + text + "\n" 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) { 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 { if err := a.requireVault(); err != nil {
return nil, err return nil, err
} }
@ -44,10 +62,26 @@ func (a *App) CaptureURL(rawURL, title string) (*InboxNodeDTO, error) {
} }
title = firstLineTitle(title, rawURL) title = firstLineTitle(title, rawURL)
content := "# " + title + "\n\n" + rawURL + "\n" 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) { 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 { if err := a.requireVault(); err != nil {
return nil, err 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 return nil, err
} }
target := activity.TargetFile target := activity.TargetFile
@ -115,6 +150,10 @@ func (a *App) CapturePath(sourcePath string) (*InboxNodeDTO, error) {
} }
func (a *App) CaptureFileData(filename, dataBase64 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 { if err := a.requireVault(); err != nil {
return nil, err 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 { 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) 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 return nil, err
} }
_ = a.activity.Record("", activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, `{"capture":true}`) _ = 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) 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, "", "") node, err := a.nodes.Create(nil, nodes.TypeNote, title, 0, "", "")
if err != nil { if err != nil {
return nil, fmt.Errorf("create node: %w", err) 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 { 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) 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 return nil, err
} }
@ -233,16 +273,44 @@ func (a *App) captureStagingDir(node *nodes.Node) (string, string, error) {
return rel, abs, nil 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 { if err := a.nodes.MetaSet(nodeID, "capture.inbox", "true"); err != nil {
return err 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 { if err := a.nodes.MetaSet(nodeID, "capture.kind", kind); err != nil {
return err 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 { if err := a.nodes.MetaSet(nodeID, "capture.source", source); err != nil {
return err 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)) 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 { if source, ok, err := a.nodes.MetaGet(n.ID, "capture.source"); err == nil && ok {
dto.CaptureSource = source 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 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 { func captureKindForFilename(filename string) string {
if strings.HasPrefix(mimeForFilename(filename), "image/") { if strings.HasPrefix(mimeForFilename(filename), "image/") {
return "image" return "image"

View File

@ -6,6 +6,17 @@ type InboxNodeDTO struct {
NodeDTO NodeDTO
CaptureKind string `json:"captureKind"` CaptureKind string `json:"captureKind"`
CaptureSource string `json:"captureSource"` 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) { func (a *App) ListInboxNodes() ([]InboxNodeDTO, error) {
@ -34,6 +45,26 @@ func (a *App) ListInboxNodes() ([]InboxNodeDTO, error) {
return dtos, nil 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) { func (a *App) AssignInboxNode(nodeID, targetParentID string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil { if err := a.requireVault(); err != nil {
return nil, err return nil, err
@ -82,7 +113,14 @@ func (a *App) filterInboxCaptureNodes(list []NodeDTO) []NodeDTO {
func (a *App) isInboxCaptureNode(nodeID string) bool { 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" 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 { func (a *App) clearCaptureMeta(nodeID string) error {

View File

@ -161,3 +161,83 @@ func TestCaptureFileDataCreatesImageInboxArtifact(t *testing.T) {
t.Fatalf("records = %+v, want one png image", records) 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)
}
}

View File

@ -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:
- `Будет добавлено в Неразобранное для: <node title/path>` 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 repositorys 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.