diff --git a/cmd/verstak-gui/bindings_capture.go b/cmd/verstak-gui/bindings_capture.go new file mode 100644 index 0000000..2cb2e50 --- /dev/null +++ b/cmd/verstak-gui/bindings_capture.go @@ -0,0 +1,126 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "verstak/internal/core/activity" + "verstak/internal/core/files" + "verstak/internal/core/nodes" + syncsvc "verstak/internal/core/sync" + "verstak/internal/core/util" +) + +func (a *App) CaptureText(text string) (*InboxNodeDTO, error) { + if err := a.requireVault(); err != nil { + return nil, err + } + text = strings.TrimSpace(text) + if text == "" { + return nil, fmt.Errorf("text required") + } + title := firstLineTitle(text, "Captured text") + content := "# " + title + "\n\n" + text + "\n" + return a.createCaptureNote(title, content, "text", "clipboard") +} + +func (a *App) CaptureURL(rawURL, title string) (*InboxNodeDTO, error) { + if err := a.requireVault(); err != nil { + return nil, err + } + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return nil, fmt.Errorf("url required") + } + title = strings.TrimSpace(title) + if title == "" { + title = rawURL + } + title = firstLineTitle(title, rawURL) + content := "# " + title + "\n\n" + rawURL + "\n" + return a.createCaptureNote(title, content, "url", "clipboard") +} + +func (a *App) createCaptureNote(title, content, kind, source string) (*InboxNodeDTO, error) { + node, err := a.nodes.Create(nil, nodes.TypeNote, title, 0, "", "") + if err != nil { + return nil, fmt.Errorf("create node: %w", err) + } + + inboxDir := filepath.Join(a.vault, ".verstak", "inbox") + if err := os.MkdirAll(inboxDir, 0o750); err != nil { + return nil, fmt.Errorf("create inbox dir: %w", err) + } + filename := node.ID + ".md" + absPath := filepath.Join(inboxDir, filename) + if err := os.WriteFile(absPath, []byte(content), 0o640); err != nil { + return nil, fmt.Errorf("write capture note: %w", err) + } + relPath, _ := filepath.Rel(a.vault, absPath) + now := time.Now().UTC() + fileRec := &files.Record{ + ID: util.UUID7(), + NodeID: node.ID, + Filename: filename, + Path: relPath, + StorageMode: "vault", + Size: int64(len(content)), + MIME: "text/markdown", + CreatedAt: now, + UpdatedAt: now, + } + if _, err := a.db.Exec( + `INSERT INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing) + VALUES (?,?,?,?,?,?,?,?,?,0)`, + fileRec.ID, fileRec.NodeID, fileRec.Filename, fileRec.Path, fileRec.StorageMode, + fileRec.Size, fileRec.MIME, fileRec.CreatedAt.Format(time.RFC3339), fileRec.UpdatedAt.Format(time.RFC3339)); err != nil { + return nil, fmt.Errorf("insert capture file: %w", err) + } + 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.nodes.MetaSet(node.ID, "capture.inbox", "true"); err != nil { + return nil, err + } + if err := a.nodes.MetaSet(node.ID, "capture.kind", kind); err != nil { + return nil, err + } + if err := a.nodes.MetaSet(node.ID, "capture.source", source); err != nil { + return nil, err + } + if err := a.nodes.MetaSet(node.ID, "capture.created_at", now.Format(time.RFC3339)); err != nil { + return nil, err + } + + _ = a.activity.Record("", activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, `{"capture":true}`) + _ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, notePayload(node, fileRec, content)) + + return a.inboxNodeDTO(node) +} + +func (a *App) inboxNodeDTO(n *nodes.Node) (*InboxNodeDTO, error) { + dto := &InboxNodeDTO{NodeDTO: toNodeDTO(n)} + if kind, ok, err := a.nodes.MetaGet(n.ID, "capture.kind"); err == nil && ok { + dto.CaptureKind = kind + } + if source, ok, err := a.nodes.MetaGet(n.ID, "capture.source"); err == nil && ok { + dto.CaptureSource = source + } + return dto, nil +} + +func firstLineTitle(text, fallback string) string { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line != "" { + if len(line) > 80 { + return line[:80] + } + return line + } + } + return fallback +} diff --git a/cmd/verstak-gui/bindings_inbox.go b/cmd/verstak-gui/bindings_inbox.go index 6cc156f..683f5c8 100644 --- a/cmd/verstak-gui/bindings_inbox.go +++ b/cmd/verstak-gui/bindings_inbox.go @@ -16,14 +16,11 @@ func (a *App) ListInboxNodes() ([]InboxNodeDTO, error) { } dtos := make([]InboxNodeDTO, 0, len(list)) for _, n := range list { - dto := InboxNodeDTO{NodeDTO: toNodeDTO(&n)} - if kind, ok, err := a.nodes.MetaGet(n.ID, "capture.kind"); err == nil && ok { - dto.CaptureKind = kind + dto, err := a.inboxNodeDTO(&n) + if err != nil { + return nil, err } - if source, ok, err := a.nodes.MetaGet(n.ID, "capture.source"); err == nil && ok { - dto.CaptureSource = source - } - dtos = append(dtos, dto) + dtos = append(dtos, *dto) } for i := range dtos { n, err := a.nodes.CountChildren(dtos[i].ID, "case", "client", "project", "folder", "document", "recipe") diff --git a/cmd/verstak-gui/capture_test.go b/cmd/verstak-gui/capture_test.go new file mode 100644 index 0000000..ef73e8c --- /dev/null +++ b/cmd/verstak-gui/capture_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "strings" + "testing" +) + +func TestCaptureTextCreatesInboxArtifact(t *testing.T) { + app, _ := setupTestApp(t) + + dto, err := app.CaptureText("Нужно разобрать этот текст") + if err != nil { + t.Fatalf("CaptureText: %v", err) + } + if dto.ID == "" { + t.Fatal("empty captured node id") + } + if dto.CaptureKind != "text" { + t.Fatalf("CaptureKind = %q, want text", dto.CaptureKind) + } + if dto.CaptureSource != "clipboard" { + t.Fatalf("CaptureSource = %q, want clipboard", dto.CaptureSource) + } + + content, err := app.ReadNote(dto.ID) + if err != nil { + t.Fatalf("ReadNote: %v", err) + } + if !strings.Contains(content, "Нужно разобрать этот текст") { + t.Fatalf("captured content missing: %q", content) + } + var path string + if err := app.db.QueryRow(`SELECT f.path FROM notes n JOIN files f ON f.id = n.file_id WHERE n.node_id = ?`, dto.ID).Scan(&path); err != nil { + t.Fatalf("query note file path: %v", err) + } + if !strings.HasPrefix(path, ".verstak/inbox/") { + t.Fatalf("path = %q, want .verstak/inbox prefix", path) + } + + inbox, err := app.ListInboxNodes() + if err != nil { + t.Fatalf("ListInboxNodes: %v", err) + } + var found bool + for _, item := range inbox { + if item.ID == dto.ID { + found = true + } + } + if !found { + t.Fatal("captured text missing from inbox") + } +} + +func TestCaptureURLCreatesInboxArtifact(t *testing.T) { + app, _ := setupTestApp(t) + + dto, err := app.CaptureURL("https://example.test/page", "Example Page") + if err != nil { + t.Fatalf("CaptureURL: %v", err) + } + if dto.CaptureKind != "url" { + t.Fatalf("CaptureKind = %q, want url", dto.CaptureKind) + } + if dto.Title != "Example Page" { + t.Fatalf("Title = %q, want Example Page", dto.Title) + } + + content, err := app.ReadNote(dto.ID) + if err != nil { + t.Fatalf("ReadNote: %v", err) + } + if !strings.Contains(content, "https://example.test/page") { + t.Fatalf("captured URL missing: %q", content) + } +} diff --git a/frontend/src/wailsjs/go/main/App.js b/frontend/src/wailsjs/go/main/App.js index 5ec41f0..e8da691 100644 --- a/frontend/src/wailsjs/go/main/App.js +++ b/frontend/src/wailsjs/go/main/App.js @@ -82,6 +82,14 @@ export function ListInboxNodes() { return window['go']['main']['App']['ListInboxNodes'](); } +export function CaptureText(arg1) { + return window['go']['main']['App']['CaptureText'](arg1); +} + +export function CaptureURL(arg1, arg2) { + return window['go']['main']['App']['CaptureURL'](arg1, arg2); +} + export function ListTrash() { return window['go']['main']['App']['ListTrash'](); }