feat: capture files and images in inbox
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
@@ -44,6 +47,134 @@ func (a *App) CaptureURL(rawURL, title string) (*InboxNodeDTO, error) {
|
||||
return a.createCaptureNote(title, content, "url", "clipboard")
|
||||
}
|
||||
|
||||
func (a *App) CapturePath(sourcePath 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)
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.setCaptureMeta(node.ID, kind, "drop"); 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) {
|
||||
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)
|
||||
}
|
||||
if err := a.setCaptureMeta(node.ID, captureKindForFilename(filename), "clipboard"); 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) (*InboxNodeDTO, error) {
|
||||
node, err := a.nodes.Create(nil, nodes.TypeNote, title, 0, "", "")
|
||||
if err != nil {
|
||||
@@ -82,16 +213,7 @@ func (a *App) createCaptureNote(title, content, kind, source string) (*InboxNode
|
||||
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.nodes.MetaSet(node.ID, "capture.inbox", "true"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.nodes.MetaSet(node.ID, "capture.kind", kind); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.nodes.MetaSet(node.ID, "capture.source", source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.nodes.MetaSet(node.ID, "capture.created_at", now.Format(time.RFC3339)); err != nil {
|
||||
if err := a.setCaptureMeta(node.ID, kind, source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -101,6 +223,29 @@ func (a *App) createCaptureNote(title, content, kind, source string) (*InboxNode
|
||||
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) error {
|
||||
if err := a.nodes.MetaSet(nodeID, "capture.inbox", "true"); 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", source); 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 {
|
||||
@@ -112,6 +257,34 @@ func (a *App) inboxNodeDTO(n *nodes.Node) (*InboxNodeDTO, error) {
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -74,3 +77,87 @@ func TestCaptureURLCreatesInboxArtifact(t *testing.T) {
|
||||
t.Fatalf("captured URL missing: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePathCopiesFileIntoInbox(t *testing.T) {
|
||||
app, vaultRoot := setupTestApp(t)
|
||||
sourceDir := t.TempDir()
|
||||
source := filepath.Join(sourceDir, "brief.pdf")
|
||||
if err := os.WriteFile(source, []byte("pdf content"), 0o640); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
|
||||
dto, err := app.CapturePath(source)
|
||||
if err != nil {
|
||||
t.Fatalf("CapturePath: %v", err)
|
||||
}
|
||||
if dto.CaptureKind != "file" {
|
||||
t.Fatalf("CaptureKind = %q, want file", dto.CaptureKind)
|
||||
}
|
||||
|
||||
records, err := app.files.ListByNode(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByNode: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %d, want 1", len(records))
|
||||
}
|
||||
if !strings.HasPrefix(records[0].Path, ".verstak/inbox/") {
|
||||
t.Fatalf("path = %q, want .verstak/inbox prefix", records[0].Path)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(vaultRoot, records[0].Path)); err != nil {
|
||||
t.Fatalf("captured file missing in vault: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePathCopiesDirectoryIntoInbox(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
source := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(source, "nested"), 0o750); err != nil {
|
||||
t.Fatalf("mkdir nested: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "nested", "note.txt"), []byte("nested"), 0o640); err != nil {
|
||||
t.Fatalf("write nested file: %v", err)
|
||||
}
|
||||
|
||||
dto, err := app.CapturePath(source)
|
||||
if err != nil {
|
||||
t.Fatalf("CapturePath: %v", err)
|
||||
}
|
||||
if dto.CaptureKind != "folder" {
|
||||
t.Fatalf("CaptureKind = %q, want folder", dto.CaptureKind)
|
||||
}
|
||||
|
||||
items, err := app.ListItems(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListItems: %v", err)
|
||||
}
|
||||
var foundNested bool
|
||||
for _, item := range items {
|
||||
if item.Name == "nested" && item.Type == "folder" {
|
||||
foundNested = true
|
||||
}
|
||||
}
|
||||
if !foundNested {
|
||||
t.Fatalf("captured folder children missing: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureFileDataCreatesImageInboxArtifact(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
data := base64.StdEncoding.EncodeToString([]byte("fake image bytes"))
|
||||
|
||||
dto, err := app.CaptureFileData("pasted.png", data)
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureFileData: %v", err)
|
||||
}
|
||||
if dto.CaptureKind != "image" {
|
||||
t.Fatalf("CaptureKind = %q, want image", dto.CaptureKind)
|
||||
}
|
||||
records, err := app.files.ListByNode(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByNode: %v", err)
|
||||
}
|
||||
if len(records) != 1 || records[0].MIME != "image/png" {
|
||||
t.Fatalf("records = %+v, want one png image", records)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,8 +16,8 @@
|
||||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-OClTUyKu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BE1Aa3xO.css">
|
||||
<script type="module" crossorigin src="/assets/main-D-CBbIRx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BgORPqqS.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user