feat: track capture context in inbox
This commit is contained in:
parent
6eaa4cda49
commit
336037d887
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 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.
|
||||||
Loading…
Reference in New Issue