Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2487d3bbaa | ||
|
|
645d8878cc |
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd frontend && npm run build && cd ..
|
||||
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
|
||||
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
||||
+149
-19
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
@@ -13,10 +14,13 @@ import (
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
|
||||
|
||||
// App is the Wails v2 application adapter. It wraps core services.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
@@ -26,12 +30,18 @@ type App struct {
|
||||
notes *notes.Service
|
||||
actions *actions.Service
|
||||
worklog *worklog.Service
|
||||
search *search.Service
|
||||
vault string
|
||||
}
|
||||
|
||||
// startup is called when the app starts. Store context.
|
||||
// startup is called when the app starts. Store context and wire drag-and-drop.
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
wailsruntime.OnFileDrop(ctx, func(x, y int, paths []string) {
|
||||
if len(paths) > 0 {
|
||||
wailsruntime.EventsEmit(ctx, "files-dropped", paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -72,6 +82,16 @@ type FileDTO struct {
|
||||
Missing bool `json:"missing"`
|
||||
}
|
||||
|
||||
type FileTreeItemDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "folder" | "file"
|
||||
FileID string `json:"fileId,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Mime string `json:"mime,omitempty"`
|
||||
HasKids bool `json:"hasKids"`
|
||||
}
|
||||
|
||||
type ActionDTO struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
@@ -196,6 +216,7 @@ func (a *App) SaveNote(noteID, content string) error {
|
||||
// Files
|
||||
// ============================================================
|
||||
|
||||
// ListFiles returns file records directly linked to a node (non-recursive).
|
||||
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
||||
records, err := a.files.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
@@ -203,22 +224,105 @@ func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
||||
}
|
||||
result := make([]FileDTO, len(records))
|
||||
for i := range records {
|
||||
isDir := records[i].MIME == "inode/directory"
|
||||
missing := false
|
||||
rec := &records[i]
|
||||
result[i] = FileDTO{
|
||||
ID: records[i].ID,
|
||||
NodeID: records[i].NodeID,
|
||||
Name: records[i].Filename,
|
||||
Path: records[i].Path,
|
||||
Size: records[i].Size,
|
||||
Mime: records[i].MIME,
|
||||
IsDir: isDir,
|
||||
Missing: missing,
|
||||
ID: rec.ID,
|
||||
NodeID: rec.NodeID,
|
||||
Name: rec.Filename,
|
||||
Path: rec.Path,
|
||||
Size: rec.Size,
|
||||
Mime: rec.MIME,
|
||||
IsDir: rec.MIME == "inode/directory",
|
||||
Missing: rec.Missing,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListItems returns children of a node for the file tree view.
|
||||
// Folders can be expanded; files include their file record info.
|
||||
func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
|
||||
children, err := a.nodes.ListChildren(nodeID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]FileTreeItemDTO, 0, len(children))
|
||||
for i := range children {
|
||||
if children[i].Type != nodes.TypeFolder && children[i].Type != nodes.TypeFile {
|
||||
continue
|
||||
}
|
||||
item := FileTreeItemDTO{
|
||||
ID: children[i].ID,
|
||||
Name: children[i].Title,
|
||||
Type: children[i].Type,
|
||||
}
|
||||
if children[i].Type == nodes.TypeFolder {
|
||||
// Check if this folder has children
|
||||
kids, _ := a.nodes.ListChildren(children[i].ID, false)
|
||||
item.HasKids = len(kids) > 0
|
||||
} else if children[i].Type == nodes.TypeFile {
|
||||
records, _ := a.files.ListByNode(children[i].ID)
|
||||
if len(records) > 0 {
|
||||
item.FileID = records[0].ID
|
||||
item.Size = records[0].Size
|
||||
item.Mime = records[0].MIME
|
||||
}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
nodes, err := a.files.AddPathCopy(nodeID, sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
nodes, err := a.files.AddPathLink(nodeID, sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteFileOrFolder(nodeID string) error {
|
||||
return a.files.DeleteNodeAndChildren(nodeID)
|
||||
}
|
||||
|
||||
func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
|
||||
node, err := a.files.CreateEmptyFile(parentID, filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
|
||||
node, err := a.files.Duplicate(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||
return a.nodes.UpdateTitle(nodeID, newTitle)
|
||||
}
|
||||
|
||||
func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||
return a.nodes.Move(nodeID, newParentID, 0)
|
||||
}
|
||||
|
||||
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
|
||||
return a.files.PreviewImport(sourcePath)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Actions
|
||||
// ============================================================
|
||||
@@ -296,14 +400,27 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Search (stubs — search service not wired yet)
|
||||
// Search
|
||||
// ============================================================
|
||||
|
||||
func (a *App) Search(query string) ([]SearchResultDTO, error) {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return []SearchResultDTO{}, nil
|
||||
}
|
||||
return []SearchResultDTO{}, nil
|
||||
results, err := a.search.Search(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SearchResultDTO, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = SearchResultDTO{
|
||||
NodeID: r.NodeID,
|
||||
Title: r.Title,
|
||||
Snippet: r.Snippet,
|
||||
Type: r.Type,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -333,11 +450,27 @@ func (a *App) PickDirectory() (string, error) {
|
||||
// ============================================================
|
||||
|
||||
func (a *App) OpenFile(fileID string) error {
|
||||
return fmt.Errorf("not implemented: %s", fileID)
|
||||
return a.files.Open(fileID)
|
||||
}
|
||||
|
||||
func (a *App) ReadFileText(fileID string) (string, error) {
|
||||
return a.files.ReadText(fileID)
|
||||
}
|
||||
|
||||
func (a *App) GetFileBase64(fileID string) (string, error) {
|
||||
return a.files.ReadBase64(fileID)
|
||||
}
|
||||
|
||||
func (a *App) OpenFolder(nodeID string) error {
|
||||
cmd := exec.Command("xdg-open", a.vault)
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get node: %w", err)
|
||||
}
|
||||
dir := filepath.Join(a.vault, "spaces", n.Slug)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
dir = a.vault
|
||||
}
|
||||
cmd := exec.Command("xdg-open", dir)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
@@ -377,7 +510,4 @@ func toNodeDTOs(list []nodes.Node) []NodeDTO {
|
||||
return result
|
||||
}
|
||||
|
||||
var (
|
||||
_ = os.Getenv
|
||||
_ = exec.Command
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.svelte-14ysusk.svelte-14ysusk,.svelte-14ysusk.svelte-14ysusk:before,.svelte-14ysusk.svelte-14ysusk:after{box-sizing:border-box;margin:0;padding:0}.app.svelte-14ysusk.svelte-14ysusk{display:flex;width:100vw;height:100vh;overflow:hidden;background:#13131f;color:#e4e4ef;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:14px}.sidebar.svelte-14ysusk.svelte-14ysusk{width:260px;min-width:200px;height:100vh;display:flex;flex-direction:column;background:#1a1a28;border-right:1px solid #2a2a3c;flex-shrink:0;overflow:hidden}.sidebar-top.svelte-14ysusk.svelte-14ysusk{padding:16px 20px;display:flex;align-items:center;gap:10px;border-bottom:1px solid #2a2a3c;flex-shrink:0}.logo.svelte-14ysusk.svelte-14ysusk{font-size:20px;line-height:1}.app-name.svelte-14ysusk.svelte-14ysusk{font-size:16px;font-weight:600;color:#e4e4ef}.sidebar-nav.svelte-14ysusk.svelte-14ysusk{flex:1;overflow-y:auto;padding:12px 0}.nav-group.svelte-14ysusk.svelte-14ysusk{margin-bottom:16px}.nav-label.svelte-14ysusk.svelte-14ysusk{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:#666;padding:4px 20px;margin-bottom:4px}.nav-item.svelte-14ysusk.svelte-14ysusk{display:block;width:100%;padding:8px 20px;border:none;background:none;color:#ccc;font-size:13px;text-align:left;cursor:pointer;border-radius:0;font-family:inherit}.nav-item.svelte-14ysusk.svelte-14ysusk:hover{background:#223}.nav-item.selected.svelte-14ysusk.svelte-14ysusk{background:#2a2a4a;color:#fff;font-weight:500}.nav-empty.svelte-14ysusk.svelte-14ysusk{padding:8px 20px;color:#555;font-size:12px}.sidebar-bottom.svelte-14ysusk.svelte-14ysusk{padding:12px 20px;border-top:1px solid #2a2a3c;flex-shrink:0}.version.svelte-14ysusk.svelte-14ysusk{font-size:11px;color:#555}.main.svelte-14ysusk.svelte-14ysusk{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0;overflow:hidden;background:#13131f}.header.svelte-14ysusk.svelte-14ysusk{padding:12px 24px;border-bottom:1px solid #2a2a3c;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;min-height:48px}.crumb.svelte-14ysusk.svelte-14ysusk{font-size:14px;font-weight:500;color:#e4e4ef}.crumb.placeholder.svelte-14ysusk.svelte-14ysusk{color:#666}.search-hint.svelte-14ysusk.svelte-14ysusk{padding:6px 12px;background:#1e1e2e;border:1px solid #2a2a3c;border-radius:4px;color:#666;font-size:12px;cursor:text}.error-banner.svelte-14ysusk.svelte-14ysusk{background:#3a2222;color:#f88;padding:8px 24px;font-size:12px;border-bottom:1px solid #4a2222;flex-shrink:0}.content.svelte-14ysusk.svelte-14ysusk{flex:1;overflow-y:auto;padding:24px}.welcome.svelte-14ysusk h2.svelte-14ysusk{font-size:28px;font-weight:300;margin-bottom:12px;color:#8888a4}.welcome.svelte-14ysusk p.svelte-14ysusk{color:#666;font-size:13px;margin-bottom:4px}.error-text.svelte-14ysusk.svelte-14ysusk{color:#f88;margin-top:12px}.loading.svelte-14ysusk.svelte-14ysusk{color:#666}.node-view.svelte-14ysusk h2.svelte-14ysusk{font-size:24px;margin-bottom:16px}.node-meta.svelte-14ysusk.svelte-14ysusk{display:flex;gap:16px;color:#666;font-size:12px}
|
||||
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
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
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
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/index-COs6tJEl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ClxkTvdE.css">
|
||||
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -43,13 +43,12 @@ func main() {
|
||||
|
||||
// Init core services
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, abs)
|
||||
fileSvc := files.NewService(db, abs, nodeRepo)
|
||||
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
|
||||
actionSvc := actions.NewService(db)
|
||||
worklogSvc := worklog.NewService(db)
|
||||
searchSvc := search.NewService(db)
|
||||
plugins.NewManager(abs).Discover()
|
||||
_ = searchSvc
|
||||
|
||||
app := &App{
|
||||
db: db,
|
||||
@@ -58,6 +57,7 @@ func main() {
|
||||
notes: noteSvc,
|
||||
actions: actionSvc,
|
||||
worklog: worklogSvc,
|
||||
search: searchSvc,
|
||||
vault: abs,
|
||||
}
|
||||
|
||||
@@ -72,7 +72,10 @@ func main() {
|
||||
Assets: assets,
|
||||
},
|
||||
OnStartup: app.startup,
|
||||
Bind: []interface{}{app},
|
||||
DragAndDrop: &options.DragAndDrop{
|
||||
EnableFileDrop: true,
|
||||
},
|
||||
Bind: []interface{}{app},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
+1083
-246
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,346 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import FileIcon from './lib/FileIcon.svelte'
|
||||
import { formatFileSize, formatMimeType, getFileKind } from './lib/fileUtils.js'
|
||||
|
||||
export let item
|
||||
export let selected = false
|
||||
export let onDragStart
|
||||
export let onDragOver
|
||||
export let onDrop
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const kind = getFileKind(item)
|
||||
const isFolder = item.type === 'folder'
|
||||
let menuOpen = false
|
||||
let clickTimer = null
|
||||
|
||||
function handleClick(e) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
dispatch('toggleSelect', item.id)
|
||||
} else if (e.shiftKey) {
|
||||
dispatch('rangeSelect', item.id)
|
||||
} else {
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer)
|
||||
clickTimer = null
|
||||
// Double click
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
} else {
|
||||
clickTimer = setTimeout(() => {
|
||||
clickTimer = null
|
||||
// Single click: select
|
||||
if (selected) {
|
||||
// Already selected: navigate/preview
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
} else {
|
||||
dispatch('selectOne', item.id)
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenExternal() {
|
||||
dispatch('openExternal', item.fileId)
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
dispatch('delete', { id: item.id, type: item.type })
|
||||
}
|
||||
|
||||
function handleRename() {
|
||||
menuOpen = false
|
||||
dispatch('rename', { id: item.id, name: item.name })
|
||||
}
|
||||
|
||||
function handleDuplicate() {
|
||||
menuOpen = false
|
||||
dispatch('duplicate', item.id)
|
||||
}
|
||||
|
||||
function handleCut() {
|
||||
menuOpen = false
|
||||
dispatch('cut', item.id)
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
menuOpen = false
|
||||
dispatch('copy', item.id)
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen = !menuOpen
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuOpen = false
|
||||
}
|
||||
|
||||
function handleDragStart(e) {
|
||||
if (onDragStart) onDragStart(e, item.id)
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
if (onDragOver && isFolder) onDragOver(e, item.id)
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
if (onDrop && isFolder) onDrop(e, item.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeMenu}/>
|
||||
|
||||
<div class="file-row"
|
||||
class:file-row--selected={selected}
|
||||
class:file-row--dragover={false}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
on:click={handleClick}
|
||||
on:keydown={handleKeydown}
|
||||
on:dragstart={handleDragStart}
|
||||
on:dragover={handleDragOver}
|
||||
on:drop={handleDrop}
|
||||
aria-label={isFolder ? `Folder ${item.name}` : `File ${item.name}`}>
|
||||
<div class="file-row-icon">
|
||||
<FileIcon {kind} size={22}/>
|
||||
</div>
|
||||
<div class="file-row-body">
|
||||
<div class="file-row-name" title={item.name}>{item.name}</div>
|
||||
<div class="file-row-meta">
|
||||
{#if isFolder}
|
||||
<span>Folder</span>
|
||||
{:else}
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
{#if item.mime}
|
||||
<span class="meta-sep">·</span>
|
||||
<span>{formatMimeType(item.mime)}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-row-actions">
|
||||
{#if !isFolder}
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Preview" aria-label="Preview">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Open in external program" aria-label="Open externally">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Open folder" aria-label="Open folder">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="More actions" aria-label="More actions" aria-expanded={menuOpen}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="2"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<circle cx="12" cy="19" r="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Delete" aria-label="Delete">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if menuOpen}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
|
||||
<div class="menu" on:click|stopPropagation role="menu">
|
||||
<button class="menu-item" on:click={handleRename} role="menuitem">Rename</button>
|
||||
<button class="menu-item" on:click={handleDuplicate} role="menuitem">Duplicate</button>
|
||||
<button class="menu-item" on:click={handleCut} role="menuitem">Cut</button>
|
||||
<button class="menu-item" on:click={handleCopy} role="menuitem">Copy</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: default;
|
||||
transition: background 0.12s;
|
||||
min-height: 52px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
background: #1e1e30;
|
||||
}
|
||||
|
||||
.file-row--selected {
|
||||
background: #1e1e3a;
|
||||
outline: 1px solid #3a3a6c;
|
||||
}
|
||||
|
||||
.file-row--selected:hover {
|
||||
background: #252545;
|
||||
}
|
||||
|
||||
.file-row:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.file-row-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.file-row-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.file-row-name {
|
||||
font-size: 13px;
|
||||
color: #ddd;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.file-row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.meta-sep {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.file-row-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-row:hover .file-row-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #2a2a3c;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.action-btn-danger:hover {
|
||||
background: #3a2222;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.action-btn:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
margin-top: 4px;
|
||||
background: #1a1a28;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
min-width: 140px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #2a2a3c;
|
||||
}
|
||||
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
+36
-36
@@ -1,43 +1,43 @@
|
||||
// Wails v2 API wrapper — single frontend access point to Go backend
|
||||
// Wails v2 API wrapper — uses generated bindings from wailsjs/go/main/App.js
|
||||
import * as App from '../wailsjs/go/main/App.js'
|
||||
|
||||
function wailsCall(method, ...args) {
|
||||
if (window.go && window.go.main && window.go.main.App) {
|
||||
return window.go.main.App[method](...args)
|
||||
}
|
||||
return Promise.reject(new Error('Wails bindings not loaded'))
|
||||
}
|
||||
// Re-export all methods
|
||||
export const listSections = () => App.ListSections()
|
||||
export const listNodesBySection = (section) => App.ListNodesBySection(section)
|
||||
export const listChildren = (parentID) => App.ListChildren(parentID)
|
||||
export const getNodeDetail = (id) => App.GetNodeDetail(id)
|
||||
export const createNode = (parentID, type, title, section) => App.CreateNode(parentID, type, title, section)
|
||||
export const deleteNode = (id) => App.DeleteNode(id)
|
||||
|
||||
// Sections
|
||||
export const listSections = () => wailsCall('ListSections')
|
||||
export const listNotes = (nodeID) => App.ListNotes(nodeID)
|
||||
export const createNote = (parentID, title) => App.CreateNote(parentID, title)
|
||||
export const readNote = (noteID) => App.ReadNote(noteID)
|
||||
export const saveNote = (noteID, content) => App.SaveNote(noteID, content)
|
||||
|
||||
// Nodes
|
||||
export const listNodesBySection = (section) => wailsCall('ListNodesBySection', section)
|
||||
export const listChildren = (parentID) => wailsCall('ListChildren', parentID)
|
||||
export const getNodeDetail = (id) => wailsCall('GetNodeDetail', id)
|
||||
export const createNode = (parentID, type, title, section) =>
|
||||
wailsCall('CreateNode', parentID, type, title, section)
|
||||
export const deleteNode = (id) => wailsCall('DeleteNode', id)
|
||||
export const listFiles = (nodeID) => App.ListFiles(nodeID)
|
||||
export const listItems = (nodeID) => App.ListItems(nodeID)
|
||||
export const addPathCopy = (nodeID, sourcePath) => App.AddPathCopy(nodeID, sourcePath)
|
||||
export const addPathLink = (nodeID, sourcePath) => App.AddPathLink(nodeID, sourcePath)
|
||||
export const deleteFileOrFolder = (nodeID) => App.DeleteFileOrFolder(nodeID)
|
||||
export const previewImport = (sourcePath) => App.PreviewImport(sourcePath)
|
||||
export const pickFile = () => App.PickFile()
|
||||
export const pickFiles = () => App.PickFiles()
|
||||
export const pickDirectory = () => App.PickDirectory()
|
||||
export const openFile = (id) => App.OpenFile(id)
|
||||
export const readFileText = (id) => App.ReadFileText(id)
|
||||
export const getFileBase64 = (id) => App.GetFileBase64(id)
|
||||
export const createEmptyFile = (parentID, filename) => App.CreateEmptyFile(parentID, filename)
|
||||
export const duplicateNode = (nodeID) => App.DuplicateNode(nodeID)
|
||||
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
|
||||
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
|
||||
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
|
||||
|
||||
// Notes
|
||||
export const listNotes = (nodeID) => wailsCall('ListNotes', nodeID)
|
||||
export const createNote = (parentID, title) => wailsCall('CreateNote', parentID, title)
|
||||
export const readNote = (noteID) => wailsCall('ReadNote', noteID)
|
||||
export const saveNote = (noteID, content) => wailsCall('SaveNote', noteID, content)
|
||||
export const listActions = (nodeID) => App.ListActions(nodeID)
|
||||
export const runAction = (id) => App.RunAction(id)
|
||||
|
||||
// Files
|
||||
export const listFiles = (nodeID) => wailsCall('ListFiles', nodeID)
|
||||
export const listWorklog = (nodeID) => App.ListWorklog(nodeID)
|
||||
export const createWorklog = (nodeID, summary, minutes) => App.CreateWorklog(nodeID, summary, minutes)
|
||||
|
||||
// Actions
|
||||
export const listActions = (nodeID) => wailsCall('ListActions', nodeID)
|
||||
export const runAction = (id) => wailsCall('RunAction', id)
|
||||
export const search = (query) => App.Search(query)
|
||||
|
||||
// Worklog
|
||||
export const listWorklog = (nodeID) => wailsCall('ListWorklog', nodeID)
|
||||
export const createWorklog = (nodeID, summary, minutes) =>
|
||||
wailsCall('CreateWorklog', nodeID, summary, minutes)
|
||||
|
||||
// Search
|
||||
export const search = (query) => wailsCall('Search', query)
|
||||
|
||||
// System
|
||||
export const verstakVersion = () => wailsCall('VerstakVersion')
|
||||
export const verstakVersion = () => App.VerstakVersion()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<script>
|
||||
export let title = 'Подтверждение'
|
||||
export let message = ''
|
||||
export let confirmText = 'Удалить'
|
||||
export let cancelText = 'Отмена'
|
||||
export let danger = false
|
||||
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<div class="overlay" on:click|self={() => dispatch('cancel')} role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div class="modal">
|
||||
<h3>{title}</h3>
|
||||
<p class="message">{message}</p>
|
||||
<div class="actions">
|
||||
<button class="btn {danger ? 'btn-danger' : 'btn-primary'}" on:click={() => dispatch('confirm')}>{confirmText}</button>
|
||||
<button class="btn" on:click={() => dispatch('cancel')}>{cancelText}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #1a1a28;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
color: #e4e4ef;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #2a2a3c;
|
||||
background: #1a1a28;
|
||||
color: #ccc;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #222233;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
border-color: #6366f1;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let isFolder = false
|
||||
export let fileId = ''
|
||||
export let nodeId = ''
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function handleOpen() {
|
||||
if (isFolder) {
|
||||
dispatch('openFolder', nodeId)
|
||||
} else {
|
||||
dispatch('open', fileId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
dispatch('delete', nodeId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="file-actions">
|
||||
<button class="action-btn" on:click={handleOpen} title={isFolder ? 'Open folder' : 'Open file'} aria-label={isFolder ? 'Open folder' : 'Open file'}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
{#if isFolder}
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
{:else}
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn action-btn-danger" on:click={handleDelete} title="Delete" aria-label="Delete">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
:global(.file-row:hover) .file-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #2a2a3c;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.action-btn-danger:hover {
|
||||
background: #3a2222;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.action-btn:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let crumbs = []
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function navigateTo(index) {
|
||||
dispatch('navigate', index)
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="breadcrumbs">
|
||||
{#each crumbs as crumb, i}
|
||||
{#if i > 0}
|
||||
<span class="sep">/</span>
|
||||
{/if}
|
||||
{#if i === crumbs.length - 1}
|
||||
<span class="crumb crumb--current">{crumb.name}</span>
|
||||
{:else}
|
||||
<button class="crumb crumb--link" on:click={() => navigateTo(i)}>{crumb.name}</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.crumb--current {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.crumb--link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
transition: color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.crumb--link:hover {
|
||||
color: #ccc;
|
||||
background: #1e1e30;
|
||||
}
|
||||
|
||||
.crumb--link:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script>
|
||||
export let kind = 'generic'
|
||||
export let size = 20
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
{#if kind === 'folder'}
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
{:else if kind === 'image'}
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
{:else if kind === 'video'}
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<polyline points="10 9 16 12 10 15 10 9"/>
|
||||
{:else if kind === 'audio'}
|
||||
<path d="M9 18V5l12-2v13"/>
|
||||
<circle cx="6" cy="18" r="3"/>
|
||||
<circle cx="18" cy="16" r="3"/>
|
||||
{:else if kind === 'pdf'}
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
<line x1="8" y1="16" x2="16" y2="16"/>
|
||||
<line x1="8" y1="14" x2="12" y2="14"/>
|
||||
{:else if kind === 'document'}
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
{:else if kind === 'spreadsheet'}
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
<line x1="8" y1="16" x2="16" y2="16"/>
|
||||
<line x1="8" y1="14" x2="12" y2="14"/>
|
||||
<line x1="12" y1="12" x2="12" y2="18"/>
|
||||
{:else if kind === 'presentation'}
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="9" y1="12" x2="15" y2="12"/>
|
||||
<line x1="9" y1="15" x2="13" y2="15"/>
|
||||
<line x1="12" y1="15" x2="12" y2="18"/>
|
||||
{:else if kind === 'archive'}
|
||||
<path d="M21 8v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"/>
|
||||
<polyline points="7 3 12 8 17 3"/>
|
||||
<line x1="3" y1="8" x2="21" y2="8"/>
|
||||
<rect x="10" y="12" width="4" height="4" rx="1"/>
|
||||
{:else if kind === 'code'}
|
||||
<polyline points="16 18 22 12 16 6"/>
|
||||
<polyline points="8 6 2 12 8 18"/>
|
||||
{:else if kind === 'text'}
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
{:else}
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
{/if}
|
||||
</svg>
|
||||
@@ -0,0 +1,262 @@
|
||||
<script>
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte'
|
||||
import FileIcon from './FileIcon.svelte'
|
||||
import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js'
|
||||
|
||||
export let item
|
||||
export let content = '' // base64 data URI or text content
|
||||
export let loading = false
|
||||
export let error = ''
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const kind = getFileKind(item)
|
||||
$: showImage = isImageFile(item) && content && content.startsWith('data:')
|
||||
$: showText = isTextFile(item) || isMarkdownFile(item)
|
||||
$: showPdf = isPdfFile(item)
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
dispatch('close')
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenExternal() {
|
||||
dispatch('openExternal', item.fileId)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="overlay" on:click|self={() => dispatch('close')} role="dialog" aria-modal="true" aria-label={`Preview: ${item.name}`}>
|
||||
<div class="modal">
|
||||
<header class="preview-header">
|
||||
<div class="preview-title">
|
||||
<FileIcon {kind} size={18}/>
|
||||
<span class="preview-name" title={item.name}>{item.name}</span>
|
||||
</div>
|
||||
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
||||
<div class="preview-actions">
|
||||
<button class="action-btn" on:click={handleOpenExternal} title="Open in external program" aria-label="Open externally">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn action-btn-close" on:click={() => dispatch('close')} title="Close" aria-label="Close preview">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="preview-body">
|
||||
{#if loading}
|
||||
<div class="preview-status"><p>Loading preview...</p></div>
|
||||
{:else if error}
|
||||
<div class="preview-status">
|
||||
<p>{error}</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
</div>
|
||||
{:else if showImage && content}
|
||||
<div class="preview-image-container">
|
||||
<img src={content} alt={item.name} class="preview-image"/>
|
||||
</div>
|
||||
{:else if showText && content}
|
||||
<pre class="preview-text"><code>{content}</code></pre>
|
||||
{:else if showPdf}
|
||||
{#if content && content.startsWith('data:')}
|
||||
<div class="preview-pdf-container">
|
||||
<embed src={content} type="application/pdf" class="preview-pdf"/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="preview-status">
|
||||
<p>PDF preview is not available in this environment.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="preview-status">
|
||||
<p>Preview is not available for this file type.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #14141f;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 10px;
|
||||
width: 90vw;
|
||||
max-width: 900px;
|
||||
height: 85vh;
|
||||
max-height: 700px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #2a2a3c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #ddd;
|
||||
font-size: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #2a2a3c;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.action-btn:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.action-btn-close {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.action-btn-close:hover {
|
||||
background: #3a2222;
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.preview-image-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
min-height: 200px;
|
||||
background: #0e0e18;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: calc(85vh - 100px);
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #ccc;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview-pdf-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-pdf {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #2a2a3c;
|
||||
background: #1a1a28;
|
||||
color: #ccc;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: #222233;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes == null || bytes < 0) return '—'
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const val = bytes / Math.pow(1024, i)
|
||||
return (i === 0 ? val.toFixed(0) : val.toFixed(1)) + ' ' + units[i]
|
||||
}
|
||||
|
||||
const mimeLabels = {
|
||||
'image/jpeg': 'JPEG image',
|
||||
'image/png': 'PNG image',
|
||||
'image/gif': 'GIF image',
|
||||
'image/webp': 'WebP image',
|
||||
'image/svg+xml': 'SVG image',
|
||||
'image/bmp': 'BMP image',
|
||||
'image/tiff': 'TIFF image',
|
||||
'image/avif': 'AVIF image',
|
||||
'application/pdf': 'PDF document',
|
||||
'application/msword': 'Word document',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document',
|
||||
'application/vnd.ms-excel': 'Excel spreadsheet',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet',
|
||||
'application/vnd.ms-powerpoint': 'PowerPoint presentation',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint presentation',
|
||||
'application/zip': 'ZIP archive',
|
||||
'application/gzip': 'GZIP archive',
|
||||
'application/x-tar': 'TAR archive',
|
||||
'application/x-7z-compressed': '7z archive',
|
||||
'application/x-rar-compressed': 'RAR archive',
|
||||
'text/plain': 'Text file',
|
||||
'text/html': 'HTML file',
|
||||
'text/css': 'CSS file',
|
||||
'text/javascript': 'JavaScript file',
|
||||
'application/json': 'JSON file',
|
||||
'application/xml': 'XML file',
|
||||
'application/x-yaml': 'YAML file',
|
||||
'application/octet-stream': 'Binary file',
|
||||
'application/x-msdos-program': 'Executable',
|
||||
'inode/directory': 'Folder',
|
||||
}
|
||||
|
||||
export function formatMimeType(mime) {
|
||||
if (!mime) return 'Unknown'
|
||||
return mimeLabels[mime] || mime
|
||||
}
|
||||
|
||||
export function getFileKind(item) {
|
||||
if (item.type === 'folder') return 'folder'
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
if (mime.startsWith('image/')) return 'image'
|
||||
if (mime.startsWith('video/')) return 'video'
|
||||
if (mime.startsWith('audio/')) return 'audio'
|
||||
if (mime.startsWith('text/')) return 'text'
|
||||
if (mime.includes('pdf')) return 'pdf'
|
||||
if (mime.includes('word') || mime.includes('document')) return 'document'
|
||||
if (mime.includes('spreadsheet') || mime.includes('excel')) return 'spreadsheet'
|
||||
if (mime.includes('presentation') || mime.includes('powerpoint')) return 'presentation'
|
||||
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gzip') || mime.includes('rar') || mime.includes('7z') || mime.includes('compress')) return 'archive'
|
||||
if (mime.includes('json') || mime.includes('xml') || mime.includes('yaml') || mime.includes('javascript') || mime.includes('css') || mime.includes('html')) return 'code'
|
||||
const name = (item.name || '').toLowerCase()
|
||||
const ext = name.split('.').pop()
|
||||
const codeExts = ['js','ts','jsx','tsx','vue','svelte','py','rs','go','c','cpp','h','hpp','java','kt','swift','rb','php','pl','sh','bash','zsh','fish','yml','yaml','json','xml','toml','ini','cfg','conf','md','markdown','css','scss','less','sass','sql','graphql','proto','gradle','cmake','makefile','dockerfile','env','gitignore']
|
||||
if (codeExts.includes(ext)) return 'code'
|
||||
return 'generic'
|
||||
}
|
||||
|
||||
const imageMimes = ['image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff','image/avif','image/svg+xml']
|
||||
const textMimes = ['text/plain','text/html','text/css','text/javascript','application/json','application/xml','application/x-yaml','text/x-shellscript']
|
||||
const codeNames = ['txt','log','conf','ini','yaml','yml','json','xml','csv','sh','py','js','ts','css','html','md','markdown','cfg']
|
||||
|
||||
const imageExts = ['jpg','jpeg','png','gif','webp','bmp','tiff','tif','avif','svg']
|
||||
|
||||
export function canPreviewFile(item) {
|
||||
if (item.type === 'folder') return false
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
const name = (item.name || '').toLowerCase()
|
||||
const ext = name.split('.').pop()
|
||||
if (imageMimes.includes(mime) || imageExts.includes(ext)) return true
|
||||
if (mime.includes('pdf')) return true
|
||||
if (textMimes.includes(mime) || codeNames.includes(ext)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function isImageFile(item) {
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
const name = (item.name || '').toLowerCase()
|
||||
const ext = name.split('.').pop()
|
||||
return imageMimes.includes(mime) || imageExts.includes(ext)
|
||||
}
|
||||
|
||||
export function isTextFile(item) {
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
const name = (item.name || '').toLowerCase()
|
||||
const ext = name.split('.').pop()
|
||||
return textMimes.includes(mime) || (codeNames.includes(ext) && ext !== 'md' && ext !== 'markdown')
|
||||
}
|
||||
|
||||
export function isPdfFile(item) {
|
||||
return (item.mime || '').toLowerCase().includes('pdf')
|
||||
}
|
||||
|
||||
export function isMarkdownFile(item) {
|
||||
const name = (item.name || '').toLowerCase()
|
||||
return name.endsWith('.md') || name.endsWith('.markdown')
|
||||
}
|
||||
|
||||
export function needsBase64Preview(item) {
|
||||
return isImageFile(item) || isPdfFile(item)
|
||||
}
|
||||
|
||||
export function needsTextPreview(item) {
|
||||
return isTextFile(item) || isMarkdownFile(item)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// @ts-check
|
||||
// Wails v2 generated bindings — auto-generated by wails build
|
||||
// Manual version for go build -tags gui
|
||||
|
||||
export function ListSections() {
|
||||
return window['go']['main']['App']['ListSections']();
|
||||
}
|
||||
|
||||
export function ListNodesBySection(arg1) {
|
||||
return window['go']['main']['App']['ListNodesBySection'](arg1);
|
||||
}
|
||||
|
||||
export function ListChildren(arg1) {
|
||||
return window['go']['main']['App']['ListChildren'](arg1);
|
||||
}
|
||||
|
||||
export function GetNodeDetail(arg1) {
|
||||
return window['go']['main']['App']['GetNodeDetail'](arg1);
|
||||
}
|
||||
|
||||
export function CreateNode(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['CreateNode'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function DeleteNode(arg1) {
|
||||
return window['go']['main']['App']['DeleteNode'](arg1);
|
||||
}
|
||||
|
||||
export function ListNotes(arg1) {
|
||||
return window['go']['main']['App']['ListNotes'](arg1);
|
||||
}
|
||||
|
||||
export function CreateNote(arg1, arg2) {
|
||||
return window['go']['main']['App']['CreateNote'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ReadNote(arg1) {
|
||||
return window['go']['main']['App']['ReadNote'](arg1);
|
||||
}
|
||||
|
||||
export function SaveNote(arg1, arg2) {
|
||||
return window['go']['main']['App']['SaveNote'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ListFiles(arg1) {
|
||||
return window['go']['main']['App']['ListFiles'](arg1);
|
||||
}
|
||||
|
||||
export function AddPathCopy(arg1, arg2) {
|
||||
return window['go']['main']['App']['AddPathCopy'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function AddPathLink(arg1, arg2) {
|
||||
return window['go']['main']['App']['AddPathLink'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DeleteFileOrFolder(arg1) {
|
||||
return window['go']['main']['App']['DeleteFileOrFolder'](arg1);
|
||||
}
|
||||
|
||||
export function PreviewImport(arg1) {
|
||||
return window['go']['main']['App']['PreviewImport'](arg1);
|
||||
}
|
||||
|
||||
export function ListActions(arg1) {
|
||||
return window['go']['main']['App']['ListActions'](arg1);
|
||||
}
|
||||
|
||||
export function RunAction(arg1) {
|
||||
return window['go']['main']['App']['RunAction'](arg1);
|
||||
}
|
||||
|
||||
export function ListWorklog(arg1) {
|
||||
return window['go']['main']['App']['ListWorklog'](arg1);
|
||||
}
|
||||
|
||||
export function CreateWorklog(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['CreateWorklog'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function Search(arg1) {
|
||||
return window['go']['main']['App']['Search'](arg1);
|
||||
}
|
||||
|
||||
export function PickFile() {
|
||||
return window['go']['main']['App']['PickFile']();
|
||||
}
|
||||
|
||||
export function PickFiles() {
|
||||
return window['go']['main']['App']['PickFiles']();
|
||||
}
|
||||
|
||||
export function PickDirectory() {
|
||||
return window['go']['main']['App']['PickDirectory']();
|
||||
}
|
||||
|
||||
export function OpenFile(arg1) {
|
||||
return window['go']['main']['App']['OpenFile'](arg1);
|
||||
}
|
||||
|
||||
export function OpenFolder(arg1) {
|
||||
return window['go']['main']['App']['OpenFolder'](arg1);
|
||||
}
|
||||
|
||||
export function VerstakVersion() {
|
||||
return window['go']['main']['App']['VerstakVersion']();
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 3001,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [svelte()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, "index.html"),
|
||||
},
|
||||
},
|
||||
},
|
||||
publicDir: "public",
|
||||
});
|
||||
|
||||
+288
-2
@@ -3,6 +3,7 @@ package files
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
@@ -32,15 +34,25 @@ type Record struct {
|
||||
Missing bool `json:"missing"`
|
||||
}
|
||||
|
||||
// ImportSummary describes a scanned directory before import.
|
||||
type ImportSummary struct {
|
||||
Files int `json:"files"`
|
||||
Folders int `json:"folders"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
IsDangerous bool `json:"isDangerous"`
|
||||
DangerReason string `json:"dangerReason,omitempty"`
|
||||
}
|
||||
|
||||
// Service provides file operations inside a vault.
|
||||
type Service struct {
|
||||
db *storage.DB
|
||||
vaultRoot string
|
||||
nodes *nodes.Repository
|
||||
}
|
||||
|
||||
// NewService creates a file service bound to a vault.
|
||||
func NewService(db *storage.DB, vaultRoot string) *Service {
|
||||
return &Service{db: db, vaultRoot: vaultRoot}
|
||||
func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository) *Service {
|
||||
return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo}
|
||||
}
|
||||
|
||||
// DB returns the underlying storage.
|
||||
@@ -166,6 +178,280 @@ func (s *Service) Open(id string) error {
|
||||
return openWithSystem(abs)
|
||||
}
|
||||
|
||||
// maxPreviewSize is the maximum file size (5 MB) for inline preview.
|
||||
const maxPreviewSize = 5 * 1024 * 1024
|
||||
|
||||
// ReadText reads a file's content as text, up to maxPreviewSize.
|
||||
func (s *Service) ReadText(id string) (string, error) {
|
||||
rec, err := s.Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rec.Size > maxPreviewSize {
|
||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// ReadBase64 reads a file and returns a data URI (base64-encoded).
|
||||
func (s *Service) ReadBase64(id string) (string, error) {
|
||||
rec, err := s.Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rec.Size > maxPreviewSize {
|
||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read: %w", err)
|
||||
}
|
||||
mime := rec.MIME
|
||||
if mime == "" {
|
||||
mime = "application/octet-stream"
|
||||
}
|
||||
return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(b)), nil
|
||||
}
|
||||
|
||||
// CreateEmptyFile creates a file node and an empty vault file.
|
||||
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
|
||||
filename = s.uniqueTitle(parentID, filename)
|
||||
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
dest := filepath.Join(dir, filename)
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create file: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
|
||||
return node, err
|
||||
}
|
||||
|
||||
// Duplicate creates a copy of a node and its file record under the same parent.
|
||||
func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
||||
original, err := s.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentID := ""
|
||||
if original.ParentID != nil {
|
||||
parentID = *original.ParentID
|
||||
}
|
||||
newName := s.uniqueTitle(parentID, original.Title)
|
||||
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if original.Type == nodes.TypeFile {
|
||||
records, _ := s.ListByNode(original.ID)
|
||||
if len(records) > 0 {
|
||||
src := &records[0]
|
||||
if src.StorageMode == "vault" {
|
||||
srcPath := filepath.Join(s.vaultRoot, src.Path)
|
||||
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
|
||||
os.MkdirAll(dir, 0o750)
|
||||
dst := filepath.Join(dir, newName)
|
||||
hash, err := copyAndHash(srcPath, dst)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy file: %w", err)
|
||||
}
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dst)
|
||||
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// External file: create a new record pointing to the same absolute path.
|
||||
_, err = s.insertRecord(node.ID, newName, src.Path, "external", src.Size, src.SHA256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// AddPathCopy copies sourcePath (file or directory) into the vault under nodeID.
|
||||
func (s *Service) AddPathCopy(nodeID, sourcePath string) ([]nodes.Node, error) {
|
||||
return s.importPath(nodeID, sourcePath, true)
|
||||
}
|
||||
|
||||
// AddPathLink links sourcePath (file or directory) without copying into vault.
|
||||
func (s *Service) AddPathLink(nodeID, sourcePath string) ([]nodes.Node, error) {
|
||||
return s.importPath(nodeID, sourcePath, false)
|
||||
}
|
||||
|
||||
// PreviewImport scans sourcePath and returns a summary without importing.
|
||||
func (s *Service) PreviewImport(sourcePath string) (*ImportSummary, error) {
|
||||
info, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return &ImportSummary{Files: 1, TotalBytes: info.Size()}, nil
|
||||
}
|
||||
|
||||
var sum ImportSummary
|
||||
err = filepath.Walk(sourcePath, func(path string, fi os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if fi.IsDir() {
|
||||
sum.Folders++
|
||||
name := strings.ToLower(fi.Name())
|
||||
if name == ".git" || name == "node_modules" || name == ".cache" {
|
||||
sum.IsDangerous = true
|
||||
sum.DangerReason = fmt.Sprintf("содержит %s", fi.Name())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
sum.Files++
|
||||
sum.TotalBytes += fi.Size()
|
||||
return nil
|
||||
})
|
||||
if sum.Files > 1000 && !sum.IsDangerous {
|
||||
sum.IsDangerous = true
|
||||
sum.DangerReason = "более 1000 файлов"
|
||||
}
|
||||
if sum.TotalBytes > 1<<30 && !sum.IsDangerous {
|
||||
sum.IsDangerous = true
|
||||
sum.DangerReason = "более 1 GB"
|
||||
}
|
||||
return &sum, err
|
||||
}
|
||||
|
||||
// DeleteNodeAndChildren soft-deletes a node and all descendants,
|
||||
// moving vault files to trash.
|
||||
func (s *Service) DeleteNodeAndChildren(nodeID string) error {
|
||||
children, _ := s.nodes.ListChildren(nodeID, false)
|
||||
for i := range children {
|
||||
if err := s.DeleteNodeAndChildren(children[i].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_ = s.deleteFileRecords(nodeID)
|
||||
return s.nodes.SoftDelete(nodeID)
|
||||
}
|
||||
|
||||
func (s *Service) deleteFileRecords(nodeID string) error {
|
||||
records, err := s.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range records {
|
||||
_ = s.DeleteToTrash(r.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) importPath(parentID, sourcePath string, copyMode bool) ([]nodes.Node, error) {
|
||||
info, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
title := s.uniqueTitle(parentID, filepath.Base(sourcePath))
|
||||
node, err := s.nodes.Create(parentID, nodes.TypeFile, title, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if copyMode {
|
||||
_, err = s.CopyIntoVault(node.ID, sourcePath, node.Slug)
|
||||
} else {
|
||||
_, err = s.AddExternal(node.ID, sourcePath)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []nodes.Node{*node}, nil
|
||||
}
|
||||
return s.importDir(parentID, sourcePath, info.Name(), copyMode)
|
||||
}
|
||||
|
||||
func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool) ([]nodes.Node, error) {
|
||||
dirName = s.uniqueTitle(parentID, dirName)
|
||||
folderNode, err := s.nodes.Create(parentID, nodes.TypeFolder, dirName, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var all []nodes.Node
|
||||
all = append(all, *folderNode)
|
||||
|
||||
for _, entry := range entries {
|
||||
childPath := filepath.Join(sourcePath, entry.Name())
|
||||
if entry.IsDir() {
|
||||
children, err := s.importDir(folderNode.ID, childPath, entry.Name(), copyMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, children...)
|
||||
} else {
|
||||
childNode, err := s.nodes.Create(folderNode.ID, nodes.TypeFile, entry.Name(), "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if copyMode {
|
||||
_, err = s.CopyIntoVault(childNode.ID, childPath, childNode.Slug)
|
||||
} else {
|
||||
_, err = s.AddExternal(childNode.ID, childPath)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, *childNode)
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (s *Service) uniqueTitle(parentID, desired string) string {
|
||||
children, _ := s.nodes.ListChildren(parentID, false)
|
||||
used := make(map[string]bool, len(children))
|
||||
for i := range children {
|
||||
used[children[i].Title] = true
|
||||
}
|
||||
if !used[desired] {
|
||||
return desired
|
||||
}
|
||||
for n := 2; ; n++ {
|
||||
c := fmt.Sprintf("%s (%d)", desired, n)
|
||||
if !used[c] {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- implementation details ---
|
||||
|
||||
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/storage"
|
||||
)
|
||||
|
||||
@@ -23,7 +24,7 @@ func TestAddExternal(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
// Run migration 002 manually since storage.Open already applied it.
|
||||
// We can verify the table exists by inserting.
|
||||
filesSvc := NewService(db, t.TempDir())
|
||||
filesSvc := NewService(db, t.TempDir(), nodes.NewRepository(db))
|
||||
|
||||
// Create a real temp file to register.
|
||||
tmpDir := t.TempDir()
|
||||
@@ -62,7 +63,7 @@ func TestAddExternal(t *testing.T) {
|
||||
func TestCopyIntoVault(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
svc := NewService(db, vaultRoot)
|
||||
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
|
||||
|
||||
// Source file.
|
||||
srcDir := t.TempDir()
|
||||
@@ -88,7 +89,7 @@ func TestCopyIntoVault(t *testing.T) {
|
||||
|
||||
func TestListByNode(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
svc := NewService(db, t.TempDir())
|
||||
svc := NewService(db, t.TempDir(), nodes.NewRepository(db))
|
||||
|
||||
os.WriteFile(filepath.Join(t.TempDir(), "a.txt"), []byte("a"), 0o640)
|
||||
f1 := filepath.Join(t.TempDir(), "a1.txt")
|
||||
@@ -111,7 +112,7 @@ func TestListByNode(t *testing.T) {
|
||||
func TestDeleteToTrash(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
svc := NewService(db, vaultRoot)
|
||||
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
|
||||
|
||||
src := filepath.Join(t.TempDir(), "important.pdf")
|
||||
os.WriteFile(src, []byte("important data"), 0o640)
|
||||
@@ -140,6 +141,170 @@ func TestDeleteToTrash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPathCopySingleFile(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
svc := NewService(db, vaultRoot, nodeRepo)
|
||||
|
||||
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
|
||||
src := filepath.Join(t.TempDir(), "doc.pdf")
|
||||
os.WriteFile(src, []byte("file content"), 0o640)
|
||||
|
||||
nodes, err := svc.AddPathCopy(parent.ID, src)
|
||||
if err != nil {
|
||||
t.Fatalf("AddPathCopy: %v", err)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("got %d nodes, want 1", len(nodes))
|
||||
}
|
||||
if nodes[0].Type != "file" {
|
||||
t.Errorf("type = %q", nodes[0].Type)
|
||||
}
|
||||
// Source intact.
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
t.Error("source should remain intact")
|
||||
}
|
||||
// File record created.
|
||||
records, _ := svc.ListByNode(nodes[0].ID)
|
||||
if len(records) != 1 {
|
||||
t.Errorf("file records = %d", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPathLinkSingleFile(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
svc := NewService(db, vaultRoot, nodeRepo)
|
||||
|
||||
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
|
||||
src := filepath.Join(t.TempDir(), "linked.pdf")
|
||||
os.WriteFile(src, []byte("linked"), 0o640)
|
||||
|
||||
nodes, err := svc.AddPathLink(parent.ID, src)
|
||||
if err != nil {
|
||||
t.Fatalf("AddPathLink: %v", err)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("got %d nodes, want 1", len(nodes))
|
||||
}
|
||||
// File record should have external storage mode.
|
||||
records, _ := svc.ListByNode(nodes[0].ID)
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("file records = %d", len(records))
|
||||
}
|
||||
if records[0].StorageMode != "external" {
|
||||
t.Errorf("storage mode = %q, want external", records[0].StorageMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPathCopyDirectory(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
svc := NewService(db, vaultRoot, nodeRepo)
|
||||
|
||||
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
|
||||
srcDir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
|
||||
os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0o640)
|
||||
os.WriteFile(filepath.Join(srcDir, "sub", "b.txt"), []byte("bb"), 0o640)
|
||||
|
||||
nodes, err := svc.AddPathCopy(parent.ID, srcDir)
|
||||
if err != nil {
|
||||
t.Fatalf("AddPathCopy dir: %v", err)
|
||||
}
|
||||
// Should create: folder node + file node + sub folder node + file node in sub.
|
||||
if len(nodes) < 3 {
|
||||
t.Errorf("expected 3+ nodes, got %d", len(nodes))
|
||||
}
|
||||
// Verify structure: root folder + children.
|
||||
var folders, files int
|
||||
for i := range nodes {
|
||||
if nodes[i].Type == "folder" {
|
||||
folders++
|
||||
} else {
|
||||
files++
|
||||
}
|
||||
}
|
||||
if folders < 1 {
|
||||
t.Error("expected at least 1 folder")
|
||||
}
|
||||
if files < 1 {
|
||||
t.Error("expected at least 1 file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteNodeAndChildren(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
svc := NewService(db, vaultRoot, nodeRepo)
|
||||
|
||||
parent, _ := nodeRepo.Create("", "case", "To Delete", "")
|
||||
child, _ := nodeRepo.Create(parent.ID, "file", "child.txt", "")
|
||||
// Add file record to child.
|
||||
src := filepath.Join(t.TempDir(), "child.txt")
|
||||
os.WriteFile(src, []byte("data"), 0o640)
|
||||
svc.CopyIntoVault(child.ID, src, child.Slug)
|
||||
|
||||
if err := svc.DeleteNodeAndChildren(parent.ID); err != nil {
|
||||
t.Fatalf("DeleteNodeAndChildren: %v", err)
|
||||
}
|
||||
// Parent should be soft-deleted.
|
||||
if _, err := nodeRepo.GetActive(parent.ID); err == nil {
|
||||
t.Error("parent should be deleted")
|
||||
}
|
||||
// Child should be soft-deleted.
|
||||
if _, err := nodeRepo.GetActive(child.ID); err == nil {
|
||||
t.Error("child should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameConflict(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
svc := NewService(db, vaultRoot, nodeRepo)
|
||||
|
||||
parent, _ := nodeRepo.Create("", "case", "Test", "")
|
||||
src := filepath.Join(t.TempDir(), "conflict.pdf")
|
||||
os.WriteFile(src, []byte("data"), 0o640)
|
||||
|
||||
// Import twice with same filename.
|
||||
n1, _ := svc.AddPathCopy(parent.ID, src)
|
||||
n2, _ := svc.AddPathCopy(parent.ID, src)
|
||||
if n1[0].Title == n2[0].Title {
|
||||
t.Error("expected unique name on conflict")
|
||||
}
|
||||
if n2[0].Title == "conflict.pdf" {
|
||||
t.Errorf("title unchanged = %q", n2[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewImportDir(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
|
||||
|
||||
srcDir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
|
||||
os.WriteFile(filepath.Join(srcDir, "f1.txt"), []byte("hello"), 0o640)
|
||||
os.WriteFile(filepath.Join(srcDir, "f2.txt"), []byte("world"), 0o640)
|
||||
|
||||
sum, err := svc.PreviewImport(srcDir)
|
||||
if err != nil {
|
||||
t.Fatalf("PreviewImport: %v", err)
|
||||
}
|
||||
if sum.Files != 2 {
|
||||
t.Errorf("files = %d, want 2", sum.Files)
|
||||
}
|
||||
if sum.Folders != 2 { // root + sub
|
||||
t.Errorf("folders = %d, want 2", sum.Folders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessMIME(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"a.md": "text/plain",
|
||||
|
||||
@@ -21,7 +21,7 @@ func setupService(t *testing.T) (*Service, *nodes.Repository, string) {
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, dir)
|
||||
fileSvc := files.NewService(db, dir, nodeRepo)
|
||||
svc := NewService(db, dir, nodeRepo, fileSvc)
|
||||
return svc, nodeRepo, dir
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ type Server struct {
|
||||
// NewServer creates a GUI server for the given vault.
|
||||
func NewServer(db *storage.DB, vaultRoot string) *Server {
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, vaultRoot)
|
||||
fileSvc := files.NewService(db, vaultRoot, nodeRepo)
|
||||
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
|
||||
actionSvc := actions.NewService(db)
|
||||
workSvc := worklog.NewService(db)
|
||||
|
||||
Reference in New Issue
Block a user