feat: capture files and images in inbox

This commit is contained in:
mirivlad 2026-06-05 02:06:21 +08:00
parent 326f6f283d
commit a96a316883
10 changed files with 427 additions and 21 deletions

View File

@ -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)

View File

@ -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

View File

@ -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>

View File

@ -137,6 +137,7 @@
let selectedIds = []
let dragIds = []
let dropRootValid = false
let inboxDropValid = false
let showConfirm = false
let confirmTitle = ''
@ -1335,6 +1336,10 @@
// ===== Drag-and-drop =====
async function onFilesDropped(paths) {
if (!paths || paths.length === 0) return
if (selectedSection === 'inbox') {
await captureDroppedPaths(paths)
return
}
if (!selectedNode) {
error = t('error.selectCaseFirst')
return
@ -1391,6 +1396,10 @@
if (!source) return ''
return t('capture.source.' + source)
}
function addInboxCapture(item) {
if (!item || !item.id) return
inboxNodes = [item, ...inboxNodes.filter(existing => existing.id !== item.id)]
}
function looksLikeURL(value) {
try {
const parsed = new URL(value)
@ -1399,6 +1408,43 @@
return false
}
}
function extensionForMime(type) {
const map = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
}
return map[type] || 'bin'
}
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const value = String(reader.result || '')
const comma = value.indexOf(',')
resolve(comma >= 0 ? value.slice(comma + 1) : value)
}
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(blob)
})
}
async function captureClipboardFile() {
if (!navigator.clipboard || typeof navigator.clipboard.read !== 'function') return false
const items = await navigator.clipboard.read()
for (const item of items || []) {
const type = (item.types || []).find(value => value.startsWith('image/'))
if (!type) continue
const blob = await item.getType(type)
const filename = blob.name || `clipboard.${extensionForMime(type)}`
const data = await blobToBase64(blob)
const captured = await wailsCall('CaptureFileData', filename, data)
addInboxCapture(captured)
inboxCaptureStatus = t('inbox.captured')
return true
}
return false
}
async function captureClipboard() {
if (inboxCaptureBusy) return
inboxCaptureStatus = ''
@ -1408,6 +1454,7 @@
}
inboxCaptureBusy = true
try {
if (await captureClipboardFile()) return
const text = (await navigator.clipboard.readText()).trim()
if (!text) {
inboxCaptureStatus = t('inbox.clipboardEmpty')
@ -1416,7 +1463,7 @@
const item = looksLikeURL(text)
? await wailsCall('CaptureURL', text, '')
: await wailsCall('CaptureText', text)
inboxNodes = [item, ...inboxNodes.filter(existing => existing.id !== item.id)]
addInboxCapture(item)
inboxCaptureStatus = t('inbox.captured')
} catch (e) {
error = String(e)
@ -1424,6 +1471,39 @@
inboxCaptureBusy = false
}
}
async function captureDroppedPaths(paths) {
if (inboxCaptureBusy) return
inboxCaptureBusy = true
inboxCaptureStatus = ''
try {
for (const path of paths) {
const item = await wailsCall('CapturePath', path)
addInboxCapture(item)
}
inboxCaptureStatus = t('inbox.captured')
} catch (e) {
error = String(e)
} finally {
inboxCaptureBusy = false
inboxDropValid = false
}
}
function handleInboxDragOver(e) {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
inboxDropValid = true
}
function handleInboxDragLeave() {
inboxDropValid = false
}
async function handleInboxDrop(e) {
e.preventDefault()
inboxDropValid = false
const paths = Array.from(e.dataTransfer?.files || [])
.map(file => file.path || file.webkitRelativePath || '')
.filter(Boolean)
if (paths.length > 0) await captureDroppedPaths(paths)
}
function pluralize(n, one, few, many) {
n = Math.abs(n) % 100
if (n >= 5 && n <= 20) return many
@ -2048,7 +2128,13 @@
</div>
{:else if selectedSection === 'inbox'}
<div class="inbox-screen">
<div class="inbox-screen"
class:drop-valid={inboxDropValid}
role="region"
aria-label={t('nav.inbox')}
on:dragover={handleInboxDragOver}
on:dragleave={handleInboxDragLeave}
on:drop={handleInboxDrop}>
<div class="inbox-header">
<div>
<h2>{t('nav.inbox')}</h2>
@ -2848,6 +2934,7 @@
/* Inbox screen */
.inbox-screen { padding: 24px; overflow-y: auto; flex: 1; }
.inbox-screen.drop-valid { outline: 2px dashed #4ade80; outline-offset: -4px; background: rgba(74, 222, 128, 0.04); }
.inbox-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.inbox-header h2 { margin: 0 0 6px; }
.inbox-header p { margin: 0; color: #a0a0b8; font-size: 13px; }

View File

@ -90,6 +90,14 @@ export function CaptureURL(arg1, arg2) {
return window['go']['main']['App']['CaptureURL'](arg1, arg2);
}
export function CapturePath(arg1) {
return window['go']['main']['App']['CapturePath'](arg1);
}
export function CaptureFileData(arg1, arg2) {
return window['go']['main']['App']['CaptureFileData'](arg1, arg2);
}
export function ListTrash() {
return window['go']['main']['App']['ListTrash']();
}

View File

@ -144,6 +144,13 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
await assertText(cdp, 'https://example.test/from-clipboard', 'inbox: clipboard URL captured')
await assertText(cdp, 'Ссылка', 'inbox: clipboard URL kind visible')
await emitDroppedFiles(cdp, ['/tmp/smoke-drop-folder'])
await assertText(cdp, 'smoke-drop-folder', 'inbox: dropped folder captured')
await assertText(cdp, 'Перетаскивание', 'inbox: dropped source visible')
await setClipboardImage(cdp, 'pasted-smoke.png', 'image/png', 'c21va2UtaW1hZ2U=')
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
await assertText(cdp, 'pasted-smoke.png', 'inbox: clipboard image captured')
await assertText(cdp, 'Изображение', 'inbox: clipboard image kind visible')
await screenshot(cdp, 'inbox.png')
await clickText(cdp, '.inbox-item', 'Inbox Smoke Item')
await assertText(cdp, 'Inbox Smoke Item', 'inbox: item opens from list')
@ -346,6 +353,29 @@ async function setClipboardText(cdp, value) {
})
}
async function setClipboardImage(cdp, name, type, base64) {
await cdp.send('Runtime.evaluate', {
expression: `
window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ = '';
window.__VERSTAK_GUI_SMOKE_CLIPBOARD_ITEMS__ = [{
types: [${JSON.stringify(type)}],
getType: async () => new File([Uint8Array.from(atob(${JSON.stringify(base64)}), c => c.charCodeAt(0))], ${JSON.stringify(name)}, { type: ${JSON.stringify(type)} }),
}];
`,
awaitPromise: true,
returnByValue: true,
})
}
async function emitDroppedFiles(cdp, paths) {
await cdp.send('Runtime.evaluate', {
expression: `window.__VERSTAK_GUI_SMOKE__.dropFiles(${JSON.stringify(paths)})`,
awaitPromise: true,
returnByValue: true,
})
await sleep(300)
}
async function clickFolderOpenButton(cdp, name) {
const ok = await evalValue(cdp, `
(() => {
@ -574,6 +604,7 @@ function wailsMockSource() {
configurable: true,
value: {
readText: async () => window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ || '',
read: async () => window.__VERSTAK_GUI_SMOKE_CLIPBOARD_ITEMS__ || [],
},
});
@ -719,6 +750,18 @@ function wailsMockSource() {
state.nodes.push(node);
return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource });
},
CapturePath: async (sourcePath) => {
const title = String(sourcePath || '').split('/').filter(Boolean).pop() || 'Dropped file';
const kind = title.includes('folder') ? 'folder' : 'file';
const node = { id: 'node-capture-path-' + Date.now(), title, type: kind === 'folder' ? 'folder' : 'file', section: '', captureInbox: true, captureKind: kind, captureSource: 'drop', createdAt: now, has_children: false, children: [] };
state.nodes.push(node);
return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource });
},
CaptureFileData: async (filename) => {
const node = { id: 'node-capture-data-' + Date.now(), title: filename, type: 'file', section: '', captureInbox: true, captureKind: filename.endsWith('.png') ? 'image' : 'file', captureSource: 'clipboard', createdAt: now, has_children: false, children: [] };
state.nodes.push(node);
return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource });
},
ListTrash: async () => clone({
trashPath: '/tmp/verstak-smoke-vault/.verstak/trash',
nodes: [{ id: 'node-trash', title: 'Trash Smoke Folder', type: 'folder', fsPath: 'Trash Smoke Folder', deletedAt: now }],
@ -854,11 +897,19 @@ function wailsMockSource() {
};
window.go = { main: { App } };
const runtimeHandlers = {};
window.runtime = {
EventsOn: () => {},
EventsOff: () => {},
EventsOn: (name, handler) => { runtimeHandlers[name] = handler; },
EventsOff: (name) => { delete runtimeHandlers[name]; },
};
window.__VERSTAK_GUI_SMOKE__ = {
mode,
state,
events,
dropFiles: async (paths) => {
if (runtimeHandlers['files-dropped']) await runtimeHandlers['files-dropped'](paths);
},
};
window.__VERSTAK_GUI_SMOKE__ = { mode, state, events };
})();
`
}