feat: capture files and images in inbox
This commit is contained in:
parent
326f6f283d
commit
a96a316883
|
|
@ -1,7 +1,9 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -11,6 +13,7 @@ import (
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/nodes"
|
"verstak/internal/core/nodes"
|
||||||
syncsvc "verstak/internal/core/sync"
|
syncsvc "verstak/internal/core/sync"
|
||||||
|
"verstak/internal/core/templates"
|
||||||
"verstak/internal/core/util"
|
"verstak/internal/core/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -44,6 +47,134 @@ func (a *App) CaptureURL(rawURL, title string) (*InboxNodeDTO, error) {
|
||||||
return a.createCaptureNote(title, content, "url", "clipboard")
|
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) {
|
func (a *App) createCaptureNote(title, content, kind, source string) (*InboxNodeDTO, error) {
|
||||||
node, err := a.nodes.Create(nil, nodes.TypeNote, title, 0, "", "")
|
node, err := a.nodes.Create(nil, nodes.TypeNote, title, 0, "", "")
|
||||||
if err != nil {
|
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 {
|
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)
|
return nil, fmt.Errorf("insert capture note: %w", err)
|
||||||
}
|
}
|
||||||
if err := a.nodes.MetaSet(node.ID, "capture.inbox", "true"); err != nil {
|
if err := a.setCaptureMeta(node.ID, kind, source); 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 {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,6 +223,29 @@ func (a *App) createCaptureNote(title, content, kind, source string) (*InboxNode
|
||||||
return a.inboxNodeDTO(node)
|
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) {
|
func (a *App) inboxNodeDTO(n *nodes.Node) (*InboxNodeDTO, error) {
|
||||||
dto := &InboxNodeDTO{NodeDTO: toNodeDTO(n)}
|
dto := &InboxNodeDTO{NodeDTO: toNodeDTO(n)}
|
||||||
if kind, ok, err := a.nodes.MetaGet(n.ID, "capture.kind"); err == nil && ok {
|
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
|
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 {
|
func firstLineTitle(text, fallback string) string {
|
||||||
for _, line := range strings.Split(text, "\n") {
|
for _, line := range strings.Split(text, "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
@ -74,3 +77,87 @@ func TestCaptureURLCreatesInboxArtifact(t *testing.T) {
|
||||||
t.Fatalf("captured URL missing: %q", content)
|
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;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-OClTUyKu.js"></script>
|
<script type="module" crossorigin src="/assets/main-D-CBbIRx.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-BE1Aa3xO.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-BgORPqqS.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@
|
||||||
let selectedIds = []
|
let selectedIds = []
|
||||||
let dragIds = []
|
let dragIds = []
|
||||||
let dropRootValid = false
|
let dropRootValid = false
|
||||||
|
let inboxDropValid = false
|
||||||
|
|
||||||
let showConfirm = false
|
let showConfirm = false
|
||||||
let confirmTitle = ''
|
let confirmTitle = ''
|
||||||
|
|
@ -1335,6 +1336,10 @@
|
||||||
// ===== Drag-and-drop =====
|
// ===== Drag-and-drop =====
|
||||||
async function onFilesDropped(paths) {
|
async function onFilesDropped(paths) {
|
||||||
if (!paths || paths.length === 0) return
|
if (!paths || paths.length === 0) return
|
||||||
|
if (selectedSection === 'inbox') {
|
||||||
|
await captureDroppedPaths(paths)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!selectedNode) {
|
if (!selectedNode) {
|
||||||
error = t('error.selectCaseFirst')
|
error = t('error.selectCaseFirst')
|
||||||
return
|
return
|
||||||
|
|
@ -1391,6 +1396,10 @@
|
||||||
if (!source) return ''
|
if (!source) return ''
|
||||||
return t('capture.source.' + source)
|
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) {
|
function looksLikeURL(value) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(value)
|
const parsed = new URL(value)
|
||||||
|
|
@ -1399,6 +1408,43 @@
|
||||||
return false
|
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() {
|
async function captureClipboard() {
|
||||||
if (inboxCaptureBusy) return
|
if (inboxCaptureBusy) return
|
||||||
inboxCaptureStatus = ''
|
inboxCaptureStatus = ''
|
||||||
|
|
@ -1408,6 +1454,7 @@
|
||||||
}
|
}
|
||||||
inboxCaptureBusy = true
|
inboxCaptureBusy = true
|
||||||
try {
|
try {
|
||||||
|
if (await captureClipboardFile()) return
|
||||||
const text = (await navigator.clipboard.readText()).trim()
|
const text = (await navigator.clipboard.readText()).trim()
|
||||||
if (!text) {
|
if (!text) {
|
||||||
inboxCaptureStatus = t('inbox.clipboardEmpty')
|
inboxCaptureStatus = t('inbox.clipboardEmpty')
|
||||||
|
|
@ -1416,7 +1463,7 @@
|
||||||
const item = looksLikeURL(text)
|
const item = looksLikeURL(text)
|
||||||
? await wailsCall('CaptureURL', text, '')
|
? await wailsCall('CaptureURL', text, '')
|
||||||
: await wailsCall('CaptureText', text)
|
: await wailsCall('CaptureText', text)
|
||||||
inboxNodes = [item, ...inboxNodes.filter(existing => existing.id !== item.id)]
|
addInboxCapture(item)
|
||||||
inboxCaptureStatus = t('inbox.captured')
|
inboxCaptureStatus = t('inbox.captured')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e)
|
error = String(e)
|
||||||
|
|
@ -1424,6 +1471,39 @@
|
||||||
inboxCaptureBusy = false
|
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) {
|
function pluralize(n, one, few, many) {
|
||||||
n = Math.abs(n) % 100
|
n = Math.abs(n) % 100
|
||||||
if (n >= 5 && n <= 20) return many
|
if (n >= 5 && n <= 20) return many
|
||||||
|
|
@ -2048,7 +2128,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if selectedSection === 'inbox'}
|
{: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 class="inbox-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>{t('nav.inbox')}</h2>
|
<h2>{t('nav.inbox')}</h2>
|
||||||
|
|
@ -2848,6 +2934,7 @@
|
||||||
|
|
||||||
/* Inbox screen */
|
/* Inbox screen */
|
||||||
.inbox-screen { padding: 24px; overflow-y: auto; flex: 1; }
|
.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 { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||||
.inbox-header h2 { margin: 0 0 6px; }
|
.inbox-header h2 { margin: 0 0 6px; }
|
||||||
.inbox-header p { margin: 0; color: #a0a0b8; font-size: 13px; }
|
.inbox-header p { margin: 0; color: #a0a0b8; font-size: 13px; }
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,14 @@ export function CaptureURL(arg1, arg2) {
|
||||||
return window['go']['main']['App']['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() {
|
export function ListTrash() {
|
||||||
return window['go']['main']['App']['ListTrash']();
|
return window['go']['main']['App']['ListTrash']();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,13 @@ async function runReadyScenario(cdp, url) {
|
||||||
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
|
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
|
||||||
await assertText(cdp, 'https://example.test/from-clipboard', 'inbox: clipboard URL captured')
|
await assertText(cdp, 'https://example.test/from-clipboard', 'inbox: clipboard URL captured')
|
||||||
await assertText(cdp, 'Ссылка', 'inbox: clipboard URL kind visible')
|
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 screenshot(cdp, 'inbox.png')
|
||||||
await clickText(cdp, '.inbox-item', 'Inbox Smoke Item')
|
await clickText(cdp, '.inbox-item', 'Inbox Smoke Item')
|
||||||
await assertText(cdp, 'Inbox Smoke Item', 'inbox: item opens from list')
|
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) {
|
async function clickFolderOpenButton(cdp, name) {
|
||||||
const ok = await evalValue(cdp, `
|
const ok = await evalValue(cdp, `
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -574,6 +604,7 @@ function wailsMockSource() {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
readText: async () => window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ || '',
|
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);
|
state.nodes.push(node);
|
||||||
return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource });
|
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({
|
ListTrash: async () => clone({
|
||||||
trashPath: '/tmp/verstak-smoke-vault/.verstak/trash',
|
trashPath: '/tmp/verstak-smoke-vault/.verstak/trash',
|
||||||
nodes: [{ id: 'node-trash', title: 'Trash Smoke Folder', type: 'folder', fsPath: 'Trash Smoke Folder', deletedAt: now }],
|
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 } };
|
window.go = { main: { App } };
|
||||||
|
const runtimeHandlers = {};
|
||||||
window.runtime = {
|
window.runtime = {
|
||||||
EventsOn: () => {},
|
EventsOn: (name, handler) => { runtimeHandlers[name] = handler; },
|
||||||
EventsOff: () => {},
|
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 };
|
|
||||||
})();
|
})();
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue