feat: resolve inbox links separately
This commit is contained in:
parent
336037d887
commit
bcb093d453
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
`
|
||||
|
|
@ -71,6 +71,7 @@ var migrationFiles = map[int]string{
|
|||
12: migration012,
|
||||
13: migration013,
|
||||
14: migration014,
|
||||
15: migration015,
|
||||
}
|
||||
|
||||
func (db *DB) runInitialSchema() error {
|
||||
|
|
|
|||
Loading…
Reference in New Issue