471 lines
14 KiB
Go
471 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"verstak/internal/core/activity"
|
|
"verstak/internal/core/files"
|
|
"verstak/internal/core/nodes"
|
|
syncsvc "verstak/internal/core/sync"
|
|
"verstak/internal/core/templates"
|
|
"verstak/internal/core/util"
|
|
)
|
|
|
|
type CaptureContextDTO struct {
|
|
ContextType string `json:"contextType"`
|
|
NodeID string `json:"nodeId,omitempty"`
|
|
Section string `json:"section,omitempty"`
|
|
SuggestedTargetNodeID string `json:"suggestedTargetNodeId,omitempty"`
|
|
}
|
|
|
|
func (a *App) CaptureText(text string) (*InboxNodeDTO, error) {
|
|
return a.CaptureTextWithContext(text, "clipboard", "")
|
|
}
|
|
|
|
func (a *App) CaptureTextWithContext(text, source, contextJSON string) (*InboxNodeDTO, error) {
|
|
if err := a.requireVault(); err != nil {
|
|
return nil, err
|
|
}
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return nil, fmt.Errorf("text required")
|
|
}
|
|
title := firstLineTitle(text, "Captured text")
|
|
content := "# " + title + "\n\n" + text + "\n"
|
|
ctx := parseCaptureContext(contextJSON)
|
|
return a.createCaptureNote(title, content, "text", source, ctx)
|
|
}
|
|
|
|
func (a *App) CaptureURL(rawURL, title string) (*InboxNodeDTO, error) {
|
|
return a.CaptureURLWithContext(rawURL, title, "clipboard", "")
|
|
}
|
|
|
|
func (a *App) CaptureURLWithContext(rawURL, title, source, contextJSON string) (*InboxNodeDTO, error) {
|
|
if err := a.requireVault(); err != nil {
|
|
return nil, err
|
|
}
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return nil, fmt.Errorf("url required")
|
|
}
|
|
title = strings.TrimSpace(title)
|
|
if title == "" {
|
|
title = linkTitle(rawURL, "")
|
|
}
|
|
ctx := parseCaptureContext(contextJSON)
|
|
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)
|
|
}
|
|
|
|
func (a *App) CapturePath(sourcePath string) (*InboxNodeDTO, error) {
|
|
return a.CapturePathWithContext(sourcePath, "drop", "")
|
|
}
|
|
|
|
func (a *App) CapturePathWithContext(sourcePath, source, contextJSON string) (*InboxNodeDTO, error) {
|
|
if err := a.requireVault(); err != nil {
|
|
return nil, err
|
|
}
|
|
sourcePath = strings.TrimSpace(sourcePath)
|
|
if sourcePath == "" {
|
|
return nil, fmt.Errorf("path required")
|
|
}
|
|
absPath, err := filepath.Abs(sourcePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("abs path: %w", err)
|
|
}
|
|
info, err := os.Stat(absPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stat: %w", err)
|
|
}
|
|
|
|
nodeType := nodes.TypeFile
|
|
kind := captureKindForFilename(absPath)
|
|
if info.IsDir() {
|
|
nodeType = nodes.TypeFolder
|
|
kind = "folder"
|
|
}
|
|
node, err := a.nodes.Create(nil, nodeType, filepath.Base(absPath), 0, "", "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create capture node: %w", err)
|
|
}
|
|
stagingRel, _, err := a.captureStagingDir(node)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if info.IsDir() {
|
|
if err := a.nodes.UpdateFsPath(node.ID, stagingRel); err != nil {
|
|
return nil, fmt.Errorf("set capture folder path: %w", err)
|
|
}
|
|
entries, err := os.ReadDir(absPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read source dir: %w", err)
|
|
}
|
|
for _, entry := range entries {
|
|
if _, err := a.files.AddPathCopy(node.ID, filepath.Join(absPath, entry.Name())); err != nil {
|
|
return nil, fmt.Errorf("copy capture child %s: %w", entry.Name(), err)
|
|
}
|
|
}
|
|
} else {
|
|
if _, err := a.files.CopyIntoVault(node.ID, absPath, stagingRel); err != nil {
|
|
return nil, fmt.Errorf("copy capture file: %w", err)
|
|
}
|
|
}
|
|
|
|
ctx := parseCaptureContext(contextJSON)
|
|
if err := a.setCaptureMeta(node.ID, kind, source, ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
target := activity.TargetFile
|
|
evType := activity.TypeFileAdded
|
|
entity := syncsvc.EntityFile
|
|
if info.IsDir() {
|
|
target = activity.TargetFolder
|
|
evType = activity.TypeFolderAdded
|
|
entity = syncsvc.EntityFolder
|
|
}
|
|
_ = a.activity.Record("", target, node.ID, "", evType, node.Title, `{"capture":true}`)
|
|
_ = a.sync.RecordOp(entity, node.ID, syncsvc.OpCreate, a.filePayload(node))
|
|
return a.inboxNodeDTO(node)
|
|
}
|
|
|
|
func (a *App) CaptureFileData(filename, dataBase64 string) (*InboxNodeDTO, error) {
|
|
return a.CaptureFileDataWithContext(filename, dataBase64, "clipboard", "")
|
|
}
|
|
|
|
func (a *App) CaptureFileDataWithContext(filename, dataBase64, source, contextJSON string) (*InboxNodeDTO, error) {
|
|
if err := a.requireVault(); err != nil {
|
|
return nil, err
|
|
}
|
|
filename = filepath.Base(strings.TrimSpace(filename))
|
|
if filename == "." || filename == "" {
|
|
filename = "clipboard.bin"
|
|
}
|
|
if err := files.ValidateName(filename); err != nil {
|
|
return nil, err
|
|
}
|
|
if comma := strings.Index(dataBase64, ","); comma >= 0 {
|
|
dataBase64 = dataBase64[comma+1:]
|
|
}
|
|
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(dataBase64))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode file data: %w", err)
|
|
}
|
|
if len(data) == 0 {
|
|
return nil, fmt.Errorf("file data required")
|
|
}
|
|
|
|
node, err := a.nodes.Create(nil, nodes.TypeFile, filename, 0, "", "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create capture file node: %w", err)
|
|
}
|
|
stagingRel, stagingAbs, err := a.captureStagingDir(node)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
absPath := filepath.Join(stagingAbs, filename)
|
|
if err := os.WriteFile(absPath, data, 0o640); err != nil {
|
|
return nil, fmt.Errorf("write capture data: %w", err)
|
|
}
|
|
relPath := filepath.Join(stagingRel, filename)
|
|
fileRec := &files.Record{
|
|
ID: util.UUID7(),
|
|
NodeID: node.ID,
|
|
Filename: filename,
|
|
Path: relPath,
|
|
StorageMode: "vault",
|
|
Size: int64(len(data)),
|
|
MIME: mimeForFilename(filename),
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
if _, err := a.db.Exec(
|
|
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing)
|
|
VALUES (?,?,?,?,?,?,?,?,?,0)`,
|
|
fileRec.ID, fileRec.NodeID, fileRec.Filename, fileRec.Path, fileRec.StorageMode,
|
|
fileRec.Size, fileRec.MIME, fileRec.CreatedAt.Format(time.RFC3339), fileRec.UpdatedAt.Format(time.RFC3339)); err != nil {
|
|
return nil, fmt.Errorf("insert capture file data: %w", err)
|
|
}
|
|
ctx := parseCaptureContext(contextJSON)
|
|
if err := a.setCaptureMeta(node.ID, captureKindForFilename(filename), source, ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = a.activity.Record("", activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, `{"capture":true}`)
|
|
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
|
|
return a.inboxNodeDTO(node)
|
|
}
|
|
|
|
func (a *App) createCaptureNote(title, content, kind, source string, ctx CaptureContextDTO) (*InboxNodeDTO, error) {
|
|
node, err := a.nodes.Create(nil, nodes.TypeNote, title, 0, "", "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create node: %w", err)
|
|
}
|
|
|
|
inboxDir := filepath.Join(a.vault, ".verstak", "inbox")
|
|
if err := os.MkdirAll(inboxDir, 0o750); err != nil {
|
|
return nil, fmt.Errorf("create inbox dir: %w", err)
|
|
}
|
|
filename := node.ID + ".md"
|
|
absPath := filepath.Join(inboxDir, filename)
|
|
if err := os.WriteFile(absPath, []byte(content), 0o640); err != nil {
|
|
return nil, fmt.Errorf("write capture note: %w", err)
|
|
}
|
|
relPath, _ := filepath.Rel(a.vault, absPath)
|
|
now := time.Now().UTC()
|
|
fileRec := &files.Record{
|
|
ID: util.UUID7(),
|
|
NodeID: node.ID,
|
|
Filename: filename,
|
|
Path: relPath,
|
|
StorageMode: "vault",
|
|
Size: int64(len(content)),
|
|
MIME: "text/markdown",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if _, err := a.db.Exec(
|
|
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing)
|
|
VALUES (?,?,?,?,?,?,?,?,?,0)`,
|
|
fileRec.ID, fileRec.NodeID, fileRec.Filename, fileRec.Path, fileRec.StorageMode,
|
|
fileRec.Size, fileRec.MIME, fileRec.CreatedAt.Format(time.RFC3339), fileRec.UpdatedAt.Format(time.RFC3339)); err != nil {
|
|
return nil, fmt.Errorf("insert capture file: %w", err)
|
|
}
|
|
if _, err := a.db.Exec(`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, node.ID, fileRec.ID, "markdown"); err != nil {
|
|
return nil, fmt.Errorf("insert capture note: %w", err)
|
|
}
|
|
if err := a.setCaptureMeta(node.ID, kind, source, ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_ = a.activity.Record("", activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, `{"capture":true}`)
|
|
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, notePayload(node, fileRec, content))
|
|
|
|
return a.inboxNodeDTO(node)
|
|
}
|
|
|
|
func (a *App) captureStagingDir(node *nodes.Node) (string, string, error) {
|
|
segment := node.ID + "_" + templates.SafeDisplayNameToPathSegment(node.Title)
|
|
rel := filepath.Join(".verstak", "inbox", segment)
|
|
abs := filepath.Join(a.vault, rel)
|
|
if err := os.MkdirAll(abs, 0o750); err != nil {
|
|
return "", "", fmt.Errorf("create inbox staging dir: %w", err)
|
|
}
|
|
return rel, abs, nil
|
|
}
|
|
|
|
func (a *App) setCaptureMeta(nodeID, kind, source string, ctx CaptureContextDTO) error {
|
|
if source == "" {
|
|
source = "clipboard"
|
|
}
|
|
ctx = normalizeCaptureContext(ctx)
|
|
if err := a.nodes.MetaSet(nodeID, "capture.inbox", "true"); err != nil {
|
|
return err
|
|
}
|
|
if err := a.nodes.MetaSet(nodeID, "capture.status", "unresolved"); err != nil {
|
|
return err
|
|
}
|
|
if err := a.nodes.MetaSet(nodeID, "capture.kind", kind); err != nil {
|
|
return err
|
|
}
|
|
if err := a.nodes.MetaSet(nodeID, "capture.source_kind", kind); err != nil {
|
|
return err
|
|
}
|
|
if err := a.nodes.MetaSet(nodeID, "capture.source", source); err != nil {
|
|
return err
|
|
}
|
|
if err := a.nodes.MetaSet(nodeID, "capture.context_type", ctx.ContextType); err != nil {
|
|
return err
|
|
}
|
|
if ctx.NodeID != "" {
|
|
if err := a.nodes.MetaSet(nodeID, "capture.context_node_id", ctx.NodeID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if ctx.Section != "" {
|
|
if err := a.nodes.MetaSet(nodeID, "capture.context_section", ctx.Section); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if ctx.SuggestedTargetNodeID != "" {
|
|
if err := a.nodes.MetaSet(nodeID, "capture.suggested_target_node_id", ctx.SuggestedTargetNodeID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return a.nodes.MetaSet(nodeID, "capture.created_at", time.Now().UTC().Format(time.RFC3339))
|
|
}
|
|
|
|
func (a *App) inboxNodeDTO(n *nodes.Node) (*InboxNodeDTO, error) {
|
|
dto := &InboxNodeDTO{NodeDTO: toNodeDTO(n)}
|
|
if kind, ok, err := a.nodes.MetaGet(n.ID, "capture.kind"); err == nil && ok {
|
|
dto.CaptureKind = kind
|
|
}
|
|
if source, ok, err := a.nodes.MetaGet(n.ID, "capture.source"); err == nil && ok {
|
|
dto.CaptureSource = source
|
|
}
|
|
if status, ok, err := a.nodes.MetaGet(n.ID, "capture.status"); err == nil && ok {
|
|
dto.CaptureStatus = status
|
|
}
|
|
if dto.CaptureStatus == "" {
|
|
dto.CaptureStatus = "unresolved"
|
|
}
|
|
if kind, ok, err := a.nodes.MetaGet(n.ID, "capture.source_kind"); err == nil && ok {
|
|
dto.SourceKind = kind
|
|
}
|
|
if dto.SourceKind == "" {
|
|
dto.SourceKind = dto.CaptureKind
|
|
}
|
|
if contextType, ok, err := a.nodes.MetaGet(n.ID, "capture.context_type"); err == nil && ok {
|
|
dto.CaptureContextType = contextType
|
|
}
|
|
if dto.CaptureContextType == "" {
|
|
dto.CaptureContextType = "global"
|
|
}
|
|
if nodeID, ok, err := a.nodes.MetaGet(n.ID, "capture.context_node_id"); err == nil && ok {
|
|
dto.CaptureContextNodeID = nodeID
|
|
dto.CaptureContextLabel = a.captureNodeLabel(nodeID)
|
|
}
|
|
if section, ok, err := a.nodes.MetaGet(n.ID, "capture.context_section"); err == nil && ok {
|
|
dto.CaptureContextSection = section
|
|
if dto.CaptureContextLabel == "" {
|
|
dto.CaptureContextLabel = section
|
|
}
|
|
}
|
|
if targetID, ok, err := a.nodes.MetaGet(n.ID, "capture.suggested_target_node_id"); err == nil && ok {
|
|
dto.SuggestedTargetNodeID = targetID
|
|
dto.SuggestedTargetLabel = a.captureNodeLabel(targetID)
|
|
}
|
|
if capturedAt, ok, err := a.nodes.MetaGet(n.ID, "capture.created_at"); err == nil && ok {
|
|
dto.CapturedAt = capturedAt
|
|
}
|
|
if rawURL, ok, err := a.nodes.MetaGet(n.ID, "capture.url"); err == nil && ok {
|
|
dto.URL = rawURL
|
|
}
|
|
if hostname, ok, err := a.nodes.MetaGet(n.ID, "capture.hostname"); err == nil && ok {
|
|
dto.Hostname = hostname
|
|
}
|
|
return dto, nil
|
|
}
|
|
|
|
func parseCaptureContext(contextJSON string) CaptureContextDTO {
|
|
var ctx CaptureContextDTO
|
|
if strings.TrimSpace(contextJSON) != "" {
|
|
_ = json.Unmarshal([]byte(contextJSON), &ctx)
|
|
}
|
|
return normalizeCaptureContext(ctx)
|
|
}
|
|
|
|
func normalizeCaptureContext(ctx CaptureContextDTO) CaptureContextDTO {
|
|
ctx.ContextType = strings.TrimSpace(ctx.ContextType)
|
|
ctx.NodeID = strings.TrimSpace(ctx.NodeID)
|
|
ctx.Section = strings.TrimSpace(ctx.Section)
|
|
ctx.SuggestedTargetNodeID = strings.TrimSpace(ctx.SuggestedTargetNodeID)
|
|
switch ctx.ContextType {
|
|
case "node":
|
|
if ctx.NodeID == "" {
|
|
ctx.ContextType = "global"
|
|
break
|
|
}
|
|
if ctx.SuggestedTargetNodeID == "" {
|
|
ctx.SuggestedTargetNodeID = ctx.NodeID
|
|
}
|
|
case "section":
|
|
if ctx.Section == "" {
|
|
ctx.Section = "root"
|
|
}
|
|
case "global":
|
|
default:
|
|
if ctx.NodeID != "" {
|
|
ctx.ContextType = "node"
|
|
if ctx.SuggestedTargetNodeID == "" {
|
|
ctx.SuggestedTargetNodeID = ctx.NodeID
|
|
}
|
|
} else if ctx.Section != "" {
|
|
ctx.ContextType = "section"
|
|
} else {
|
|
ctx.ContextType = "global"
|
|
}
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
func (a *App) captureNodeLabel(nodeID string) string {
|
|
if nodeID == "" {
|
|
return ""
|
|
}
|
|
if p := a.nodes.Path(nodeID); p != "" {
|
|
return p
|
|
}
|
|
if n, err := a.nodes.GetActive(nodeID); err == nil {
|
|
return n.Title
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func hostnameForURL(rawURL string) string {
|
|
u, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return u.Hostname()
|
|
}
|
|
|
|
func captureKindForFilename(filename string) string {
|
|
if strings.HasPrefix(mimeForFilename(filename), "image/") {
|
|
return "image"
|
|
}
|
|
return "file"
|
|
}
|
|
|
|
func mimeForFilename(filename string) string {
|
|
ext := strings.ToLower(filepath.Ext(filename))
|
|
switch ext {
|
|
case ".png":
|
|
return "image/png"
|
|
case ".jpg", ".jpeg":
|
|
return "image/jpeg"
|
|
case ".gif":
|
|
return "image/gif"
|
|
case ".webp":
|
|
return "image/webp"
|
|
}
|
|
if mimeType := mime.TypeByExtension(ext); mimeType != "" {
|
|
if semi := strings.Index(mimeType, ";"); semi >= 0 {
|
|
return mimeType[:semi]
|
|
}
|
|
return mimeType
|
|
}
|
|
return "application/octet-stream"
|
|
}
|
|
|
|
func firstLineTitle(text, fallback string) string {
|
|
for _, line := range strings.Split(text, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" {
|
|
if len(line) > 80 {
|
|
return line[:80]
|
|
}
|
|
return line
|
|
}
|
|
}
|
|
return fallback
|
|
}
|