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