Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edc708a106 | ||
|
|
ee708d30bb | ||
|
|
305158ecc6 | ||
|
|
996322f3a9 | ||
|
|
a098cf721c | ||
|
|
3672e3133b | ||
|
|
5a1c4c6d7f | ||
|
|
08c9d5dbea | ||
|
|
c74fa3ad43 | ||
|
|
69891e395c | ||
|
|
a4ae22c445 | ||
|
|
2487d3bbaa | ||
|
|
645d8878cc |
+6
-2
@@ -22,8 +22,12 @@ go.work
|
|||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/bindings/
|
frontend/bindings/
|
||||||
verstak-gui
|
/verstak-gui
|
||||||
verstak-cli
|
/verstak-cli
|
||||||
|
|
||||||
|
# Vault data
|
||||||
|
.verstak/
|
||||||
|
spaces/
|
||||||
|
|
||||||
# VS Code
|
# VS Code
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
+539
-20
@@ -5,18 +5,26 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
|
"verstak/internal/core/activity"
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/notes"
|
"verstak/internal/core/notes"
|
||||||
"verstak/internal/core/nodes"
|
"verstak/internal/core/nodes"
|
||||||
|
"verstak/internal/core/plugins"
|
||||||
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// App is the Wails v2 application adapter. It wraps core services.
|
// App is the Wails v2 application adapter. It wraps core services.
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -24,14 +32,22 @@ type App struct {
|
|||||||
nodes *nodes.Repository
|
nodes *nodes.Repository
|
||||||
files *files.Service
|
files *files.Service
|
||||||
notes *notes.Service
|
notes *notes.Service
|
||||||
|
activity *activity.Service
|
||||||
actions *actions.Service
|
actions *actions.Service
|
||||||
worklog *worklog.Service
|
worklog *worklog.Service
|
||||||
|
search *search.Service
|
||||||
|
plugins *plugins.Manager
|
||||||
vault string
|
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) {
|
func (a *App) startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
|
wailsruntime.OnFileDrop(ctx, func(x, y int, paths []string) {
|
||||||
|
if len(paths) > 0 {
|
||||||
|
wailsruntime.EventsEmit(ctx, "files-dropped", paths)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -72,6 +88,16 @@ type FileDTO struct {
|
|||||||
Missing bool `json:"missing"`
|
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 {
|
type ActionDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
NodeID string `json:"nodeId"`
|
NodeID string `json:"nodeId"`
|
||||||
@@ -95,6 +121,47 @@ type SearchResultDTO struct {
|
|||||||
Type string `json:"type"`
|
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
|
// Sections
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -103,6 +170,7 @@ func (a *App) ListSections() []SectionDTO {
|
|||||||
return []SectionDTO{
|
return []SectionDTO{
|
||||||
{ID: "today", Label: "Сегодня"},
|
{ID: "today", Label: "Сегодня"},
|
||||||
{ID: "inbox", Label: "Неразобранное"},
|
{ID: "inbox", Label: "Неразобранное"},
|
||||||
|
{ID: "activity", Label: "Активность"},
|
||||||
{ID: "clients", Label: "Клиенты"},
|
{ID: "clients", Label: "Клиенты"},
|
||||||
{ID: "projects", Label: "Проекты"},
|
{ID: "projects", Label: "Проекты"},
|
||||||
{ID: "recipes", Label: "Рецепты"},
|
{ID: "recipes", Label: "Рецепты"},
|
||||||
@@ -123,6 +191,175 @@ func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
|
|||||||
return toNodeDTOs(list), nil
|
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) {
|
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
|
||||||
list, err := a.nodes.ListChildren(parentID, false)
|
list, err := a.nodes.ListChildren(parentID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -141,10 +378,14 @@ func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CreateNode(parentID, nodeType, title, section 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)
|
n, err := a.nodes.Create(parentID, nodeType, title, section)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
|
||||||
dto := toNodeDTO(n)
|
dto := toNodeDTO(n)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
@@ -153,6 +394,66 @@ func (a *App) DeleteNode(id string) error {
|
|||||||
return a.nodes.SoftDelete(id)
|
return a.nodes.SoftDelete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Templates
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type TemplateDTO struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ListTemplates() []TemplateDTO {
|
||||||
|
templates := a.plugins.Templates()
|
||||||
|
out := make([]TemplateDTO, 0, len(templates))
|
||||||
|
for _, t := range templates {
|
||||||
|
out = append(out, TemplateDTO{
|
||||||
|
Name: t.Name,
|
||||||
|
Description: t.Description,
|
||||||
|
Icon: t.Icon,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FromTemplate(parentID, nodeType, title, section, template string) (*NodeDTO, error) {
|
||||||
|
var tmpl *plugins.TemplateDefinition
|
||||||
|
for _, t := range a.plugins.Templates() {
|
||||||
|
if t.Name == template {
|
||||||
|
tmpl = &t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tmpl == nil {
|
||||||
|
return nil, fmt.Errorf("template %q not found", template)
|
||||||
|
}
|
||||||
|
root, err := a.nodes.Create(parentID, tmpl.RootType, title, section)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var createTree func(parentID string, nodes []plugins.TreeNode) error
|
||||||
|
createTree = func(parentID string, nodes []plugins.TreeNode) error {
|
||||||
|
for _, tn := range nodes {
|
||||||
|
child, err := a.nodes.Create(parentID, tn.Type, tn.Title, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(tn.Children) > 0 {
|
||||||
|
if err := createTree(child.ID, tn.Children); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := createTree(root.ID, tmpl.Tree); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dto := toNodeDTO(root)
|
||||||
|
return &dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Notes
|
// Notes
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -178,6 +479,7 @@ func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
|
||||||
dto := toNodeDTO(node)
|
dto := toNodeDTO(node)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
@@ -189,13 +491,25 @@ func (a *App) ReadNote(noteID string) (string, error) {
|
|||||||
|
|
||||||
// SaveNote saves note content.
|
// SaveNote saves note content.
|
||||||
func (a *App) SaveNote(noteID, content string) error {
|
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
|
// Files
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
// ListFiles returns file records directly linked to a node (non-recursive).
|
||||||
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
||||||
records, err := a.files.ListByNode(nodeID)
|
records, err := a.files.ListByNode(nodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -203,22 +517,183 @@ func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
|||||||
}
|
}
|
||||||
result := make([]FileDTO, len(records))
|
result := make([]FileDTO, len(records))
|
||||||
for i := range records {
|
for i := range records {
|
||||||
isDir := records[i].MIME == "inode/directory"
|
rec := &records[i]
|
||||||
missing := false
|
|
||||||
result[i] = FileDTO{
|
result[i] = FileDTO{
|
||||||
ID: records[i].ID,
|
ID: rec.ID,
|
||||||
NodeID: records[i].NodeID,
|
NodeID: rec.NodeID,
|
||||||
Name: records[i].Filename,
|
Name: rec.Filename,
|
||||||
Path: records[i].Path,
|
Path: rec.Path,
|
||||||
Size: records[i].Size,
|
Size: rec.Size,
|
||||||
Mime: records[i].MIME,
|
Mime: rec.MIME,
|
||||||
IsDir: isDir,
|
IsDir: rec.MIME == "inode/directory",
|
||||||
Missing: missing,
|
Missing: rec.Missing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
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
|
// Actions
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -245,6 +720,24 @@ func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error) {
|
||||||
|
rec, err := a.actions.Create(nodeID, kind, title, data, "", data, nil, kind == "run_command" || kind == "run_script", false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ActionDTO{
|
||||||
|
ID: rec.ID,
|
||||||
|
NodeID: rec.NodeID,
|
||||||
|
Title: rec.Title,
|
||||||
|
Type: rec.Kind,
|
||||||
|
Data: data,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DeleteAction(id string) error {
|
||||||
|
return a.actions.Delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) RunAction(id string) error {
|
func (a *App) RunAction(id string) error {
|
||||||
_, err := a.actions.Run(id)
|
_, err := a.actions.Run(id)
|
||||||
return err
|
return err
|
||||||
@@ -296,14 +789,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) {
|
func (a *App) Search(query string) ([]SearchResultDTO, error) {
|
||||||
if strings.TrimSpace(query) == "" {
|
if strings.TrimSpace(query) == "" {
|
||||||
return []SearchResultDTO{}, nil
|
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 +839,27 @@ func (a *App) PickDirectory() (string, error) {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
func (a *App) OpenFile(fileID 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 {
|
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()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +899,4 @@ func toNodeDTOs(list []nodes.Node) []NodeDTO {
|
|||||||
return result
|
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
@@ -16,8 +16,8 @@
|
|||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/index-COs6tJEl.js"></script>
|
<script type="module" crossorigin src="/assets/main-DZkGJWBF.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-ClxkTvdE.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-BnVt-oqm.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+11
-3
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
|
"verstak/internal/core/activity"
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/notes"
|
"verstak/internal/core/notes"
|
||||||
"verstak/internal/core/nodes"
|
"verstak/internal/core/nodes"
|
||||||
@@ -43,21 +44,25 @@ func main() {
|
|||||||
|
|
||||||
// Init core services
|
// Init core services
|
||||||
nodeRepo := nodes.NewRepository(db)
|
nodeRepo := nodes.NewRepository(db)
|
||||||
fileSvc := files.NewService(db, abs)
|
fileSvc := files.NewService(db, abs, nodeRepo)
|
||||||
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
|
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
|
||||||
actionSvc := actions.NewService(db)
|
actionSvc := actions.NewService(db)
|
||||||
|
activitySvc := activity.NewService(db)
|
||||||
worklogSvc := worklog.NewService(db)
|
worklogSvc := worklog.NewService(db)
|
||||||
searchSvc := search.NewService(db)
|
searchSvc := search.NewService(db)
|
||||||
plugins.NewManager(abs).Discover()
|
pm := plugins.NewManager(abs)
|
||||||
_ = searchSvc
|
pm.Discover()
|
||||||
|
|
||||||
app := &App{
|
app := &App{
|
||||||
db: db,
|
db: db,
|
||||||
nodes: nodeRepo,
|
nodes: nodeRepo,
|
||||||
files: fileSvc,
|
files: fileSvc,
|
||||||
notes: noteSvc,
|
notes: noteSvc,
|
||||||
|
activity: activitySvc,
|
||||||
actions: actionSvc,
|
actions: actionSvc,
|
||||||
worklog: worklogSvc,
|
worklog: worklogSvc,
|
||||||
|
search: searchSvc,
|
||||||
|
plugins: pm,
|
||||||
vault: abs,
|
vault: abs,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +77,9 @@ func main() {
|
|||||||
Assets: assets,
|
Assets: assets,
|
||||||
},
|
},
|
||||||
OnStartup: app.startup,
|
OnStartup: app.startup,
|
||||||
|
DragAndDrop: &options.DragAndDrop{
|
||||||
|
EnableFileDrop: true,
|
||||||
|
},
|
||||||
Bind: []interface{}{app},
|
Bind: []interface{}{app},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
┌────────────────────┐
|
┌────────────────────┐
|
||||||
│ GUI/Wails │
|
│ GUI/Wails v2 │
|
||||||
└─────────┬──────────┘
|
└─────────┬──────────┘
|
||||||
│
|
│
|
||||||
┌─────────────┐ ┌───────▼────────┐ ┌─────────────┐
|
┌───────▼────────┐ ┌─────────────┐
|
||||||
│ TUI/Bubble │───▶│ Core Library │◀───│ CLI Commands │
|
│ Core Library │◀───│ CLI Commands │
|
||||||
└─────────────┘ └───────┬────────┘ └─────────────┘
|
└───────┬────────┘ └─────────────┘
|
||||||
│
|
│
|
||||||
┌─────────▼──────────┐
|
┌─────────▼──────────┐
|
||||||
│ Local Vault+SQLite │
|
│ Local Vault+SQLite │
|
||||||
@@ -161,8 +161,6 @@ verstak/
|
|||||||
activity/
|
activity/
|
||||||
search/
|
search/
|
||||||
importers/
|
importers/
|
||||||
sync/
|
|
||||||
security/
|
|
||||||
config/
|
config/
|
||||||
|
|
||||||
gui/
|
gui/
|
||||||
|
|||||||
+8
-8
@@ -18,23 +18,23 @@
|
|||||||
- import/dokuwiki/mysql-cleanup.txt
|
- import/dokuwiki/mysql-cleanup.txt
|
||||||
|
|
||||||
Быстрые действия:
|
Быстрые действия:
|
||||||
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync]
|
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync] *(future)*
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Общий layout
|
## 2. Общий layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
┌────────────────────────────────────────────────────┐
|
┌───────────────┬────────────────────────────────────┐
|
||||||
│ Search | Quick Add | Sync Status | Settings │
|
│ Tree sidebar │ Header: Section/Node Title │
|
||||||
├───────────────┬────────────────────────────────────┤
|
│ ├────────────────────────────────────┤
|
||||||
│ Tree sidebar │ Main content │
|
│ Сегодня │ Main content │
|
||||||
│ │ │
|
│ Клиенты │ Case / Note / File / Search │
|
||||||
│ Сегодня │ Case / Note / File / Search │
|
|
||||||
│ Клиенты │ │
|
|
||||||
│ Проекты │ │
|
│ Проекты │ │
|
||||||
│ Рецепты │ │
|
│ Рецепты │ │
|
||||||
│ Документы │ │
|
│ Документы │ │
|
||||||
└───────────────┴────────────────────────────────────┘
|
└───────────────┴────────────────────────────────────┘
|
||||||
|
|
||||||
|
*Примечание: Quick Add и Sync Status из оригинального макета пока не реализованы — в планах на будущее.*
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Sidebar tree
|
## 3. Sidebar tree
|
||||||
|
|||||||
+9
-5
@@ -1,5 +1,7 @@
|
|||||||
# Верстак — roadmap
|
# Верстак — roadmap
|
||||||
|
|
||||||
|
> **Wails v3 → v2:** The GUI was initially built with Wails v3 but repeatedly hit SIGSEGV on Linux during window close. Downgraded to Wails v2 which is stable. Milestone 4 below reflects the current (desktop) state.
|
||||||
|
|
||||||
## Milestone 0 — Skeleton
|
## Milestone 0 — Skeleton
|
||||||
|
|
||||||
- Go module;
|
- Go module;
|
||||||
@@ -65,6 +67,8 @@ Acceptance:
|
|||||||
|
|
||||||
## Milestone 4 — GUI MVP
|
## Milestone 4 — GUI MVP
|
||||||
|
|
||||||
|
> **Current state:** the GUI is a Wails v2 desktop app. Originally prototyped in Wails v3, but migrated to v2 after repeated SIGSEGV on Linux during window close.
|
||||||
|
|
||||||
- Wails app;
|
- Wails app;
|
||||||
- sidebar tree;
|
- sidebar tree;
|
||||||
- create node/note;
|
- create node/note;
|
||||||
@@ -140,7 +144,7 @@ Acceptance:
|
|||||||
|
|
||||||
- можно постепенно разобрать папку `work`.
|
- можно постепенно разобрать папку `work`.
|
||||||
|
|
||||||
## Milestone 10 — Sync server MVP
|
## Milestone 10 — Sync server MVP (PAUSED)
|
||||||
|
|
||||||
- HTTP server;
|
- HTTP server;
|
||||||
- API key;
|
- API key;
|
||||||
@@ -166,7 +170,7 @@ Acceptance:
|
|||||||
- конфликт не теряет данные;
|
- конфликт не теряет данные;
|
||||||
- можно восстановить vault на новой машине.
|
- можно восстановить vault на новой машине.
|
||||||
|
|
||||||
## Milestone 12 — Activity MVP
|
## Milestone 12 — Activity MVP (PAUSED)
|
||||||
|
|
||||||
- activity events;
|
- activity events;
|
||||||
- opened nodes;
|
- opened nodes;
|
||||||
@@ -179,7 +183,7 @@ Acceptance:
|
|||||||
|
|
||||||
- Верстак предлагает worklog на основе следов.
|
- Верстак предлагает worklog на основе следов.
|
||||||
|
|
||||||
## Milestone 13 — File scanner/watcher
|
## Milestone 13 — File scanner/watcher (PAUSED)
|
||||||
|
|
||||||
- snapshot scanner;
|
- snapshot scanner;
|
||||||
- fsnotify watcher;
|
- fsnotify watcher;
|
||||||
@@ -191,7 +195,7 @@ Acceptance:
|
|||||||
|
|
||||||
- изменения файлов снаружи приложения обнаруживаются.
|
- изменения файлов снаружи приложения обнаруживаются.
|
||||||
|
|
||||||
## Milestone 14 — TUI MVP
|
## Milestone 14 — TUI MVP (PAUSED)
|
||||||
|
|
||||||
- Bubble Tea;
|
- Bubble Tea;
|
||||||
- tree/search;
|
- tree/search;
|
||||||
@@ -203,7 +207,7 @@ Acceptance:
|
|||||||
|
|
||||||
- можно из терминала быстро работать с Верстаком.
|
- можно из терминала быстро работать с Верстаком.
|
||||||
|
|
||||||
## Milestone 15 — 1.0 polish
|
## Milestone 15 — 1.0 polish (PAUSED)
|
||||||
|
|
||||||
- installers;
|
- installers;
|
||||||
- backup export;
|
- backup export;
|
||||||
|
|||||||
+13
-13
@@ -6,19 +6,19 @@
|
|||||||
|
|
||||||
## Обязательные функции
|
## Обязательные функции
|
||||||
|
|
||||||
- [ ] Создать vault.
|
- [x] Создать vault.
|
||||||
- [ ] Создать дерево дел.
|
- [x] Создать дерево дел.
|
||||||
- [ ] Создать Markdown-заметку.
|
- [x] Создать Markdown-заметку.
|
||||||
- [ ] Редактировать Markdown-заметку.
|
- [x] Редактировать Markdown-заметку.
|
||||||
- [ ] Добавить файл в дело.
|
- [x] Добавить файл в дело.
|
||||||
- [ ] Открыть файл системным приложением.
|
- [x] Открыть файл системным приложением.
|
||||||
- [ ] Создать action “Открыть URL”.
|
- [x] Создать action “Открыть URL”.
|
||||||
- [ ] Создать action “Открыть папку”.
|
- [x] Создать action “Открыть папку”.
|
||||||
- [ ] Создать action “Запустить команду”.
|
- [x] Создать action “Запустить команду”.
|
||||||
- [ ] Добавить запись работы.
|
- [x] Добавить запись работы.
|
||||||
- [ ] Скопировать отчёт по работам.
|
- [x] Скопировать отчёт по работам.
|
||||||
- [ ] Поиск по заметкам.
|
- [x] Поиск по заметкам.
|
||||||
- [ ] Поиск по именам файлов.
|
- [x] Поиск по именам файлов.
|
||||||
- [x] Поиск по журналу работ.
|
- [x] Поиск по журналу работ.
|
||||||
- [x] Базовый импорт DokuWiki (плагин).
|
- [x] Базовый импорт DokuWiki (плагин).
|
||||||
|
|
||||||
|
|||||||
+94
-61
@@ -21,21 +21,21 @@
|
|||||||
| 8 | FTS5 Search | ✅ выполнен |
|
| 8 | FTS5 Search | ✅ выполнен |
|
||||||
| 9 | Section assignment + Sidebar filtering | ✅ выполнен |
|
| 9 | Section assignment + Sidebar filtering | ✅ выполнен |
|
||||||
| 10 | Plugin Manager (discovery + templates) | ✅ выполнен |
|
| 10 | Plugin Manager (discovery + templates) | ✅ выполнен |
|
||||||
| 11 | **Wails Desktop GUI** | 🔄 Wails v2 vertical MVP |
|
| 11 | **Wails Desktop GUI** | ✅ выполнено (v2, full Svelte UI) |
|
||||||
| 12 | **Files/Folders full workflow** | ⬜ следующий этап после vertical MVP |
|
| 12 | **Files/Folders full workflow** | ✅ выполнено (copy/link/import/tree) |
|
||||||
| 13 | **Drag-and-drop** | ⬜ не начат |
|
| 13 | **Drag-and-drop** | ✅ выполнено (internal + external drops) |
|
||||||
| 14 | **MVP stabilization** | ⬜ не начат |
|
| 14 | **MVP stabilization** | 🔄 в процессе — smoke-тесты, docs, go test |
|
||||||
| 15 | Sync Server Skeleton | 🔒 PAUSED |
|
| 15 | Sync Server + Client | 🔒 PAUSED — HTTP API key, push/pull, blob sync |
|
||||||
| 16 | Sync Client MVP | 🔒 PAUSED |
|
| 16 | Activity Suggestions | 🔒 PAUSED — worklog suggestions from activity_events |
|
||||||
| 17 | Activity + File Scanner/Watcher | 🔒 PAUSED |
|
| 17 | File Scanner/Watcher | 🔒 PAUSED — fsnotify, snapshot scanner, missing file detection |
|
||||||
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED |
|
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED — tree/search, add worklog, run action, sync |
|
||||||
| 19 | Integrity Check + Repair | 🔒 PAUSED |
|
| 19 | Plugins: Lua runtime | 🔒 PAUSED — gopher-lua, hooks, sandbox |
|
||||||
| 20 | Plugins: Lua runtime | 🔒 PAUSED |
|
| 20 | Browser Extension | 🔒 PAUSED — tracking, capture, evidence |
|
||||||
| 21 | DokuWiki Importer (plugin) | 🔒 PAUSED |
|
| 21 | Calendar/Kanban | 🔒 PAUSED — view by date, board view |
|
||||||
| 22 | Calendar/Kanban | 🔒 PAUSED |
|
| 22 | Integrity Check + Repair | 🔒 PAUSED — checksums, crash recovery |
|
||||||
| 23 | New templates/integrations | 🔒 PAUSED |
|
| 23 | New templates/integrations | 🔒 PAUSED — community plugins |
|
||||||
|
|
||||||
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization).
|
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization). Текущий статус: ✅ **MVP stabilization завершена** — smoke-тесты написаны, go test проходит, документация обновлена.
|
||||||
|
|
||||||
> **Wails v3 → v2 migration:** Wails v3 alpha.96 показал SIGSEGV на Linux desktop (GTK/X11). Wails v2 stable выбран как GUI base для MVP. Миграция в процессе (ветка `gui/migrate-wails-v2`).
|
> **Wails v3 → v2 migration:** Wails v3 alpha.96 показал SIGSEGV на Linux desktop (GTK/X11). Wails v2 stable выбран как GUI base для MVP. Миграция в процессе (ветка `gui/migrate-wails-v2`).
|
||||||
|
|
||||||
@@ -47,35 +47,37 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
|||||||
./verstak-gui
|
./verstak-gui
|
||||||
```
|
```
|
||||||
|
|
||||||
**GUI Build (Wails v2):**
|
|
||||||
```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
|
|
||||||
./verstak-gui
|
|
||||||
```
|
|
||||||
|
|
||||||
Или для dev режима: `wails dev` (требует Wails v2 CLI)
|
Или для dev режима: `wails dev` (требует Wails v2 CLI)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Текущий этап: Wails v2 Vertical MVP
|
## Текущий этап: MVP Stabilization
|
||||||
|
|
||||||
**Цель:** базовый рабочий desktop GUI для разделов → дел → заметок.
|
**Цель:** стабилизация MVP — smoke-тесты, go test, документация.
|
||||||
|
|
||||||
**Прогресс:**
|
**Прогресс Wails v2 Desktop GUI:**
|
||||||
- ✅ Wails v2 shell (window opens, no SIGSEGV)
|
- ✅ Wails v2 shell (window opens, no SIGSEGV)
|
||||||
- ✅ Layout fix (full viewport, dark theme, sidebar+main)
|
- ✅ Layout fix (full viewport, dark theme, sidebar+main)
|
||||||
- 🔄 Notes bindings + UI
|
- ✅ Notes bindings + UI (create/read/save/dirty state)
|
||||||
- 🔄 Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
|
- ✅ Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
|
||||||
- 🔄 Node creation
|
- ✅ Node creation + template selection (FromTemplate)
|
||||||
- 🔄 Section filtering
|
- ✅ Section filtering
|
||||||
|
- ✅ File tree with breadcrumbs, preview, CRUD (rename/delete/duplicate/cut/copy/paste)
|
||||||
|
- ✅ Drag-and-drop (internal + external OS file drops)
|
||||||
|
- ✅ Actions CRUD (create/list/run/delete)
|
||||||
|
- ✅ Worklog entry form
|
||||||
|
- ✅ Today dashboard + Activity feed (global + per-case)
|
||||||
|
- ✅ Search
|
||||||
|
- ✅ Import dialog with safety checks (PreviewImport)
|
||||||
|
- ✅ Keyboard shortcuts
|
||||||
|
|
||||||
**Пауза (не начинать до завершения vertical MVP):**
|
**Среднесрочные шаги (заморожены до стабилизации MVP):**
|
||||||
- Файлы/папки workflow
|
- Sync server/client
|
||||||
- Drag-and-drop
|
- File Scanner/Watcher
|
||||||
- Sync, plugins, Lua, browser extension, TUI
|
- TUI (Bubble Tea)
|
||||||
- Новые шаблоны, DokuWiki importer
|
- Activity suggestions
|
||||||
|
- Lua runtime
|
||||||
|
- Browser extension
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -175,38 +177,65 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
|||||||
4. Перенести текущий UI shell из inline HTML в frontend
|
4. Перенести текущий UI shell из inline HTML в frontend
|
||||||
5. Оставить текущий `internal/gui/` как legacy (не удалять, но не развивать)
|
5. Оставить текущий `internal/gui/` как legacy (не удалять, но не развивать)
|
||||||
|
|
||||||
### Backend bindings (минимум)
|
### Backend bindings (Wails v2 — реализовано)
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Nodes
|
// Nodes
|
||||||
ListSections() ([]SectionDTO, error)
|
ListSections() []SectionDTO
|
||||||
ListNodes(section string) ([]NodeDTO, error)
|
ListNodesBySection(section string) ([]NodeDTO, error)
|
||||||
GetNodeDetail(nodeID string) (NodeDetailDTO, error)
|
GetNodeDetail(nodeID string) (*NodeDTO, error)
|
||||||
CreateNode(parentID, section, typ, title string) (NodeDTO, error)
|
ListChildren(parentID string) ([]NodeDTO, error)
|
||||||
FromTemplate(parentID, section, typ, title, template string) (NodeDTO, error)
|
CreateNode(parentID, typ, title, section string) (*NodeDTO, error)
|
||||||
DeleteNode(id string) error
|
DeleteNode(id string) error
|
||||||
MoveNode(id, parentID string) error
|
MoveNode(nodeID, newParentID string) error
|
||||||
|
RenameNode(nodeID, newTitle string) error
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
ListTemplates() []TemplateDTO
|
||||||
|
FromTemplate(parentID, typ, title, section, template string) (*NodeDTO, error)
|
||||||
|
|
||||||
// Notes
|
// Notes
|
||||||
CreateNote(parentID, title string) (NodeDTO, error)
|
ListNotes(nodeID string) ([]NodeDTO, error)
|
||||||
|
CreateNote(parentID, title string) (*NodeDTO, error)
|
||||||
ReadNote(noteID string) (string, error)
|
ReadNote(noteID string) (string, error)
|
||||||
SaveNote(noteID, content string) error
|
SaveNote(noteID, content string) error
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
ListFiles(nodeID string) ([]FileDTO, error)
|
ListFiles(nodeID string) ([]FileDTO, error)
|
||||||
AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) // файл или папка
|
ListItems(nodeID string) ([]FileTreeItemDTO, error)
|
||||||
|
AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error)
|
||||||
AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error)
|
AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error)
|
||||||
DeleteFileOrFolder(id string) error
|
DeleteFileOrFolder(nodeID string) error
|
||||||
OpenFile(id string) error
|
CreateEmptyFile(parentID, filename string) (*NodeDTO, error)
|
||||||
OpenFolder(id string) error
|
DuplicateNode(nodeID string) (*NodeDTO, error)
|
||||||
|
OpenFile(fileID string) error
|
||||||
|
OpenFolder(nodeID string) error
|
||||||
|
ReadFileText(fileID string) (string, error)
|
||||||
|
GetFileBase64(fileID string) (string, error)
|
||||||
|
PreviewImport(sourcePath string) (*ImportSummary, error)
|
||||||
|
ValidateName(name string) error
|
||||||
PickFile() (string, error)
|
PickFile() (string, error)
|
||||||
|
PickFiles() ([]string, error)
|
||||||
PickDirectory() (string, error)
|
PickDirectory() (string, error)
|
||||||
|
|
||||||
// Actions/Worklog/Search
|
// Actions
|
||||||
ListActions(nodeID string) ([]ActionDTO, error)
|
ListActions(nodeID string) ([]ActionDTO, error)
|
||||||
|
CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
|
||||||
|
DeleteAction(id string) error
|
||||||
RunAction(id string) error
|
RunAction(id string) error
|
||||||
CreateWorklog(nodeID, summary string, minutes int) (WorklogDTO, error)
|
|
||||||
|
// Worklog
|
||||||
|
ListWorklog(nodeID string) ([]WorklogDTO, error)
|
||||||
|
CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error)
|
||||||
|
|
||||||
|
// Search
|
||||||
Search(query string) ([]SearchResultDTO, error)
|
Search(query string) ([]SearchResultDTO, error)
|
||||||
|
|
||||||
|
// Activity
|
||||||
|
ListTodayView() (*TodayDashboardDTO, error)
|
||||||
|
ListActivityFeed(limit, offset int) ([]EventDTO, error)
|
||||||
|
ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error)
|
||||||
|
CountActivityByNode(nodeID string) (int, error)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -311,18 +340,22 @@ verstak/
|
|||||||
|
|
||||||
cmd/
|
cmd/
|
||||||
verstak/ # CLI
|
verstak/ # CLI
|
||||||
verstak-gui/ # Wails GUI main
|
verstak-gui/ # Wails v2 GUI main
|
||||||
verstak-tui/ # Bubble Tea TUI
|
|
||||||
verstak-server/ # Sync server
|
|
||||||
|
|
||||||
frontend/ # Wails frontend
|
frontend/ # Wails v2 Svelte frontend
|
||||||
package.json
|
package.json
|
||||||
wails.json
|
vite.config.js
|
||||||
src/
|
src/
|
||||||
App.svelte
|
App.svelte
|
||||||
components/
|
FileTreeRow.svelte
|
||||||
stores/
|
lib/
|
||||||
styles/
|
FileBreadcrumbs.svelte
|
||||||
|
FilePreviewModal.svelte
|
||||||
|
ConfirmModal.svelte
|
||||||
|
FileIcon.svelte
|
||||||
|
FileActions.svelte
|
||||||
|
fileUtils.js
|
||||||
|
api/verstak.js
|
||||||
|
|
||||||
internal/
|
internal/
|
||||||
core/
|
core/
|
||||||
@@ -335,10 +368,8 @@ verstak/
|
|||||||
worklog/
|
worklog/
|
||||||
activity/
|
activity/
|
||||||
search/
|
search/
|
||||||
sync/
|
|
||||||
security/
|
|
||||||
config/
|
config/
|
||||||
plugins/ # manager, lua (stub)
|
plugins/ # manager, lua (stub), builtin templates
|
||||||
|
|
||||||
contrib/
|
contrib/
|
||||||
plugins/
|
plugins/
|
||||||
@@ -351,11 +382,13 @@ verstak/
|
|||||||
004_add_notes.sql
|
004_add_notes.sql
|
||||||
005_add_actions.sql
|
005_add_actions.sql
|
||||||
006_add_worklog.sql
|
006_add_worklog.sql
|
||||||
|
007_search.sql
|
||||||
|
008_sync.sql
|
||||||
|
009_section.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
## RAID
|
## RAID
|
||||||
|
|
||||||
- **Критично:** Wails требует Node.js для frontend-сборки
|
- **Критично:** Wails требует Node.js для frontend-сборки
|
||||||
- **Критично:** go-sqlite3 + cgo; gcc уже установлен
|
- **Критично:** go-sqlite3 + cgo; gcc уже установлен
|
||||||
- **Зависимость:** Steps 15+ ждут завершения step 14
|
- **Зависимость:** Steps 15+ ждут завершения step 14 (MVP stabilization)
|
||||||
- **Риск:** Wails v3 может быть нестабилен — проверить перед шагом 11
|
|
||||||
|
|||||||
+1543
-229
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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) {
|
// Re-export all methods
|
||||||
if (window.go && window.go.main && window.go.main.App) {
|
export const listSections = () => App.ListSections()
|
||||||
return window.go.main.App[method](...args)
|
export const listTodayView = () => App.ListTodayView()
|
||||||
}
|
export const listNodesBySection = (section) => App.ListNodesBySection(section)
|
||||||
return Promise.reject(new Error('Wails bindings not loaded'))
|
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 listNotes = (nodeID) => App.ListNotes(nodeID)
|
||||||
export const listSections = () => wailsCall('ListSections')
|
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 listFiles = (nodeID) => App.ListFiles(nodeID)
|
||||||
export const listNodesBySection = (section) => wailsCall('ListNodesBySection', section)
|
export const listItems = (nodeID) => App.ListItems(nodeID)
|
||||||
export const listChildren = (parentID) => wailsCall('ListChildren', parentID)
|
export const addPathCopy = (nodeID, sourcePath) => App.AddPathCopy(nodeID, sourcePath)
|
||||||
export const getNodeDetail = (id) => wailsCall('GetNodeDetail', id)
|
export const addPathLink = (nodeID, sourcePath) => App.AddPathLink(nodeID, sourcePath)
|
||||||
export const createNode = (parentID, type, title, section) =>
|
export const deleteFileOrFolder = (nodeID) => App.DeleteFileOrFolder(nodeID)
|
||||||
wailsCall('CreateNode', parentID, type, title, section)
|
export const previewImport = (sourcePath) => App.PreviewImport(sourcePath)
|
||||||
export const deleteNode = (id) => wailsCall('DeleteNode', id)
|
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 listActions = (nodeID) => App.ListActions(nodeID)
|
||||||
export const listNotes = (nodeID) => wailsCall('ListNotes', nodeID)
|
export const runAction = (id) => App.RunAction(id)
|
||||||
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)
|
|
||||||
|
|
||||||
// Files
|
export const listWorklog = (nodeID) => App.ListWorklog(nodeID)
|
||||||
export const listFiles = (nodeID) => wailsCall('ListFiles', nodeID)
|
export const createWorklog = (nodeID, summary, minutes) => App.CreateWorklog(nodeID, summary, minutes)
|
||||||
|
|
||||||
// Actions
|
export const search = (query) => App.Search(query)
|
||||||
export const listActions = (nodeID) => wailsCall('ListActions', nodeID)
|
|
||||||
export const runAction = (id) => wailsCall('RunAction', id)
|
|
||||||
|
|
||||||
// Worklog
|
export const verstakVersion = () => App.VerstakVersion()
|
||||||
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')
|
|
||||||
|
|||||||
@@ -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="Открыть во внешней программе" 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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
server: {
|
server: {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 3001,
|
port: 3001,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
plugins: [svelte()],
|
|
||||||
build: {
|
build: {
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, "index.html"),
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
publicDir: "public",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -3,6 +3,7 @@ package files
|
|||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"verstak/internal/core/nodes"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
"verstak/internal/core/util"
|
"verstak/internal/core/util"
|
||||||
)
|
)
|
||||||
@@ -32,15 +34,25 @@ type Record struct {
|
|||||||
Missing bool `json:"missing"`
|
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.
|
// Service provides file operations inside a vault.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *storage.DB
|
db *storage.DB
|
||||||
vaultRoot string
|
vaultRoot string
|
||||||
|
nodes *nodes.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a file service bound to a vault.
|
// NewService creates a file service bound to a vault.
|
||||||
func NewService(db *storage.DB, vaultRoot string) *Service {
|
func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository) *Service {
|
||||||
return &Service{db: db, vaultRoot: vaultRoot}
|
return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB returns the underlying storage.
|
// DB returns the underlying storage.
|
||||||
@@ -48,6 +60,59 @@ func (s *Service) DB() *storage.DB {
|
|||||||
return s.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 ---
|
// --- public operations ---
|
||||||
|
|
||||||
// AddExternal registers an external file (absolute path) without copying.
|
// 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)
|
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 {
|
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
||||||
return nil, fmt.Errorf("mkdir: %w", err)
|
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)
|
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)
|
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,12 +208,19 @@ func (s *Service) DeleteToTrash(id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if rec.StorageMode == "vault" {
|
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")
|
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||||
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
|
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 {
|
if err := os.Rename(src, dest); err != nil {
|
||||||
return fmt.Errorf("move to trash: %w", err)
|
return fmt.Errorf("move to trash: %w", err)
|
||||||
}
|
}
|
||||||
@@ -157,15 +235,325 @@ func (s *Service) Open(id string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var abs string
|
abs, err := s.absPathSafe(rec)
|
||||||
if rec.StorageMode == "vault" {
|
if err != nil {
|
||||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
return err
|
||||||
} else {
|
|
||||||
abs = rec.Path
|
|
||||||
}
|
}
|
||||||
return openWithSystem(abs)
|
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 ---
|
// --- implementation details ---
|
||||||
|
|
||||||
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
|
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"verstak/internal/core/nodes"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ func TestAddExternal(t *testing.T) {
|
|||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
// Run migration 002 manually since storage.Open already applied it.
|
// Run migration 002 manually since storage.Open already applied it.
|
||||||
// We can verify the table exists by inserting.
|
// 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.
|
// Create a real temp file to register.
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -62,7 +63,7 @@ func TestAddExternal(t *testing.T) {
|
|||||||
func TestCopyIntoVault(t *testing.T) {
|
func TestCopyIntoVault(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
vaultRoot := t.TempDir()
|
vaultRoot := t.TempDir()
|
||||||
svc := NewService(db, vaultRoot)
|
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
|
||||||
|
|
||||||
// Source file.
|
// Source file.
|
||||||
srcDir := t.TempDir()
|
srcDir := t.TempDir()
|
||||||
@@ -88,7 +89,7 @@ func TestCopyIntoVault(t *testing.T) {
|
|||||||
|
|
||||||
func TestListByNode(t *testing.T) {
|
func TestListByNode(t *testing.T) {
|
||||||
db := openTestDB(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)
|
os.WriteFile(filepath.Join(t.TempDir(), "a.txt"), []byte("a"), 0o640)
|
||||||
f1 := filepath.Join(t.TempDir(), "a1.txt")
|
f1 := filepath.Join(t.TempDir(), "a1.txt")
|
||||||
@@ -111,7 +112,7 @@ func TestListByNode(t *testing.T) {
|
|||||||
func TestDeleteToTrash(t *testing.T) {
|
func TestDeleteToTrash(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
vaultRoot := t.TempDir()
|
vaultRoot := t.TempDir()
|
||||||
svc := NewService(db, vaultRoot)
|
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
|
||||||
|
|
||||||
src := filepath.Join(t.TempDir(), "important.pdf")
|
src := filepath.Join(t.TempDir(), "important.pdf")
|
||||||
os.WriteFile(src, []byte("important data"), 0o640)
|
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) {
|
func TestGuessMIME(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"a.md": "text/plain",
|
"a.md": "text/plain",
|
||||||
|
|||||||
@@ -50,16 +50,10 @@ func now() string {
|
|||||||
return time.Now().UTC().Format(time.RFC3339)
|
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.
|
// Create inserts a root or child node.
|
||||||
// parentID may be empty for root-level nodes.
|
// 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) {
|
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
|
||||||
if !IsValidType(typ) {
|
if !IsValidType(typ) {
|
||||||
return nil, fmt.Errorf("invalid node type: %s", 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 == "" {
|
if title == "" {
|
||||||
return nil, errors.New("title is required")
|
return nil, errors.New("title is required")
|
||||||
}
|
}
|
||||||
|
if section != "" && !IsValidSection(section) {
|
||||||
|
return nil, fmt.Errorf("invalid section: %s", section)
|
||||||
|
}
|
||||||
|
|
||||||
n := &Node{
|
n := &Node{
|
||||||
ID: util.UUID7(),
|
ID: util.UUID7(),
|
||||||
@@ -87,9 +84,22 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Bump parent's updated_at so it appears in today view.
|
||||||
|
if parentID != "" {
|
||||||
|
_ = r.touch(parentID)
|
||||||
|
}
|
||||||
return n, nil
|
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 {
|
func (r *Repository) insertNode(n *Node) error {
|
||||||
var parent interface{}
|
var parent interface{}
|
||||||
if n.ParentID != nil {
|
if n.ParentID != nil {
|
||||||
@@ -182,6 +192,41 @@ func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, err
|
|||||||
return scanNodes(rows)
|
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).
|
// UpdateTitle changes title (slug is recomputed).
|
||||||
func (r *Repository) UpdateTitle(id, title string) error {
|
func (r *Repository) UpdateTitle(id, title string) error {
|
||||||
if title == "" {
|
if title == "" {
|
||||||
|
|||||||
@@ -35,12 +35,41 @@ var TypeSet = map[string]struct{}{
|
|||||||
TypeLink: {},
|
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.
|
// IsValidType checks whether a type string is recognized.
|
||||||
func IsValidType(t string) bool {
|
func IsValidType(t string) bool {
|
||||||
_, ok := TypeSet[t]
|
_, ok := TypeSet[t]
|
||||||
return ok
|
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.
|
// Slugify converts a title into a filesystem-safe slug.
|
||||||
// Examples:
|
// Examples:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func setupService(t *testing.T) (*Service, *nodes.Repository, string) {
|
|||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
nodeRepo := nodes.NewRepository(db)
|
nodeRepo := nodes.NewRepository(db)
|
||||||
fileSvc := files.NewService(db, dir)
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
||||||
svc := NewService(db, dir, nodeRepo, fileSvc)
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
||||||
return svc, nodeRepo, dir
|
return svc, nodeRepo, dir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"verstak/internal/core/actions"
|
||||||
|
"verstak/internal/core/files"
|
||||||
|
"verstak/internal/core/notes"
|
||||||
|
"verstak/internal/core/nodes"
|
||||||
|
"verstak/internal/core/search"
|
||||||
|
"verstak/internal/core/storage"
|
||||||
|
"verstak/internal/core/vault"
|
||||||
|
"verstak/internal/core/worklog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openTestDB(t *testing.T) *storage.DB {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMVPSmoke(t *testing.T) {
|
||||||
|
// Create a test vault with a real filesystem area.
|
||||||
|
vaultDir := t.TempDir()
|
||||||
|
|
||||||
|
// 1. Init vault.
|
||||||
|
if err := vault.Init(vaultDir); err != nil {
|
||||||
|
t.Fatalf("vault init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := openTestDB(t)
|
||||||
|
nodeRepo := nodes.NewRepository(db)
|
||||||
|
fileSvc := files.NewService(db, vaultDir, nodeRepo)
|
||||||
|
noteSvc := notes.NewService(db, vaultDir, nodeRepo, fileSvc)
|
||||||
|
actionSvc := actions.NewService(db)
|
||||||
|
worklogSvc := worklog.NewService(db)
|
||||||
|
searchSvc := search.NewService(db)
|
||||||
|
|
||||||
|
// 2. Create client case structure.
|
||||||
|
client, err := nodeRepo.Create("", nodes.TypeCase, "ООО Ромашка", "clients")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create client: %v", err)
|
||||||
|
}
|
||||||
|
project, err := nodeRepo.Create(client.ID, nodes.TypeCase, "Сайт", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create a Markdown note.
|
||||||
|
noteNode, noteRec, err := noteSvc.Create(project.ID, "overview.md", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create note: %v", err)
|
||||||
|
}
|
||||||
|
if noteNode.Title != "overview.md" {
|
||||||
|
t.Errorf("note title = %q, want overview.md", noteNode.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Write note content.
|
||||||
|
content := "# Сайт ООО Ромашка\n\nWordPress/nginx. Иногда обновление."
|
||||||
|
if err := noteSvc.Save(noteNode.ID, content); err != nil {
|
||||||
|
t.Fatalf("save note: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Read note back.
|
||||||
|
got, err := noteSvc.Read(noteNode.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read note: %v", err)
|
||||||
|
}
|
||||||
|
if got != content {
|
||||||
|
t.Errorf("note content mismatch: got %q, want %q", got, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Verify note file exists on disk.
|
||||||
|
noteFileRecs, _ := fileSvc.ListByNode(noteNode.ID)
|
||||||
|
notePath := ""
|
||||||
|
if len(noteFileRecs) > 0 {
|
||||||
|
notePath = noteFileRecs[0].Path
|
||||||
|
if _, err := os.Stat(notePath); os.IsNotExist(err) {
|
||||||
|
t.Errorf("note file not on disk: %s", notePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = noteRec
|
||||||
|
|
||||||
|
// 7. Add a dummy external file.
|
||||||
|
dummyPath := filepath.Join(vaultDir, "dogovor.docx")
|
||||||
|
if err := os.WriteFile(dummyPath, []byte("dummy contract data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
created, err := fileSvc.AddPathCopy(project.ID, dummyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add file: %v", err)
|
||||||
|
}
|
||||||
|
if len(created) == 0 {
|
||||||
|
t.Fatal("no nodes created from file import")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Open the imported file (just verify it opens without error).
|
||||||
|
fileNode := created[0]
|
||||||
|
if fileNode.Type != nodes.TypeFile {
|
||||||
|
t.Errorf("expected file type, got %s", fileNode.Type)
|
||||||
|
}
|
||||||
|
fileRecs, err := fileSvc.ListByNode(fileNode.ID)
|
||||||
|
if err != nil || len(fileRecs) == 0 {
|
||||||
|
t.Fatalf("file record not found for imported file")
|
||||||
|
}
|
||||||
|
_ = fileSvc.Open(fileRecs[0].ID) // may fail in test (no display)
|
||||||
|
|
||||||
|
// 9. Verify the imported file node exists as a child of the project.
|
||||||
|
children, err := nodeRepo.ListChildren(project.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list children: %v", err)
|
||||||
|
}
|
||||||
|
hasImported := false
|
||||||
|
for _, c := range children {
|
||||||
|
if c.Type == nodes.TypeFile && c.Title == "dogovor.docx" {
|
||||||
|
hasImported = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasImported {
|
||||||
|
t.Error("imported file node not found under project")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Create actions.
|
||||||
|
urlAction, err := actionSvc.Create(project.ID, actions.KindOpenURL, "Открыть сайт", "", "", "https://example.com", nil, false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create url action: %v", err)
|
||||||
|
}
|
||||||
|
if urlAction.Kind != actions.KindOpenURL {
|
||||||
|
t.Errorf("action kind = %s, want %s", urlAction.Kind, actions.KindOpenURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
folderAction, err := actionSvc.Create(project.ID, actions.KindOpenFolder, "Открыть папку", vaultDir, "", "", nil, false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create folder action: %v", err)
|
||||||
|
}
|
||||||
|
if folderAction.Kind != actions.KindOpenFolder {
|
||||||
|
t.Errorf("action kind = %s", folderAction.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. List actions.
|
||||||
|
actionList, err := actionSvc.ListByNode(project.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list actions: %v", err)
|
||||||
|
}
|
||||||
|
if len(actionList) != 2 {
|
||||||
|
t.Errorf("expected 2 actions, got %d", len(actionList))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Run an action (open URL — should not error).
|
||||||
|
_, err = actionSvc.Run(urlAction.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run url action: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Add worklog.
|
||||||
|
entry, err := worklogSvc.Add(project.ID, "Обновил витрину сайта, баннеры", "", 180, false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add worklog: %v", err)
|
||||||
|
}
|
||||||
|
if entry.Minutes == nil || *entry.Minutes != 180 {
|
||||||
|
t.Errorf("worklog minutes = %v, want 180", entry.Minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. List worklog.
|
||||||
|
worklogList, err := worklogSvc.ListByNode(project.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list worklog: %v", err)
|
||||||
|
}
|
||||||
|
if len(worklogList) != 1 {
|
||||||
|
t.Errorf("expected 1 worklog entry, got %d", len(worklogList))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15. Search for something (FTS5 may not be available — gracefully skip).
|
||||||
|
_ = searchSvc.Rebuild()
|
||||||
|
_ = searchSvc.Index(noteNode.ID, noteNode.Title, content, notePath, "", "note")
|
||||||
|
results, err := searchSvc.Search("WordPress")
|
||||||
|
if err != nil {
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
if len(results) > 0 {
|
||||||
|
t.Logf("search found %d results for 'WordPress'", len(results))
|
||||||
|
} else {
|
||||||
|
t.Log("search returned no results (FTS5 may not be available)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16. Verify section filtering.
|
||||||
|
roots, err := nodeRepo.ListRoots(false, "clients")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list roots by section: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, r := range roots {
|
||||||
|
if r.ID == client.ID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("client not found in section 'clients'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 17. Soft delete node and verify.
|
||||||
|
if err := nodeRepo.SoftDelete(project.ID); err != nil {
|
||||||
|
t.Fatalf("soft delete project: %v", err)
|
||||||
|
}
|
||||||
|
_, err = nodeRepo.GetActive(project.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error getting deleted node")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 18. Reopen app (simulate by reconnecting to same DB).
|
||||||
|
db2, err := storage.Open(db.Path())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen db: %v", err)
|
||||||
|
}
|
||||||
|
defer db2.Close()
|
||||||
|
repo2 := nodes.NewRepository(db2)
|
||||||
|
|
||||||
|
// 19. Verify data persists.
|
||||||
|
client2, err := repo2.GetActive(client.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get client after reopen: %v", err)
|
||||||
|
}
|
||||||
|
if client2.Title != "ООО Ромашка" {
|
||||||
|
t.Errorf("client title after reopen = %q", client2.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20. Verify soft-deleted node stays soft-deleted.
|
||||||
|
allNodes, err := repo2.ListChildren(client.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list children after reopen: %v", err)
|
||||||
|
}
|
||||||
|
if len(allNodes) == 0 {
|
||||||
|
t.Error("no children found after reopen")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("MVP smoke test passed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMVPSmoke_WorklogReport(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
nodeRepo := nodes.NewRepository(db)
|
||||||
|
worklogSvc := worklog.NewService(db)
|
||||||
|
|
||||||
|
n, err := nodeRepo.Create("", nodes.TypeCase, "Test", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = worklogSvc.Add(n.ID, "Task 1", "", 60, true, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = worklogSvc.Add(n.ID, "Task 2", "", 30, false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
report, err := worklogSvc.Report(n.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("report: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(report, "Task 1") || !strings.Contains(report, "Task 2") {
|
||||||
|
t.Error("report missing entries")
|
||||||
|
}
|
||||||
|
if !strings.Contains(report, "Task 1") || !strings.Contains(report, "Task 2") {
|
||||||
|
t.Error("report missing tasks")
|
||||||
|
}
|
||||||
|
t.Logf("report:\n%s", report)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`
|
||||||
@@ -64,7 +64,8 @@ var migrationFiles = map[int]string{
|
|||||||
5: migration005,
|
5: migration005,
|
||||||
6: migration006,
|
6: migration006,
|
||||||
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
|
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
|
||||||
// 8: migration008, etc.
|
8: migration008,
|
||||||
|
9: migration009,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) runInitialSchema() error {
|
func (db *DB) runInitialSchema() error {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ type Server struct {
|
|||||||
// NewServer creates a GUI server for the given vault.
|
// NewServer creates a GUI server for the given vault.
|
||||||
func NewServer(db *storage.DB, vaultRoot string) *Server {
|
func NewServer(db *storage.DB, vaultRoot string) *Server {
|
||||||
nodeRepo := nodes.NewRepository(db)
|
nodeRepo := nodes.NewRepository(db)
|
||||||
fileSvc := files.NewService(db, vaultRoot)
|
fileSvc := files.NewService(db, vaultRoot, nodeRepo)
|
||||||
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
|
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
|
||||||
actionSvc := actions.NewService(db)
|
actionSvc := actions.NewService(db)
|
||||||
workSvc := worklog.NewService(db)
|
workSvc := worklog.NewService(db)
|
||||||
|
|||||||
Reference in New Issue
Block a user