feat: track capture context in inbox

This commit is contained in:
2026-06-05 07:30:00 +08:00
parent 6eaa4cda49
commit 336037d887
4 changed files with 689 additions and 10 deletions
+179 -7
View File
@@ -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"
+41 -3
View File
@@ -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 {
+80
View File
@@ -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)
}
}