feat: resolve inbox links separately

This commit is contained in:
2026-06-05 07:33:10 +08:00
parent 336037d887
commit bcb093d453
7 changed files with 349 additions and 17 deletions
+10 -11
View File
@@ -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)
}
+69
View File
@@ -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
}
+172
View File
@@ -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()
}
+10 -6
View File
@@ -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)
}
}
+67
View File
@@ -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)
}
}