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") } normalizedURL, ok := normalizeHTTPURL(rawURL) if !ok { return nil, fmt.Errorf("invalid url") } rawURL = normalizedURL 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 }