diff --git a/cmd/verstak-gui/bindings_capture.go b/cmd/verstak-gui/bindings_capture.go index 4eb6343..743196d 100644 --- a/cmd/verstak-gui/bindings_capture.go +++ b/cmd/verstak-gui/bindings_capture.go @@ -58,22 +58,21 @@ func (a *App) CaptureURLWithContext(rawURL, title, source, contextJSON string) ( } title = strings.TrimSpace(title) if title == "" { - title = rawURL + title = linkTitle(rawURL, "") } - title = firstLineTitle(title, rawURL) - content := "# " + title + "\n\n" + rawURL + "\n" 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) + node, err := a.nodes.Create(nil, nodes.TypeLink, firstLineTitle(title, rawURL), 0, "", "") if err != nil { + return nil, fmt.Errorf("create capture link: %w", err) + } + if err := a.setCaptureMeta(node.ID, "url", source, ctx); err != nil { return nil, err } + _ = a.nodes.MetaSet(node.ID, "capture.url", rawURL) + _ = a.nodes.MetaSet(node.ID, "capture.title", title) + _ = a.nodes.MetaSet(node.ID, "capture.hostname", hostnameForURL(rawURL)) + _ = a.activity.Record("", activity.TargetNode, node.ID, "", activity.TypeNodeCreated, title, `{"capture":true,"kind":"url"}`) + _ = a.sync.RecordOp(syncsvc.EntityNode, node.ID, syncsvc.OpCreate, nodePayload(node)) return a.inboxNodeDTO(node) } diff --git a/cmd/verstak-gui/bindings_inbox.go b/cmd/verstak-gui/bindings_inbox.go index f7a8a2d..c607c69 100644 --- a/cmd/verstak-gui/bindings_inbox.go +++ b/cmd/verstak-gui/bindings_inbox.go @@ -66,6 +66,24 @@ func (a *App) ListInboxNodesForTarget(nodeID string) ([]InboxNodeDTO, error) { } func (a *App) AssignInboxNode(nodeID, targetParentID string) (*NodeDTO, error) { + return a.ResolveInboxNode(nodeID, targetParentID) +} + +func (a *App) ResolveInboxNodeHere(nodeID string) (*NodeDTO, error) { + if err := a.requireVault(); err != nil { + return nil, err + } + dto, err := a.inboxNodeByID(nodeID) + if err != nil { + return nil, err + } + if dto.SuggestedTargetNodeID == "" { + return nil, fmt.Errorf("suggested target is required") + } + return a.ResolveInboxNode(nodeID, dto.SuggestedTargetNodeID) +} + +func (a *App) ResolveInboxNode(nodeID, targetParentID string) (*NodeDTO, error) { if err := a.requireVault(); err != nil { return nil, err } @@ -75,6 +93,16 @@ func (a *App) AssignInboxNode(nodeID, targetParentID string) (*NodeDTO, error) { if targetParentID == "" { return nil, fmt.Errorf("target parent is required") } + sourceKind := a.captureMeta(nodeID, "capture.source_kind") + if sourceKind == "" { + sourceKind = a.captureMeta(nodeID, "capture.kind") + } + if sourceKind == "url" { + if err := a.resolveURLInboxNode(nodeID, targetParentID); err != nil { + return nil, err + } + return a.GetNodeDetail(targetParentID) + } if err := a.MoveNode(nodeID, targetParentID); err != nil { return nil, err } @@ -88,6 +116,39 @@ func (a *App) AssignInboxNode(nodeID, targetParentID string) (*NodeDTO, error) { return dto, nil } +func (a *App) inboxNodeByID(nodeID string) (*InboxNodeDTO, error) { + n, err := a.nodes.GetActive(nodeID) + if err != nil { + return nil, err + } + return a.inboxNodeDTO(n) +} + +func (a *App) resolveURLInboxNode(nodeID, targetParentID string) error { + if _, err := a.nodes.GetActive(targetParentID); err != nil { + return fmt.Errorf("target parent not found: %w", err) + } + rawURL := a.captureMeta(nodeID, "capture.url") + if rawURL == "" { + return fmt.Errorf("captured url is missing") + } + title := a.captureMeta(nodeID, "capture.title") + if title == "" { + if n, err := a.nodes.GetActive(nodeID); err == nil { + title = n.Title + } + } + source := a.captureMeta(nodeID, "capture.source") + capturedAt := a.captureMeta(nodeID, "capture.created_at") + if _, err := a.createResolvedLink(targetParentID, rawURL, title, "", source, capturedAt); err != nil { + return err + } + if err := a.DeleteNode(nodeID); err != nil { + return err + } + return a.clearCaptureMeta(nodeID) +} + func (a *App) DeleteInboxNode(nodeID string) error { if err := a.requireVault(); err != nil { return err @@ -127,3 +188,11 @@ func (a *App) clearCaptureMeta(nodeID string) error { _, err := a.db.Exec(`DELETE FROM node_meta WHERE node_id = ? AND key LIKE 'capture.%'`, nodeID) return err } + +func (a *App) captureMeta(nodeID, key string) string { + v, ok, err := a.nodes.MetaGet(nodeID, key) + if err != nil || !ok { + return "" + } + return v +} diff --git a/cmd/verstak-gui/bindings_links.go b/cmd/verstak-gui/bindings_links.go new file mode 100644 index 0000000..f8ba85d --- /dev/null +++ b/cmd/verstak-gui/bindings_links.go @@ -0,0 +1,172 @@ +package main + +import ( + "fmt" + "net/url" + "os/exec" + "runtime" + "strings" + "time" + + "verstak/internal/core/util" +) + +type LinkDTO struct { + ID string `json:"id"` + NodeID string `json:"nodeId"` + Title string `json:"title"` + URL string `json:"url"` + Hostname string `json:"hostname"` + Note string `json:"note"` + Source string `json:"source"` + CapturedAt string `json:"capturedAt"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +func (a *App) ListLinks(nodeID string) ([]LinkDTO, error) { + if err := a.requireVault(); err != nil { + return nil, err + } + if nodeID == "" { + return []LinkDTO{}, nil + } + rows, err := a.db.Query( + `SELECT id,node_id,title,url,hostname,note,source,COALESCE(captured_at,''),created_at,updated_at + FROM links + WHERE node_id = ? + ORDER BY created_at DESC, title`, nodeID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []LinkDTO + for rows.Next() { + var l LinkDTO + if err := rows.Scan(&l.ID, &l.NodeID, &l.Title, &l.URL, &l.Hostname, &l.Note, &l.Source, &l.CapturedAt, &l.CreatedAt, &l.UpdatedAt); err != nil { + return nil, err + } + out = append(out, l) + } + return out, rows.Err() +} + +func (a *App) UpdateLink(id, title, rawURL, note string) (*LinkDTO, error) { + if err := a.requireVault(); err != nil { + return nil, err + } + id = strings.TrimSpace(id) + title = strings.TrimSpace(title) + rawURL = strings.TrimSpace(rawURL) + if id == "" { + return nil, fmt.Errorf("link id required") + } + if rawURL == "" { + return nil, fmt.Errorf("url required") + } + if title == "" { + title = linkTitle(rawURL, "") + } + updatedAt := time.Now().UTC().Format(time.RFC3339) + res, err := a.db.Exec( + `UPDATE links SET title=?, url=?, hostname=?, note=?, updated_at=? WHERE id=?`, + title, rawURL, hostnameForURL(rawURL), note, updatedAt, id) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, fmt.Errorf("link not found") + } + return a.getLink(id) +} + +func (a *App) DeleteLink(id string) error { + if err := a.requireVault(); err != nil { + return err + } + res, err := a.db.Exec(`DELETE FROM links WHERE id = ?`, strings.TrimSpace(id)) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("link not found") + } + return nil +} + +func (a *App) OpenLink(id string) error { + if err := a.requireVault(); err != nil { + return err + } + l, err := a.getLink(id) + if err != nil { + return err + } + return openExternalURL(l.URL) +} + +func (a *App) createResolvedLink(nodeID, rawURL, title, note, source, capturedAt string) (*LinkDTO, error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return nil, fmt.Errorf("url required") + } + title = linkTitle(rawURL, title) + now := time.Now().UTC().Format(time.RFC3339) + id := util.UUID7() + if _, err := a.db.Exec( + `INSERT INTO links (id,node_id,title,url,hostname,note,source,captured_at,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + id, nodeID, title, rawURL, hostnameForURL(rawURL), note, source, capturedAt, now, now); err != nil { + return nil, err + } + return a.getLink(id) +} + +func (a *App) getLink(id string) (*LinkDTO, error) { + var l LinkDTO + err := a.db.QueryRow( + `SELECT id,node_id,title,url,hostname,note,source,COALESCE(captured_at,''),created_at,updated_at + FROM links WHERE id = ?`, strings.TrimSpace(id)). + Scan(&l.ID, &l.NodeID, &l.Title, &l.URL, &l.Hostname, &l.Note, &l.Source, &l.CapturedAt, &l.CreatedAt, &l.UpdatedAt) + if err != nil { + return nil, err + } + return &l, nil +} + +func linkTitle(rawURL, title string) string { + title = strings.TrimSpace(title) + if title != "" { + return firstLineTitle(title, title) + } + if h := hostnameForURL(rawURL); h != "" { + return h + } + return rawURL +} + +func isURLLike(text string) bool { + text = strings.TrimSpace(text) + if text == "" { + return false + } + u, err := url.Parse(text) + return err == nil && u.Scheme != "" && u.Host != "" +} + +func openExternalURL(rawURL string) error { + if !isURLLike(rawURL) { + return fmt.Errorf("invalid url") + } + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", rawURL) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL) + default: + cmd = exec.Command("xdg-open", rawURL) + } + return cmd.Start() +} diff --git a/cmd/verstak-gui/capture_test.go b/cmd/verstak-gui/capture_test.go index 1f38099..f799bad 100644 --- a/cmd/verstak-gui/capture_test.go +++ b/cmd/verstak-gui/capture_test.go @@ -68,13 +68,17 @@ func TestCaptureURLCreatesInboxArtifact(t *testing.T) { 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 dto.Type != "link" { + t.Fatalf("Type = %q, want link", dto.Type) } - if !strings.Contains(content, "https://example.test/page") { - t.Fatalf("captured URL missing: %q", content) + if dto.SourceKind != "url" { + t.Fatalf("SourceKind = %q, want url", dto.SourceKind) + } + if dto.URL != "https://example.test/page" { + t.Fatalf("URL = %q, want captured URL", dto.URL) + } + if dto.Hostname != "example.test" { + t.Fatalf("Hostname = %q, want example.test", dto.Hostname) } } diff --git a/cmd/verstak-gui/inbox_test.go b/cmd/verstak-gui/inbox_test.go index a15597c..96f7ff1 100644 --- a/cmd/verstak-gui/inbox_test.go +++ b/cmd/verstak-gui/inbox_test.go @@ -150,3 +150,70 @@ func TestDeleteInboxNodeRemovesArtifactFromInbox(t *testing.T) { t.Fatalf("GetActive err = %v, want ErrNotFound", err) } } + +func TestResolveCapturedURLCreatesLinkForTargetOnly(t *testing.T) { + app, _ := setupTestApp(t) + + projectA, err := app.CreateNodeFromTemplate("", "Project A", "folder.default") + if err != nil { + t.Fatalf("create project A: %v", err) + } + projectB, err := app.CreateNodeFromTemplate("", "Project B", "folder.default") + if err != nil { + t.Fatalf("create project B: %v", err) + } + ctx := `{"contextType":"node","nodeId":"` + projectA.ID + `","suggestedTargetNodeId":"` + projectA.ID + `"}` + captured, err := app.CaptureURLWithContext("https://example.test/article", "Example Article", "drop", ctx) + if err != nil { + t.Fatalf("CaptureURLWithContext: %v", err) + } + + localBefore, err := app.ListInboxNodesForTarget(projectA.ID) + if err != nil { + t.Fatalf("ListInboxNodesForTarget before: %v", err) + } + if len(localBefore) != 1 || localBefore[0].ID != captured.ID { + t.Fatalf("local inbox before = %+v, want captured URL", localBefore) + } + + if _, err := app.ResolveInboxNode(captured.ID, projectA.ID); err != nil { + t.Fatalf("ResolveInboxNode: %v", err) + } + + globalAfter, err := app.ListInboxNodes() + if err != nil { + t.Fatalf("ListInboxNodes after: %v", err) + } + for _, item := range globalAfter { + if item.ID == captured.ID { + t.Fatal("resolved URL should leave global inbox") + } + } + + localAfter, err := app.ListInboxNodesForTarget(projectA.ID) + if err != nil { + t.Fatalf("ListInboxNodesForTarget after: %v", err) + } + if len(localAfter) != 0 { + t.Fatalf("local inbox after = %+v, want empty", localAfter) + } + + linksA, err := app.ListLinks(projectA.ID) + if err != nil { + t.Fatalf("ListLinks(A): %v", err) + } + if len(linksA) != 1 { + t.Fatalf("links A = %+v, want one link", linksA) + } + if linksA[0].Title != "Example Article" || linksA[0].URL != "https://example.test/article" || linksA[0].Hostname != "example.test" { + t.Fatalf("link A = %+v, want captured URL data", linksA[0]) + } + + linksB, err := app.ListLinks(projectB.ID) + if err != nil { + t.Fatalf("ListLinks(B): %v", err) + } + if len(linksB) != 0 { + t.Fatalf("links B = %+v, want empty", linksB) + } +} diff --git a/internal/core/storage/migrations_015.sql.go b/internal/core/storage/migrations_015.sql.go new file mode 100644 index 0000000..f5e0ad3 --- /dev/null +++ b/internal/core/storage/migrations_015.sql.go @@ -0,0 +1,20 @@ +package storage + +// migration015 — dedicated resolved link artifacts. +const migration015 = ` +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); +CREATE INDEX IF NOT EXISTS idx_links_url ON links(url); +` diff --git a/internal/core/storage/storage.go b/internal/core/storage/storage.go index 74c006f..54b25e4 100644 --- a/internal/core/storage/storage.go +++ b/internal/core/storage/storage.go @@ -71,6 +71,7 @@ var migrationFiles = map[int]string{ 12: migration012, 13: migration013, 14: migration014, + 15: migration015, } func (db *DB) runInitialSchema() error {