feat: resolve inbox links separately

This commit is contained in:
mirivlad 2026-06-05 07:33:10 +08:00
parent 336037d887
commit bcb093d453
7 changed files with 349 additions and 17 deletions

View File

@ -58,22 +58,21 @@ func (a *App) CaptureURLWithContext(rawURL, title, source, contextJSON string) (
} }
title = strings.TrimSpace(title) title = strings.TrimSpace(title)
if title == "" { if title == "" {
title = rawURL title = linkTitle(rawURL, "")
} }
title = firstLineTitle(title, rawURL)
content := "# " + title + "\n\n" + rawURL + "\n"
ctx := parseCaptureContext(contextJSON) ctx := parseCaptureContext(contextJSON)
dto, err := a.createCaptureNote(title, content, "url", source, ctx) node, err := a.nodes.Create(nil, nodes.TypeLink, firstLineTitle(title, rawURL), 0, "", "")
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 { 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 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) return a.inboxNodeDTO(node)
} }

View File

@ -66,6 +66,24 @@ func (a *App) ListInboxNodesForTarget(nodeID string) ([]InboxNodeDTO, error) {
} }
func (a *App) AssignInboxNode(nodeID, targetParentID string) (*NodeDTO, 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 { if err := a.requireVault(); err != nil {
return nil, err return nil, err
} }
@ -75,6 +93,16 @@ func (a *App) AssignInboxNode(nodeID, targetParentID string) (*NodeDTO, error) {
if targetParentID == "" { if targetParentID == "" {
return nil, fmt.Errorf("target parent is required") 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 { if err := a.MoveNode(nodeID, targetParentID); err != nil {
return nil, err return nil, err
} }
@ -88,6 +116,39 @@ func (a *App) AssignInboxNode(nodeID, targetParentID string) (*NodeDTO, error) {
return dto, nil 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 { func (a *App) DeleteInboxNode(nodeID string) error {
if err := a.requireVault(); err != nil { if err := a.requireVault(); err != nil {
return err 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) _, err := a.db.Exec(`DELETE FROM node_meta WHERE node_id = ? AND key LIKE 'capture.%'`, nodeID)
return err 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
}

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()
}

View File

@ -68,13 +68,17 @@ func TestCaptureURLCreatesInboxArtifact(t *testing.T) {
if dto.Title != "Example Page" { if dto.Title != "Example Page" {
t.Fatalf("Title = %q, want Example Page", dto.Title) t.Fatalf("Title = %q, want Example Page", dto.Title)
} }
if dto.Type != "link" {
content, err := app.ReadNote(dto.ID) t.Fatalf("Type = %q, want link", dto.Type)
if err != nil {
t.Fatalf("ReadNote: %v", err)
} }
if !strings.Contains(content, "https://example.test/page") { if dto.SourceKind != "url" {
t.Fatalf("captured URL missing: %q", content) 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)
} }
} }

View File

@ -150,3 +150,70 @@ func TestDeleteInboxNodeRemovesArtifactFromInbox(t *testing.T) {
t.Fatalf("GetActive err = %v, want ErrNotFound", err) 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)
}
}

View File

@ -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);
`

View File

@ -71,6 +71,7 @@ var migrationFiles = map[int]string{
12: migration012, 12: migration012,
13: migration013, 13: migration013,
14: migration014, 14: migration014,
15: migration015,
} }
func (db *DB) runInitialSchema() error { func (db *DB) runInitialSchema() error {