Author SHA1 Message Date
mirivlad a098cf721c add missing Wails bindings for ListActivityFeed, ListActivityByNode, CountActivityByNode 2026-06-01 22:00:58 +08:00
mirivlad 3672e3133b activity: global feed, per-case log, sidebar section, today UX
- migration 009: target_type, target_id, target_path columns
- new Event fields: TargetType, TargetID, TargetPath
- ListActivityFeed (paginated global), ListActivityByNode (per-case)
- all Record() callsites pass target info
- frontend: Активность sidebar section with chronological feed
- per-case Активность tab with real data (was placeholder)
- today events: clickable, target-type badges, event counts
2026-06-01 02:53:56 +08:00
mirivlad 5a1c4c6d7f simplify ListTodayView: remove fallback table queries, pure activity_events + ListTodayNodes
TodayView now uses only activity_events (source of truth) + ListTodayNodes (ensure changed cases appear). Removed direct queries to nodes (notes) and files tables — those will come from activity_events going forward.
2026-06-01 02:45:55 +08:00
mirivlad 08c9d5dbea gitignore: fix verstak-gui pattern to root-only, add .verstak/ 2026-06-01 02:16:25 +08:00
mirivlad c74fa3ad43 today dashboard: activity_events, ListTodayView with events timeline, frontend TodayDashboard separated from sidebar 2026-06-01 02:16:13 +08:00
mirivlad 69891e395c Today view and Inbox section: dynamic query, not stored sections
- ListTodayView() on backend: queries nodes created or updated today
  using local timezone day boundaries
- todayBoundaries() helper returns start/end of current day in RFC3339
- Section validation: Create() rejects today and inbox as node sections
- validSections moved from repository.go to types.go with IsValidSection
  and IsServiceSection helpers
- Frontend selectSection('today') calls ListTodayView instead of
  ListNodesBySection('today')
- FAB (create node) hidden in today and inbox sections
- CreateNode in app.go guarded against today/inbox sections
- types.go: today/inbox defined as service sections (sidebar only)
2026-06-01 01:39:02 +08:00
mirivlad a4ae22c445 File manager: selection, keyboard shortcuts, rename modal, security, localization
- Selection model: click=select, dblclick=open, Ctrl+click toggle,
  Shift+click range select, Ctrl+A, Esc to clear
- Keyboard shortcuts: Enter, Ctrl+Enter, F2, Backspace (navigate up),
  Delete/Backspace (delete with confirm)
- Rename modal replaces prompt() with validation via backend ValidateName
- Context menu: Open, Open External, Show in Folder, Rename, Duplicate,
  Copy, Cut, Delete — all with SVG icons and Russian labels
- Backend security: vaultPath/absPathSafe helpers prevent path traversal,
  validateName rejects .. / \ null bytes empty overlong names
- MoveNode auto-renames on name conflict (copy style)
- Duplicate uses (copy) (copy 2) suffix pattern
- Russian localization: all file type labels, preview messages, tooltips
- FilePreviewModal: fixed broken {/if} tag
2026-06-01 01:35:45 +08:00
mirivlad 2487d3bbaa Files tab: multi-selection, drag-and-drop, keyboard shortcuts, custom confirm modal, SVG icons 2026-06-01 01:16:51 +08:00
mirivlad 645d8878cc gui: complete Wails v2 vertical MVP — fixes, search, polished UI
Backend fixes:
- Wire search service in App struct, implement Search() bindings
- Fix OpenFile to use files.Service.Open() instead of stub
- Fix OpenFolder to open spaces/<slug>/ instead of vault root
- Remove unused imports and dead code in app.go

Frontend fixes:
- Add missing Svelte plugin to vite.config.js (blocking build error)
- Fix optional catch binding for compatibility
- Fix select dropdown rendering on Linux (appearance: none + custom arrow)
- Switch api/verstak.js to use generated Wails v2 bindings
- Include hand-written wailsjs bindings in repository
- Add build.sh to repository

Build:
  cd frontend && npm run build
  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
2026-05-31 23:48:38 +08:00
31 changed files with 4068 additions and 337 deletions
+5 -2
View File
@@ -22,8 +22,11 @@ go.work
frontend/dist/
frontend/node_modules/
frontend/bindings/
verstak-gui
verstak-cli
/verstak-gui
/verstak-cli
# Vault data
.verstak/
# VS Code
.vscode/
BIN
View File
Binary file not shown.
Executable
+4
View File
@@ -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
+459 -20
View File
@@ -5,18 +5,25 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"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
@@ -24,14 +31,21 @@ type App struct {
nodes *nodes.Repository
files *files.Service
notes *notes.Service
activity *activity.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 +86,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"`
@@ -95,6 +119,47 @@ type SearchResultDTO struct {
Type string `json:"type"`
}
type EventDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
EventType string `json:"eventType"`
TargetType string `json:"targetType"`
TargetID string `json:"targetId"`
TargetPath string `json:"targetPath"`
Title string `json:"title"`
DetailsJSON string `json:"detailsJson"`
CreatedAt string `json:"createdAt"`
}
type CaseActivityDTO struct {
Node NodeDTO `json:"node"`
Events []EventDTO `json:"events"`
}
type SummaryDTO struct {
ChangedCases int `json:"changedCases"`
Notes int `json:"notes"`
Files int `json:"files"`
Actions int `json:"actions"`
TimeEntries int `json:"timeEntries"`
}
type TodayGroupDTO struct {
NodeID string `json:"nodeId"`
NodeTitle string `json:"nodeTitle"`
NodeKind string `json:"nodeKind"`
Section string `json:"section"`
LastActivityAt string `json:"lastActivityAt"`
Events []EventDTO `json:"events"`
}
type TodayDashboardDTO struct {
Date string `json:"date"`
Summary SummaryDTO `json:"summary"`
Groups []TodayGroupDTO `json:"groups"`
Events []EventDTO `json:"events"`
}
// ============================================================
// Sections
// ============================================================
@@ -103,6 +168,7 @@ func (a *App) ListSections() []SectionDTO {
return []SectionDTO{
{ID: "today", Label: "Сегодня"},
{ID: "inbox", Label: "Неразобранное"},
{ID: "activity", Label: "Активность"},
{ID: "clients", Label: "Клиенты"},
{ID: "projects", Label: "Проекты"},
{ID: "recipes", Label: "Рецепты"},
@@ -123,6 +189,175 @@ func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
return toNodeDTOs(list), nil
}
// ListTodayView returns a dashboard of today's activity.
// For MVP this uses activity_events + root nodes changed today.
// Future: full Activity/Event Log system will be the single source of truth.
func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
// Collect events from activity_events, grouped by parent node.
aeByParent, err := a.activity.ListTodayEventsByParent()
if err != nil {
aeByParent = nil
}
// Root nodes that were created/updated today.
todayNodes, _ := a.nodes.ListTodayNodes()
type rawEvent struct {
NodeID string
EventType string
TargetType string
TargetID string
TargetPath string
Title string
CreatedAt string
}
type caseInfo struct {
Node nodes.Node
Events []rawEvent
}
caseMap := make(map[string]*caseInfo)
ensureCase := func(caseID string) *caseInfo {
if ci, ok := caseMap[caseID]; ok {
return ci
}
ci := &caseInfo{Events: nil}
if n, err := a.nodes.GetActive(caseID); err == nil {
ci.Node = *n
}
caseMap[caseID] = ci
return ci
}
// Merge activity_events.
for pid, events := range aeByParent {
ci := ensureCase(pid)
for _, e := range events {
ci.Events = append(ci.Events, rawEvent{
NodeID: e.NodeID,
EventType: e.EventType,
TargetType: e.TargetType,
TargetID: e.TargetID,
TargetPath: e.TargetPath,
Title: e.Title,
CreatedAt: e.CreatedAt,
})
}
}
// Ensure all today's root nodes are present (even without events).
for _, n := range todayNodes {
_ = ensureCase(n.ID)
if ci := caseMap[n.ID]; ci.Node.ID == "" {
ci.Node = n
}
}
var groups []TodayGroupDTO
var flatEvents []EventDTO
summary := SummaryDTO{}
for _, ci := range caseMap {
if ci.Node.ID == "" {
continue
}
summary.ChangedCases++
dtoEvents := make([]EventDTO, 0, len(ci.Events))
for _, re := range ci.Events {
dtoEvents = append(dtoEvents, EventDTO{
ID: ci.Node.ID + "/" + re.NodeID + "/" + re.CreatedAt,
NodeID: re.NodeID,
EventType: re.EventType,
TargetType: re.TargetType,
TargetID: re.TargetID,
TargetPath: re.TargetPath,
Title: re.Title,
CreatedAt: re.CreatedAt,
})
switch re.EventType {
case activity.TypeNoteCreated, activity.TypeNoteUpdated:
summary.Notes++
case activity.TypeFileAdded, activity.TypeFileDeleted, activity.TypeFileRenamed, activity.TypeFileCopied, activity.TypeFileMoved:
summary.Files++
}
}
last := ci.Node.UpdatedAt.Format(time.RFC3339)
for _, e := range dtoEvents {
if e.CreatedAt > last {
last = e.CreatedAt
}
}
groups = append(groups, TodayGroupDTO{
NodeID: ci.Node.ID,
NodeTitle: ci.Node.Title,
NodeKind: ci.Node.Type,
Section: ci.Node.Section,
LastActivityAt: last,
Events: dtoEvents,
})
flatEvents = append(flatEvents, dtoEvents...)
}
sort.Slice(groups, func(i, j int) bool {
return groups[i].LastActivityAt > groups[j].LastActivityAt
})
sort.Slice(flatEvents, func(i, j int) bool {
return flatEvents[i].CreatedAt > flatEvents[j].CreatedAt
})
return &TodayDashboardDTO{
Date: time.Now().Format("2006-01-02"),
Summary: summary,
Groups: groups,
Events: flatEvents,
}, nil
}
func toEventDTO(e activity.Event) EventDTO {
return EventDTO{
ID: e.ID,
NodeID: e.NodeID,
EventType: e.EventType,
TargetType: e.TargetType,
TargetID: e.TargetID,
TargetPath: e.TargetPath,
Title: e.Title,
DetailsJSON: e.DetailsJSON,
CreatedAt: e.CreatedAt,
}
}
func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
events, err := a.activity.ListRecent(limit, offset)
if err != nil {
return nil, err
}
result := make([]EventDTO, len(events))
for i, e := range events {
result[i] = toEventDTO(e)
}
return result, nil
}
func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error) {
events, err := a.activity.ListByNode(nodeID, limit, offset)
if err != nil {
return nil, err
}
result := make([]EventDTO, len(events))
for i, e := range events {
result[i] = toEventDTO(e)
}
return result, nil
}
func (a *App) CountActivityByNode(nodeID string) (int, error) {
return a.activity.CountByNode(nodeID)
}
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
list, err := a.nodes.ListChildren(parentID, false)
if err != nil {
@@ -141,10 +376,14 @@ func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
}
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) {
if section == "today" || section == "inbox" {
return nil, fmt.Errorf("cannot create node with section %q", section)
}
n, err := a.nodes.Create(parentID, nodeType, title, section)
if err != nil {
return nil, err
}
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
dto := toNodeDTO(n)
return &dto, nil
}
@@ -178,6 +417,7 @@ func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
if err != nil {
return nil, err
}
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
dto := toNodeDTO(node)
return &dto, nil
}
@@ -189,13 +429,25 @@ func (a *App) ReadNote(noteID string) (string, error) {
// SaveNote saves note content.
func (a *App) SaveNote(noteID, content string) error {
return a.notes.Save(noteID, content)
if err := a.notes.Save(noteID, content); err != nil {
return err
}
// Record note_updated event.
if n, err := a.nodes.GetActive(noteID); err == nil {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
}
return nil
}
// ============================================================
// 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 +455,183 @@ 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
}
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
}
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
}
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
}
return toNodeDTOs(nodes), nil
}
func (a *App) DeleteFileOrFolder(nodeID string) error {
n, err := a.nodes.GetActive(nodeID)
if err == nil {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
evType := activity.TypeFileDeleted
targetType := activity.TargetFile
if n.Type == nodes.TypeFolder {
evType = activity.TypeFolderDeleted
targetType = activity.TargetFolder
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
}
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
}
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
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
}
// Find parent for recording
n, err2 := a.nodes.GetActive(nodeID)
pid := ""
if err2 == nil && n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
dto := toNodeDTO(node)
return &dto, nil
}
func (a *App) RenameNode(nodeID, newTitle string) error {
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return err
}
oldTitle := n.Title
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
return err
}
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
evType := activity.TypeFileRenamed
targetType := activity.TargetFile
if n.Type == nodes.TypeFolder {
evType = activity.TypeFolderRenamed
targetType = activity.TargetFolder
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
return nil
}
func (a *App) ValidateName(name string) error {
return files.ValidateName(name)
}
func (a *App) MoveNode(nodeID, newParentID string) error {
// Check for name conflict at destination
destChildren, err := a.nodes.ListChildren(newParentID, false)
if err != nil {
return err
}
node, err := a.nodes.GetActive(nodeID)
if err != nil {
return err
}
for i := range destChildren {
if destChildren[i].Title == node.Title {
// Conflict: auto-rename
newName := a.files.UniqueTitleCopy(newParentID, node.Title)
if err := a.nodes.UpdateTitle(nodeID, newName); err != nil {
return err
}
break
}
}
if err := a.nodes.Move(nodeID, newParentID, 0); err != nil {
return err
}
pid := ""
if node.ParentID != nil {
pid = *node.ParentID
}
_ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
return nil
}
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
return a.files.PreviewImport(sourcePath)
}
// ============================================================
// Actions
// ============================================================
@@ -296,14 +709,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 +759,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 +819,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
+2 -2
View File
@@ -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-DtITCkHU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-5x3eoU2l.css">
</head>
<body>
<div id="app"></div>
+8 -2
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
@@ -43,21 +44,23 @@ 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)
activitySvc := activity.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
plugins.NewManager(abs).Discover()
_ = searchSvc
app := &App{
db: db,
nodes: nodeRepo,
files: fileSvc,
notes: noteSvc,
activity: activitySvc,
actions: actionSvc,
worklog: worklogSvc,
search: searchSvc,
vault: abs,
}
@@ -72,6 +75,9 @@ func main() {
Assets: assets,
},
OnStartup: app.startup,
DragAndDrop: &options.DragAndDrop{
EnableFileDrop: true,
},
Bind: []interface{}{app},
})
+1426 -229
View File
File diff suppressed because it is too large Load Diff
+392
View File
@@ -0,0 +1,392 @@
<script>
import { createEventDispatcher } from 'svelte'
import FileIcon from './lib/FileIcon.svelte'
import { formatFileSize, formatFileType, 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'
const fileType = formatFileType(item)
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 → выделить
dispatch('selectOne', item.id)
}, 250)
}
}
}
function handleOpenExternal() {
dispatch('openExternal', item.fileId)
}
function handleShowInFolder() {
dispatch('showInFolder', item.id)
}
function handleDelete() {
menuOpen = false
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 handleOpen() {
menuOpen = false
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
}
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)
}
function oncontextmenu(e) {
e.preventDefault()
menuOpen = true
}
</script>
<svelte:window on:click={closeMenu}/>
<div class="file-row"
class:file-row--selected={selected}
role="button"
tabindex="0"
draggable="true"
on:click={handleClick}
on:contextmenu={oncontextmenu}
on:dragstart={handleDragStart}
on:dragover={handleDragOver}
on:drop={handleDrop}
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${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">
<span>{fileType}</span>
{#if !isFolder}
<span class="meta-sep">·</span>
<span>{formatFileSize(item.size)}</span>
{/if}
</div>
</div>
<div class="file-row-actions">
{#if !isFolder}
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Предпросмотр" aria-label="Предпросмотр">
<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="Открыть во внешней программе" aria-label="Открыть внешне">
<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="Открыть папку" aria-label="Открыть папку">
<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="Ещё" aria-label="Ещё" 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="Удалить" aria-label="Удалить">
<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={handleOpen} role="menuitem">
<svg width="14" height="14" 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="menu-item" on:click={handleOpenExternal} role="menuitem">
<svg width="14" height="14" 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>
{#if isFolder}
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
<svg width="14" height="14" 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"/></svg>
Показать в проводнике
</button>
{/if}
<div class="menu-sep"></div>
<button class="menu-item" on:click={handleRename} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Переименовать
</button>
<button class="menu-item" on:click={handleDuplicate} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Дублировать
</button>
<button class="menu-item" on:click={handleCut} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
Вырезать
</button>
<button class="menu-item" on:click={handleCopy} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Копировать
</button>
<div class="menu-sep"></div>
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
<svg width="14" height="14" 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>
{/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;
position: relative;
}
.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: 8px;
padding: 4px;
z-index: 100;
min-width: 220px;
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
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;
color: #fff;
}
.menu-item-danger {
color: #ff6b6b;
}
.menu-item-danger:hover {
background: #3a2222;
}
.menu-item:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
.menu-sep {
height: 1px;
background: #2a2a3c;
margin: 4px 8px;
}
</style>
+38 -36
View File
@@ -1,43 +1,45 @@
// 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 listTodayView = () => App.ListTodayView()
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)
export const validateName = (name) => App.ValidateName(name)
// 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()
+101
View File
@@ -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>
+85
View File
@@ -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>
+69
View File
@@ -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>
+63
View File
@@ -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>
+262
View File
@@ -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="Открыть во внешней программе" aria-label="Открыть внешне">
<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>Загрузка...</p></div>
{:else if error}
<div class="preview-status">
<p>{error}</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</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 недоступен.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
</div>
{/if}
{:else}
<div class="preview-status">
<p>Предпросмотр недоступен для этого типа файлов.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</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>
+124
View File
@@ -0,0 +1,124 @@
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/png': 'Изображение PNG',
'image/gif': 'Изображение GIF',
'image/webp': 'Изображение WebP',
'image/svg+xml': 'Изображение SVG',
'image/bmp': 'Изображение BMP',
'image/tiff': 'Изображение TIFF',
'image/avif': 'Изображение AVIF',
'application/pdf': 'PDF документ',
'application/msword': 'Документ Word',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
'application/vnd.ms-excel': 'Таблица Excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
'application/zip': 'ZIP архив',
'application/gzip': 'GZIP архив',
'application/x-tar': 'TAR архив',
'application/x-7z-compressed': '7z архив',
'application/x-rar-compressed': 'RAR архив',
'text/plain': 'Текстовый файл',
'text/html': 'HTML файл',
'text/css': 'CSS файл',
'text/javascript': 'JavaScript файл',
'application/json': 'JSON файл',
'application/xml': 'XML файл',
'application/x-yaml': 'YAML файл',
'application/octet-stream': 'Бинарный файл',
'application/x-msdos-program': 'Исполняемый файл',
'inode/directory': 'Папка',
}
export function formatMimeType(mime) {
if (!mime) return 'Неизвестно'
return mimeLabels[mime] || mime
}
export function formatFileType(item) {
if (item.type === 'folder') return 'Папка'
const mime = (item.mime || '').toLowerCase()
if (mimeLabels[mime]) return mimeLabels[mime]
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
if (ext) return ext.toUpperCase()
return 'Файл'
}
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)
}
+127
View File
@@ -0,0 +1,127 @@
// @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 ListTodayView() {
return window['go']['main']['App']['ListTodayView']();
}
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']();
}
export function ListActivityFeed(arg1, arg2) {
return window['go']['main']['App']['ListActivityFeed'](arg1, arg2);
}
export function ListActivityByNode(arg1, arg2, arg3) {
return window['go']['main']['App']['ListActivityByNode'](arg1, arg2, arg3);
}
export function CountActivityByNode(arg1) {
return window['go']['main']['App']['CountActivityByNode'](arg1);
}
export function CreateEmptyFile(arg1, arg2) {
return window['go']['main']['App']['CreateEmptyFile'](arg1, arg2);
}
+8 -1
View File
@@ -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",
});
+182
View File
@@ -0,0 +1,182 @@
package activity
import (
"database/sql"
"time"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// Event types.
const (
TypeNoteCreated = "note_created"
TypeNoteUpdated = "note_updated"
TypeFileAdded = "file_added"
TypeFileDeleted = "file_deleted"
TypeFileRenamed = "file_renamed"
TypeFileCopied = "file_copied"
TypeFileMoved = "file_moved"
TypeFolderAdded = "folder_added"
TypeFolderDeleted = "folder_deleted"
TypeFolderRenamed = "folder_renamed"
TypeNodeCreated = "node_created"
TypeNodeUpdated = "node_updated"
TypeActionCreated = "action_created"
TypeActionDone = "action_done"
TypeWorklogAdded = "worklog_added"
)
// Target types.
const (
TargetNote = "note"
TargetFile = "file"
TargetFolder = "folder"
TargetAction = "action"
TargetNode = "node"
TargetWorklog = "worklog"
)
// Event represents an activity event.
type Event struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
EventType string `json:"event_type"`
TargetType string `json:"target_type,omitempty"`
TargetID string `json:"target_id,omitempty"`
TargetPath string `json:"target_path,omitempty"`
Title string `json:"title"`
DetailsJSON string `json:"details_json,omitempty"`
CreatedAt string `json:"created_at"`
}
// Service records and queries activity events.
type Service struct {
db *storage.DB
}
func NewService(db *storage.DB) *Service {
return &Service{db: db}
}
// Record inserts a new activity event.
func (s *Service) Record(nodeID, targetType, targetID, targetPath, eventType, title, detailsJSON string) error {
id := util.UUID7()
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
id, nodeID, eventType, targetType, targetID, targetPath, title, detailsJSON, now)
return err
}
// TodayBoundaries returns the current local day in UTC as RFC3339 strings.
func TodayBoundaries() (string, string) {
now := time.Now()
y, m, d := now.Date()
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
end := start.Add(24 * time.Hour)
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
}
// scanEvent scans a single event row.
func scanEvent(rows *sql.Rows) (Event, error) {
var e Event
err := rows.Scan(&e.ID, &e.NodeID, &e.EventType,
&e.TargetType, &e.TargetID, &e.TargetPath,
&e.Title, &e.DetailsJSON, &e.CreatedAt)
return e, err
}
// ListTodayEvents returns all activity events from today.
func (s *Service) ListTodayEvents() ([]Event, error) {
start, end := TodayBoundaries()
rows, err := s.db.Query(
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
title,COALESCE(metadata,'{}'),created_at
FROM activity_events
WHERE created_at >= ? AND created_at < ?
ORDER BY created_at DESC`, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// ListTodayEventsByParent returns today's events grouped by their NodeID.
func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) {
events, err := s.ListTodayEvents()
if err != nil {
return nil, err
}
grouped := make(map[string][]Event, 8)
for _, e := range events {
pid := e.NodeID
grouped[pid] = append(grouped[pid], e)
}
return grouped, nil
}
// ListRecent returns a paginated global activity feed.
func (s *Service) ListRecent(limit, offset int) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
title,COALESCE(metadata,'{}'),created_at
FROM activity_events
ORDER BY created_at DESC
LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// ListByNode returns paginated events for a specific node.
func (s *Service) ListByNode(nodeID string, limit, offset int) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
title,COALESCE(metadata,'{}'),created_at
FROM activity_events
WHERE node_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?`, nodeID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// CountByNode returns the total number of events for a node.
func (s *Service) CountByNode(nodeID string) (int, error) {
var count int
err := s.db.QueryRow(
`SELECT COUNT(*) FROM activity_events WHERE node_id = ?`, nodeID).Scan(&count)
return count, err
}
+396 -8
View File
@@ -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.
@@ -48,6 +60,59 @@ func (s *Service) DB() *storage.DB {
return s.db
}
// --- security helpers ---
// ValidateName is an exported wrapper for validateName.
func ValidateName(name string) error {
return validateName(name)
}
// validateName rejects filenames with path separators, relative components,
// and other dangerous patterns.
func validateName(name string) error {
if name == "" {
return fmt.Errorf("name is required")
}
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
return fmt.Errorf("name must not contain path separators")
}
if strings.Contains(name, "..") {
return fmt.Errorf("name must not contain '..'")
}
if strings.Contains(name, "\x00") {
return fmt.Errorf("name must not contain null bytes")
}
if len(name) > 255 {
return fmt.Errorf("name too long (max 255)")
}
return nil
}
// vaultPath resolves a relative vault path and checks it stays within jail.
func (s *Service) vaultPath(rel string) (string, error) {
abs := filepath.Join(s.vaultRoot, rel)
cleaned := filepath.Clean(abs)
if !strings.HasPrefix(cleaned, filepath.Clean(s.vaultRoot)) {
return "", fmt.Errorf("path escapes vault root")
}
return cleaned, nil
}
// absPathSafe resolves an absolute path and checks jail if it's under vault.
// For "external" mode records the path stored may be an absolute external path.
// This function only checks path safety — it does not enforce that external
// files must be inside the vault.
func (s *Service) absPathSafe(rec *Record) (string, error) {
if rec.StorageMode == "vault" {
return s.vaultPath(rec.Path)
}
abs, err := filepath.Abs(rec.Path)
if err != nil {
return "", fmt.Errorf("abs: %w", err)
}
return filepath.Clean(abs), nil
}
// --- public operations ---
// AddExternal registers an external file (absolute path) without copying.
@@ -72,6 +137,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
}
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
if _, err := s.vaultPath(filepath.Join("spaces", nodeSlug)); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
if err := os.MkdirAll(destDir, 0o750); err != nil {
return nil, fmt.Errorf("mkdir: %w", err)
}
@@ -93,6 +161,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
}
relPath, _ := filepath.Rel(s.vaultRoot, dest)
if _, err := s.vaultPath(relPath); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
}
@@ -137,12 +208,19 @@ func (s *Service) DeleteToTrash(id string) error {
return err
}
if rec.StorageMode == "vault" {
src := filepath.Join(s.vaultRoot, rec.Path)
src, err := s.vaultPath(rec.Path)
if err != nil {
return err
}
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
if err := os.MkdirAll(trashDir, 0o750); err != nil {
return err
}
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
// verify trash is inside vault
if _, err := s.vaultPath(filepath.Join(".verstak", "trash", rec.ID+"_"+rec.Filename)); err != nil {
return err
}
if err := os.Rename(src, dest); err != nil {
return fmt.Errorf("move to trash: %w", err)
}
@@ -157,15 +235,325 @@ func (s *Service) Open(id string) error {
if err != nil {
return err
}
var abs string
if rec.StorageMode == "vault" {
abs = filepath.Join(s.vaultRoot, rec.Path)
} else {
abs = rec.Path
abs, err := s.absPathSafe(rec)
if err != nil {
return err
}
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)
}
abs, err := s.absPathSafe(rec)
if err != nil {
return "", err
}
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)
}
abs, err := s.absPathSafe(rec)
if err != nil {
return "", err
}
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) {
if err := validateName(filename); err != nil {
return nil, fmt.Errorf("invalid filename: %w", err)
}
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)
// Verify dest is inside vault
if _, err := s.vaultPath(relPath); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
_, 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.copyTitle(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, err := s.vaultPath(src.Path)
if err != nil {
return nil, err
}
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)
if _, err := s.vaultPath(relPath); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
_, 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
}
}
}
// copyTitle generates a unique "Name (copy).ext" style name for duplicates.
// For files with extensions: "photo.jpg" → "photo (copy).jpg", "photo (copy 2).jpg"
// For folders: "Docs" → "Docs (copy)", "Docs (copy 2)"
func (s *Service) copyTitle(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
}
ext := filepath.Ext(desired)
base := strings.TrimSuffix(desired, ext)
copyName := base + " (copy)" + ext
if !used[copyName] {
return copyName
}
for n := 2; ; n++ {
candidate := fmt.Sprintf("%s (copy %d)%s", base, n, ext)
if !used[candidate] {
return candidate
}
}
}
// UniqueTitleCopy returns a copy-style unique name for use in conflict resolution.
func (s *Service) UniqueTitleCopy(parentID, desired string) string {
return s.copyTitle(parentID, desired)
}
// --- implementation details ---
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
+169 -4
View File
@@ -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",
+53 -8
View File
@@ -50,16 +50,10 @@ func now() string {
return time.Now().UTC().Format(time.RFC3339)
}
// --- CRUD ---
// Valid sections for root-level nodes.
var validSections = map[string]struct{}{
"clients": {}, "projects": {}, "recipes": {}, "documents": {}, "archive": {},
}
// Create inserts a root or child node.
// parentID may be empty for root-level nodes.
// For root nodes, section determines sidebar placement (may be empty → inbox).
// For root nodes, section determines sidebar placement (may be empty = inbox).
// section must be a valid section (clients, projects, etc.) or empty for inbox.
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
if !IsValidType(typ) {
return nil, fmt.Errorf("invalid node type: %s", typ)
@@ -67,6 +61,9 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
if title == "" {
return nil, errors.New("title is required")
}
if section != "" && !IsValidSection(section) {
return nil, fmt.Errorf("invalid section: %s", section)
}
n := &Node{
ID: util.UUID7(),
@@ -87,9 +84,22 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
if err != nil {
return nil, err
}
// Bump parent's updated_at so it appears in today view.
if parentID != "" {
_ = r.touch(parentID)
}
return n, nil
}
// touch updates a node's updated_at without changing other fields.
func (r *Repository) touch(id string) error {
t := now()
_, err := r.db.Exec(
`UPDATE nodes SET updated_at=?, revision=revision+1
WHERE id=? AND deleted_at IS NULL`, t, id)
return err
}
func (r *Repository) insertNode(n *Node) error {
var parent interface{}
if n.ParentID != nil {
@@ -182,6 +192,41 @@ func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, err
return scanNodes(rows)
}
// todayBoundaries returns RFC3339 start and end strings for the current day
// in UTC, so string comparison against UTC-stored DB timestamps is correct.
func todayBoundaries() (string, string) {
now := time.Now()
y, m, d := now.Date()
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
end := start.Add(24 * time.Hour)
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
}
// ListTodayNodes returns active root-level nodes created or updated today.
// This is a dynamic view, not a section — it shows the day's activity.
// Child nodes (notes, files, folders) are not listed directly; instead,
// their parent is bumped via touch() on creation.
func (r *Repository) ListTodayNodes() ([]Node, error) {
start, end := todayBoundaries()
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
created_at,updated_at,deleted_at,revision,device_id
FROM nodes
WHERE deleted_at IS NULL
AND parent_id IS NULL
AND (
(created_at >= ? AND created_at < ?)
OR
(updated_at >= ? AND updated_at < ?)
)
ORDER BY updated_at DESC, created_at DESC`
rows, err := r.db.Query(q, start, end, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
return scanNodes(rows)
}
// UpdateTitle changes title (slug is recomputed).
func (r *Repository) UpdateTitle(id, title string) error {
if title == "" {
+29
View File
@@ -35,12 +35,41 @@ var TypeSet = map[string]struct{}{
TypeLink: {},
}
// Valid sections for root-level nodes.
// today and inbox are service sections, not stored in nodes.section.
var validSections = map[string]struct{}{
"clients": {},
"projects": {},
"recipes": {},
"documents": {},
"archive": {},
}
// serviceSections are sidebar entries that are not stored as node sections.
var serviceSections = map[string]struct{}{
"today": {},
"inbox": {},
"activity": {},
}
// IsValidType checks whether a type string is recognized.
func IsValidType(t string) bool {
_, ok := TypeSet[t]
return ok
}
// IsValidSection returns true for sections that can be stored on a node.
func IsValidSection(s string) bool {
_, ok := validSections[s]
return ok
}
// IsServiceSection returns true for sidebar-only sections (today, inbox).
func IsServiceSection(s string) bool {
_, ok := serviceSections[s]
return ok
}
// Slugify converts a title into a filesystem-safe slug.
// Examples:
//
+1 -1
View File
@@ -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
}
@@ -0,0 +1,17 @@
package storage
// migration008 — activity_events table for the Сегодня (Today) dashboard.
const migration008 = `
CREATE TABLE IF NOT EXISTS activity_events (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
parent_id TEXT,
event_type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ae_created ON activity_events(created_at);
CREATE INDEX IF NOT EXISTS idx_ae_parent ON activity_events(parent_id);
`
@@ -0,0 +1,8 @@
package storage
// migration009 — add target_type, target_id, target_path to activity_events.
const migration009 = `
ALTER TABLE activity_events ADD COLUMN target_type TEXT;
ALTER TABLE activity_events ADD COLUMN target_id TEXT;
ALTER TABLE activity_events ADD COLUMN target_path TEXT;
`
+2 -1
View File
@@ -64,7 +64,8 @@ var migrationFiles = map[int]string{
5: migration005,
6: migration006,
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
// 8: migration008, etc.
8: migration008,
9: migration009,
}
func (db *DB) runInitialSchema() error {
+1 -1
View File
@@ -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)