feat: track capture context in inbox
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user