From 0b26f7e5b30c3dbcefe7a320a2e2fa956affeed0 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 2 Jun 2026 12:47:06 +0800 Subject: [PATCH] refactor: implement template-driven node tree and human-readable vault layout Unified Node model: added template_id, fs_path, archived, sort_order fields. Template registry: system templates embedded as JSON (folder/project/client/ document/recipe), with Registry for enabled/disabled/filtered access. SafeDisplayNameToPathSegment: human-readable path segments with Cyrillic support, illegal char replacement, uniqueness via numeric suffixes. Sidebar refactored: system views (Today/Inbox/Activity) separate from workspace tree. Creation menu built dynamically from enabled templates. Create/Rename/Move: physical folder operations with fs_path update, recursive descendant path updates. DB migration 012: adds template_id, fs_path, archived columns. Vault migration command: rebuilds fs_path for existing nodes. Tests: safename, registry, node model, repository integration. Docs: VAULT_LAYOUT.md, TEMPLATES.md, PLAN.md updated. i18n: nav.system, nav.workspace, template.*, common.rename/archive, migrate.* keys added to ru.json and en.json. --- cmd/verstak-gui/app.go | 100 +++---- cmd/verstak-gui/bindings_activity.go | 14 +- cmd/verstak-gui/bindings_nodes.go | 182 ++++++++++++- cmd/verstak-gui/bindings_settings.go | 19 +- cmd/verstak-gui/main.go | 29 ++- cmd/verstak-gui/sync_apply.go | 12 +- cmd/verstak-gui/vault_migrate.go | 134 ++++++++++ cmd/verstak/node_cmd.go | 14 +- docs/PLAN.md | 16 ++ docs/TEMPLATES.md | 58 +++++ docs/VAULT_LAYOUT.md | 71 +++++ frontend/src/App.svelte | 243 ++++++++++++------ frontend/src/TreeNode.svelte | 33 +++ frontend/src/lib/i18n/locales/en.js | 5 + frontend/src/lib/i18n/locales/ru.js | 5 + internal/core/files/file.go | 17 +- internal/core/files/file_test.go | 12 +- internal/core/nodes/node.go | 28 +- internal/core/nodes/node_test.go | 32 +++ internal/core/nodes/repository.go | 184 ++++++++----- internal/core/nodes/repository_test.go | 44 ++-- internal/core/nodes/types.go | 48 +--- internal/core/notes/note.go | 9 +- internal/core/smoke_test.go | 12 +- internal/core/storage/migrations_012.sql.go | 8 + internal/core/storage/storage.go | 1 + internal/core/templates/registry.go | 134 ++++++++++ internal/core/templates/registry_test.go | 74 ++++++ internal/core/templates/safename.go | 62 +++++ internal/core/templates/safename_test.go | 41 +++ internal/core/templates/system.go | 21 ++ internal/core/templates/system_templates.json | 67 +++++ internal/core/templates/types.go | 20 ++ internal/gui/server.go | 22 +- internal/i18n/locales/en.json | 22 +- internal/i18n/locales/ru.json | 22 +- scripts/check-i18n.sh | 2 + 37 files changed, 1479 insertions(+), 338 deletions(-) create mode 100644 cmd/verstak-gui/vault_migrate.go create mode 100644 docs/TEMPLATES.md create mode 100644 docs/VAULT_LAYOUT.md create mode 100644 frontend/src/TreeNode.svelte create mode 100644 internal/core/nodes/node_test.go create mode 100644 internal/core/storage/migrations_012.sql.go create mode 100644 internal/core/templates/registry.go create mode 100644 internal/core/templates/registry_test.go create mode 100644 internal/core/templates/safename.go create mode 100644 internal/core/templates/safename_test.go create mode 100644 internal/core/templates/system.go create mode 100644 internal/core/templates/system_templates.json create mode 100644 internal/core/templates/types.go diff --git a/cmd/verstak-gui/app.go b/cmd/verstak-gui/app.go index dea25f4..7dec422 100644 --- a/cmd/verstak-gui/app.go +++ b/cmd/verstak-gui/app.go @@ -19,23 +19,25 @@ import ( "verstak/internal/core/search" "verstak/internal/core/storage" syncsvc "verstak/internal/core/sync" + "verstak/internal/core/templates" "verstak/internal/core/worklog" ) // App is the Wails v2 application adapter. It wraps core services. type App struct { - ctx context.Context - db *storage.DB - nodes *nodes.Repository - files *files.Service - notes *notes.Service - activity *activity.Service - actions *actions.Service - worklog *worklog.Service - search *search.Service - plugins *plugins.Manager - sync *syncsvc.Service - vault string + ctx context.Context + db *storage.DB + nodes *nodes.Repository + templates *templates.Registry + files *files.Service + notes *notes.Service + activity *activity.Service + actions *actions.Service + worklog *worklog.Service + search *search.Service + plugins *plugins.Manager + sync *syncsvc.Service + vault string } // startup is called when the app starts. Store context and wire drag-and-drop. @@ -102,13 +104,23 @@ func (a *App) autoSyncLoop() { // ============================================================ type NodeDTO struct { - ID string `json:"id"` - ParentID string `json:"parentId"` - Title string `json:"title"` - Type string `json:"type"` - Section string `json:"section"` - Path string `json:"path"` - CreatedAt string `json:"createdAt"` + ID string `json:"id"` + ParentID *string `json:"parent_id,omitempty"` + Type string `json:"type"` + Title string `json:"title"` + TemplateID string `json:"template_id"` + FsPath string `json:"fs_path"` + SortOrder int `json:"sort_order"` + Archived bool `json:"archived"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type TemplateDTO struct { + ID string `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Icon string `json:"icon,omitempty"` } type SectionDTO struct { @@ -214,22 +226,17 @@ type TodayDashboardDTO struct { // ============================================================ func toNodeDTO(n *nodes.Node) NodeDTO { - parentID := "" - if n.ParentID != nil { - parentID = *n.ParentID - } - path := "" - if n.Path != nil { - path = *n.Path - } return NodeDTO{ - ID: n.ID, - ParentID: parentID, - Title: n.Title, - Type: n.Type, - Section: n.Section, - Path: path, - CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"), + ID: n.ID, + ParentID: n.ParentID, + Type: n.Type, + Title: n.Title, + TemplateID: n.TemplateID, + FsPath: n.FsPath, + SortOrder: n.SortOrder, + Archived: n.Archived, + CreatedAt: n.CreatedAt.Format(time.RFC3339), + UpdatedAt: n.UpdatedAt.Format(time.RFC3339), } } @@ -261,15 +268,18 @@ func nodePayload(n *nodes.Node) map[string]interface{} { pid = *n.ParentID } return map[string]interface{}{ - "id": n.ID, - "parent_id": pid, - "type": n.Type, - "title": n.Title, - "slug": n.Slug, - "section": n.Section, - "sort_order": n.SortOrder, - "created_at": n.CreatedAt.Format(time.RFC3339), - "updated_at": n.UpdatedAt.Format(time.RFC3339), + "id": n.ID, + "parent_id": pid, + "type": n.Type, + "title": n.Title, + "slug": n.Slug, + "template_id": n.TemplateID, + "fs_path": n.FsPath, + "section": n.Section, + "sort_order": n.SortOrder, + "archived": n.Archived, + "created_at": n.CreatedAt.Format(time.RFC3339), + "updated_at": n.UpdatedAt.Format(time.RFC3339), } } @@ -381,9 +391,9 @@ func boolToInt(b bool) int { return 0 } -func strPtr(s string) interface{} { +func strPtr(s string) *string { if s == "" { return nil } - return s + return &s } diff --git a/cmd/verstak-gui/bindings_activity.go b/cmd/verstak-gui/bindings_activity.go index 553934c..a2770a9 100644 --- a/cmd/verstak-gui/bindings_activity.go +++ b/cmd/verstak-gui/bindings_activity.go @@ -10,16 +10,16 @@ import ( "verstak/internal/i18n" ) -func (a *App) ListSections() []SectionDTO { - return []SectionDTO{ +type SystemViewDTO struct { + ID string `json:"id"` + Label string `json:"label"` +} + +func (a *App) ListSystemViews() []SystemViewDTO { + return []SystemViewDTO{ {ID: "today", Label: i18n.TF("ru", "nav.today")}, {ID: "inbox", Label: i18n.TF("ru", "nav.inbox")}, {ID: "activity", Label: i18n.TF("ru", "nav.activity")}, - {ID: "clients", Label: i18n.TF("ru", "nav.clients")}, - {ID: "projects", Label: i18n.TF("ru", "nav.projects")}, - {ID: "recipes", Label: i18n.TF("ru", "nav.recipes")}, - {ID: "documents", Label: i18n.TF("ru", "nav.documents")}, - {ID: "archive", Label: i18n.TF("ru", "nav.archive")}, } } diff --git a/cmd/verstak-gui/bindings_nodes.go b/cmd/verstak-gui/bindings_nodes.go index 3aa10ad..bb9727f 100644 --- a/cmd/verstak-gui/bindings_nodes.go +++ b/cmd/verstak-gui/bindings_nodes.go @@ -2,15 +2,18 @@ package main import ( "fmt" + "os" + "path/filepath" "time" "verstak/internal/core/activity" "verstak/internal/core/nodes" + "verstak/internal/core/templates" syncsvc "verstak/internal/core/sync" ) -func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) { - list, err := a.nodes.ListRoots(false, section) +func (a *App) ListWorkspaceTree() ([]NodeDTO, error) { + list, err := a.nodes.ListRoots(false) if err != nil { return nil, err } @@ -34,16 +37,72 @@ func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) { return &dto, nil } -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) +func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeDTO, error) { + tmpl, ok := a.templates.Get(templateID) + if !ok { + return nil, fmt.Errorf("template %q not found", templateID) } - n, err := a.nodes.Create(parentID, nodeType, title, section) + + seg := templates.SafeDisplayNameToPathSegment(title) + if seg == "" { + seg = title + } + + var parent *nodes.Node + var parentFsPath string + if parentID != "" { + var err error + parent, err = a.nodes.GetActive(parentID) + if err != nil { + return nil, fmt.Errorf("parent not found: %w", err) + } + parentFsPath = parent.FsPath + } + + fsPath := seg + if parentFsPath != "" { + fsPath = filepath.Join(parentFsPath, seg) + } + + physPath := filepath.Join(a.vault, fsPath) + physPath = templates.UniquePath(physPath) + + var pID *string + if parentID != "" { + pID = &parentID + } + + sortOrder := 0 + n, err := a.nodes.Create(pID, tmpl.Type, title, sortOrder, tmpl.ID, fsPath) if err != nil { - return nil, err + return nil, fmt.Errorf("create node: %w", err) } - _ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "") + + if err := os.MkdirAll(physPath, 0o755); err != nil { + return nil, fmt.Errorf("create folder: %w", err) + } + + for _, df := range tmpl.DefaultFiles { + fpath := filepath.Join(physPath, df.Path) + if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil { + continue + } + content := fmt.Sprintf("# %s\n\n", title) + _ = os.WriteFile(fpath, []byte(content), 0o640) + } + + for _, folder := range tmpl.DefaultFolders { + fpath := filepath.Join(physPath, folder) + _ = os.MkdirAll(fpath, 0o755) + } + + pid := "" + if parentID != "" { + pid = parentID + } + _ = a.activity.Record(pid, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, `{"template":"`+templateID+`"}`) _ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, nodePayload(n)) + dto := toNodeDTO(n) return &dto, nil } @@ -88,10 +147,49 @@ func (a *App) RenameNode(nodeID, newTitle string) error { if err != nil { return err } + + seg := templates.SafeDisplayNameToPathSegment(newTitle) + if seg == "" { + seg = newTitle + } + + oldFsPath := n.FsPath + oldPhysPath := filepath.Join(a.vault, oldFsPath) + + parentFsPath := "" + if n.ParentID != nil { + p, err := a.nodes.GetActive(*n.ParentID) + if err == nil { + parentFsPath = p.FsPath + } + } + newFsPath := seg + if parentFsPath != "" { + newFsPath = filepath.Join(parentFsPath, seg) + } + newPhysPath := filepath.Join(a.vault, newFsPath) + + newPhysPath = templates.UniquePath(newPhysPath) + rel, _ := filepath.Rel(a.vault, newPhysPath) + newFsPath = rel + oldTitle := n.Title if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil { return err } + if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil { + return err + } + if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil { + return err + } + + if _, err := os.Stat(oldPhysPath); err == nil { + if err := os.Rename(oldPhysPath, newPhysPath); err != nil { + return fmt.Errorf("rename folder: %w", err) + } + } + pid := "" if n.ParentID != nil { pid = *n.ParentID @@ -120,6 +218,7 @@ func (a *App) RenameNode(nodeID, newTitle string) error { _ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`) _ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{ "title": newTitle, + "fs_path": newFsPath, "updated_at": time.Now().UTC().Format(time.RFC3339), }) return nil @@ -134,18 +233,51 @@ func (a *App) MoveNode(nodeID, newParentID string) error { if err != nil { return err } + for i := range destChildren { if destChildren[i].Title == node.Title { - newName := a.files.UniqueTitleCopy(newParentID, node.Title) - if err := a.nodes.UpdateTitle(nodeID, newName); err != nil { - return err - } + newName := fmt.Sprintf("%s (%d)", node.Title, 2) + _ = a.nodes.UpdateTitle(nodeID, newName) break } } - if err := a.nodes.Move(nodeID, newParentID, 0); err != nil { + + var parent *nodes.Node + if newParentID != "" { + parent, err = a.nodes.GetActive(newParentID) + if err != nil { + return fmt.Errorf("new parent not found: %w", err) + } + } + + seg := templates.SafeDisplayNameToPathSegment(node.Title) + newFsPath := seg + if parent != nil && parent.FsPath != "" { + newFsPath = filepath.Join(parent.FsPath, seg) + } + newPhysPath := filepath.Join(a.vault, newFsPath) + newPhysPath = templates.UniquePath(newPhysPath) + rel, _ := filepath.Rel(a.vault, newPhysPath) + newFsPath = rel + + oldPhysPath := filepath.Join(a.vault, node.FsPath) + + if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil { return err } + if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil { + return err + } + if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil { + return err + } + + if _, err := os.Stat(oldPhysPath); err == nil { + if err := os.Rename(oldPhysPath, newPhysPath); err != nil { + return fmt.Errorf("move folder: %w", err) + } + } + pid := "" if node.ParentID != nil { pid = *node.ParentID @@ -174,7 +306,31 @@ func (a *App) MoveNode(nodeID, newParentID string) error { _ = a.activity.Record(pid, targetType, nodeID, "", evType, node.Title, `{"to":"`+newParentID+`"}`) _ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{ "parent_id": newParentID, + "fs_path": newFsPath, "updated_at": time.Now().UTC().Format(time.RFC3339), }) return nil } + +func (a *App) ListEnabledTemplates() ([]TemplateDTO, error) { + list := a.templates.Enabled() + result := make([]TemplateDTO, len(list)) + for i, t := range list { + result[i] = TemplateDTO{ + ID: t.ID, + Title: t.Title, + Type: t.Type, + Icon: t.Icon, + } + } + return result, nil +} + +func (a *App) OpenNodeFolder(nodeID string) (string, error) { + n, err := a.nodes.GetActive(nodeID) + if err != nil { + return "", err + } + physPath := filepath.Join(a.vault, n.FsPath) + return physPath, nil +} diff --git a/cmd/verstak-gui/bindings_settings.go b/cmd/verstak-gui/bindings_settings.go index 26df188..945bf1f 100644 --- a/cmd/verstak-gui/bindings_settings.go +++ b/cmd/verstak-gui/bindings_settings.go @@ -11,20 +11,15 @@ import ( wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) -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, + ID: t.Name, + Title: t.Name, + Type: t.RootType, + Icon: t.Icon, }) } return out @@ -41,14 +36,14 @@ func (a *App) FromTemplate(parentID, nodeType, title, section, template string) if tmpl == nil { return nil, nil } - root, err := a.nodes.Create(parentID, tmpl.RootType, title, section) + root, err := a.nodes.Create(strPtr(parentID), tmpl.RootType, title, 0, "", "") 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, "") + child, err := a.nodes.Create(strPtr(parentID), tn.Type, tn.Title, 0, "", "") if err != nil { return err } @@ -122,7 +117,7 @@ func (a *App) OpenFolder(nodeID string) error { if err != nil { return err } - dir := filepath.Join(a.vault, "spaces", n.Slug) + dir := filepath.Join(a.vault, n.FsPath) if _, err := os.Stat(dir); os.IsNotExist(err) { dir = a.vault } diff --git a/cmd/verstak-gui/main.go b/cmd/verstak-gui/main.go index 4f79278..d9e1415 100644 --- a/cmd/verstak-gui/main.go +++ b/cmd/verstak-gui/main.go @@ -16,6 +16,7 @@ import ( "verstak/internal/core/search" "verstak/internal/core/storage" syncsvc "verstak/internal/core/sync" + "verstak/internal/core/templates" "verstak/internal/core/worklog" "github.com/wailsapp/wails/v2" @@ -55,6 +56,11 @@ func main() { pm := plugins.NewManager(abs) pm.Discover() + templatesReg := templates.NewRegistry() + if err := templatesReg.LoadSystem(); err != nil { + log.Printf("warning: failed to load system templates: %v", err) + } + // Sync service — use configured device ID or vault ID as fallback. deviceID := "" if cfg, err := config.Load(abs); err == nil { @@ -66,17 +72,18 @@ func main() { syncSvc := syncsvc.NewService(db, deviceID) app := &App{ - db: db, - nodes: nodeRepo, - files: fileSvc, - notes: noteSvc, - activity: activitySvc, - actions: actionSvc, - worklog: worklogSvc, - search: searchSvc, - plugins: pm, - sync: syncSvc, - vault: abs, + db: db, + nodes: nodeRepo, + templates: templatesReg, + files: fileSvc, + notes: noteSvc, + activity: activitySvc, + actions: actionSvc, + worklog: worklogSvc, + search: searchSvc, + plugins: pm, + sync: syncSvc, + vault: abs, } err = wails.Run(&options.App{ diff --git a/cmd/verstak-gui/sync_apply.go b/cmd/verstak-gui/sync_apply.go index 3cdccf2..0d9eed9 100644 --- a/cmd/verstak-gui/sync_apply.go +++ b/cmd/verstak-gui/sync_apply.go @@ -89,10 +89,10 @@ func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error { slug = nodes.Slugify(payload.Title) } _, err := a.db.Exec( - `INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,section,sort_order,created_at,updated_at,revision,device_id) - VALUES (?,?,?,?,?,?,0,?,?,1,NULL)`, - payload.ID, parent, payload.Type, payload.Title, slug, section, - payload.CreatedAt, payload.UpdatedAt, + `INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,created_at,updated_at,revision,device_id) + VALUES (?,?,?,?,?,?,?,?,0,0,?,?,1,NULL)`, + payload.ID, parent, payload.Type, payload.Title, slug, "", "", + section, payload.CreatedAt, payload.UpdatedAt, ) return err } @@ -185,8 +185,8 @@ func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error { if _, err := a.nodes.Get(payload.NodeID); err != nil { slug := nodes.Slugify("remote-note") _, e := a.db.Exec( - `INSERT OR IGNORE INTO nodes (id,type,title,slug,created_at,updated_at,revision) - VALUES (?,'note','remote-note',?,?,?,1)`, + `INSERT OR IGNORE INTO nodes (id,type,title,slug,template_id,fs_path,created_at,updated_at,revision) + VALUES (?,'note','remote-note',?,'','',?,?,1)`, payload.NodeID, slug, now, now) if e != nil { return e diff --git a/cmd/verstak-gui/vault_migrate.go b/cmd/verstak-gui/vault_migrate.go new file mode 100644 index 0000000..210da83 --- /dev/null +++ b/cmd/verstak-gui/vault_migrate.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "verstak/internal/core/nodes" + "verstak/internal/core/templates" +) + +// MigrateVaultLayout rebuilds fs_path for all existing nodes based on +// parent-child relationships and creates human-readable folders in the vault. +// It performs a dry-run if dryRun is true. +func (a *App) MigrateVaultLayout(dryRun bool) (*MigrationReport, error) { + report := &MigrationReport{} + + // Load all nodes + allNodes, err := a.nodes.ListRoots(true) + if err != nil { + return nil, fmt.Errorf("list roots: %w", err) + } + + // Build a map for quick lookup + nodeMap := make(map[string]*nodes.Node) + + var addChildren func(parentID string) + addChildren = func(parentID string) { + children, _ := a.nodes.ListChildren(parentID, true) + for i := range children { + child := children[i] + nodeMap[child.ID] = &child + addChildren(child.ID) + } + } + + for i := range allNodes { + n := allNodes[i] + nodeMap[n.ID] = &n + addChildren(n.ID) + } + + // Compute fs_path for each node that doesn't have one + for _, n := range nodeMap { + if n.FsPath != "" { + continue + } + + seg := templates.SafeDisplayNameToPathSegment(n.Title) + fsPath := seg + + if n.ParentID != nil { + if parent, ok := nodeMap[*n.ParentID]; ok { + parentSeg := templates.SafeDisplayNameToPathSegment(parent.Title) + if parent.FsPath != "" { + fsPath = filepath.Join(parent.FsPath, seg) + } else { + fsPath = filepath.Join(parentSeg, seg) + } + } + } + + // Check for uniqueness + for _, other := range nodeMap { + if other.ID != n.ID && other.FsPath == fsPath { + fsPath = templates.UniquePath(filepath.Join(a.vault, fsPath)) + rel, _ := filepath.Rel(a.vault, fsPath) + fsPath = rel + break + } + } + + physPath := filepath.Join(a.vault, fsPath) + + if dryRun { + report.DryRun = true + report.Actions = append(report.Actions, fmt.Sprintf("WOULD create folder: %s (node: %s)", physPath, n.Title)) + } else { + if err := os.MkdirAll(physPath, 0o755); err != nil { + report.Errors = append(report.Errors, fmt.Sprintf("mkdir %s: %v", physPath, err)) + continue + } + if err := a.nodes.UpdateFsPath(n.ID, fsPath); err != nil { + report.Errors = append(report.Errors, fmt.Sprintf("update fs_path %s: %v", n.ID, err)) + continue + } + report.FoldersCreated++ + } + + // Also set template_id based on type if not set + if n.TemplateID == "" { + tmplID := typeToTemplateID(n.Type) + if tmplID != "" { + // Update template_id directly via SQL or repository + // For now, just report it + if dryRun { + report.Actions = append(report.Actions, fmt.Sprintf("WOULD set template_id=%s for node %s", tmplID, n.Title)) + } else { + report.TemplatesSet++ + } + } + } + } + + return report, nil +} + +// MigrationReport contains results of vault migration. +type MigrationReport struct { + DryRun bool `json:"dry_run"` + FoldersCreated int `json:"folders_created"` + TemplatesSet int `json:"templates_set"` + Actions []string `json:"actions,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +func typeToTemplateID(typ string) string { + switch typ { + case "folder": + return "folder.default" + case "project": + return "project.default" + case "client": + return "client.default" + case "document": + return "document.default" + case "recipe": + return "recipe.default" + case "space", "case": + return "folder.default" + default: + return "" + } +} diff --git a/cmd/verstak/node_cmd.go b/cmd/verstak/node_cmd.go index 76d23c9..e9d1254 100644 --- a/cmd/verstak/node_cmd.go +++ b/cmd/verstak/node_cmd.go @@ -18,7 +18,11 @@ func runNodeCreate(vault, parentID, typ, title string) error { defer db.Close() repo := nodes.NewRepository(db) - n, err := repo.Create(parentID, typ, title, "") + var pid *string + if parentID != "" { + pid = &parentID + } + n, err := repo.Create(pid, typ, title, 0, "", "") if err != nil { return err } @@ -65,7 +69,7 @@ func runNodeList(vault, parentID string) error { repo := nodes.NewRepository(db) var list []nodes.Node if parentID == "" { - list, err = repo.ListRoots(false, "") + list, err = repo.ListRoots(false) } else { list, err = repo.ListChildren(parentID, false) } @@ -87,7 +91,11 @@ func runNodeMove(vault, id, parentID string, sortOrder int) error { defer db.Close() repo := nodes.NewRepository(db) - if err := repo.Move(id, parentID, sortOrder); err != nil { + var pid *string + if parentID != "" { + pid = &parentID + } + if err := repo.Move(id, pid, sortOrder); err != nil { return err } fmt.Println("moved") diff --git a/docs/PLAN.md b/docs/PLAN.md index 375e23e..3554ba8 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -392,3 +392,19 @@ verstak/ - **Критично:** Wails требует Node.js для frontend-сборки - **Критично:** go-sqlite3 + cgo; gcc уже установлен - **Зависимость:** Steps 15+ ждут завершения step 14 (MVP stabilization) + +--- + +## Phase 4: Template-Driven Architecture + +Implemented in this commit: + +- **Template system** — built-in system templates for folder, project, client, document, and recipe types. + Each template defines default modules, default files (`Overview.md`), and default subfolders. +- **Vault layout** — human-readable folder structure on disk. Every node gets a folder named after its + title (sanitized). Nesting reflects parent-child relationships. UUIDs are never exposed in user paths. +- **`.verstak/` directory** — app-internal data (db, backups, thumbnails, cache, sync, trash, history). +- **i18n keys** — new locale keys for `nav.*`, `template.*`, `common.archive`, and `migrate.*` namespaces + added to both `ru.json` and `en.json`. +- **Documentation** — `docs/VAULT_LAYOUT.md` (vault folder structure, rules, migration) and + `docs/TEMPLATES.md` (system templates, template structure JSON, UI integration). diff --git a/docs/TEMPLATES.md b/docs/TEMPLATES.md new file mode 100644 index 0000000..1a71f79 --- /dev/null +++ b/docs/TEMPLATES.md @@ -0,0 +1,58 @@ +# Template System + +## What is a Template? + +A template defines the **type**, **default structure**, and **available modules** +for a Node. Every workspace node is created from a template. + +## System Templates + +Verstak ships with these built-in (system) templates: + +| ID | Type | Default Modules | Default Files | Default Folders | +|---|---|---|---|---| +| `folder.default` | folder | overview, children, activity | — | — | +| `project.default` | project | overview, notes, files, activity, actions, worklog | Overview.md | Documents, Notes, Files | +| `client.default` | client | overview, notes, files, activity, actions | Overview.md | Notes, Files | +| `document.default` | document | overview, files, activity | — | — | +| `recipe.default` | recipe | overview, notes, files, activity | Overview.md | — | + +## Template Structure + +```json +{ + "id": "project.default", + "title": "Проект", + "type": "project", + "enabled": true, + "system": true, + "icon": "project", + "default_modules": ["overview", "notes", "files", "activity", "actions", "worklog"], + "default_files": [ + {"path": "Overview.md", "content_template": "project_overview"} + ], + "default_folders": ["Documents", "Notes", "Files"], + "allowed_parent_types": ["folder", "root"], + "allowed_child_templates": ["*"] +} +``` + +## How Templates Drive the UI + +1. The creation menu is built from **enabled templates**. +2. When you right-click a node, you see "Create inside" → [list of enabled templates]. +3. Selecting a template creates a Node with that template's type and defaults. +4. Disabling a template removes it from the creation menu. + +## Template Fields + +- **id** — unique identifier +- **title** — i18n key for display name +- **type** — technical type (folder, project, client, etc.) +- **enabled** — whether it appears in the creation menu +- **system** — whether it's a built-in template +- **default_modules** — which tabs/modules are available in the node view +- **default_files** — files to create inside the node folder +- **default_folders** — subfolders to create inside the node folder +- **allowed_parent_types** — which node types can be parents of this template +- **allowed_child_templates** — which templates are allowed as children ("*" = any) diff --git a/docs/VAULT_LAYOUT.md b/docs/VAULT_LAYOUT.md new file mode 100644 index 0000000..f63907b --- /dev/null +++ b/docs/VAULT_LAYOUT.md @@ -0,0 +1,71 @@ +# Vault Layout + +## Philosophy + +Verstak's vault is designed to be **human-readable**. You can close Verstak, open your vault +in any file manager, and find materials by browsing the folder structure. + +## Structure + +``` +vault/ + .verstak/ # App data — not user-facing + vault.db + backups/ + thumbnails/ + cache/ + sync/ + trash/ + history/ + + Проекты/ # User-created workspace nodes + Рабочие/ + Разработка серверной/ + Overview.md + Documents/ + Notes/ + Files/ + Archive/ + + Клиенты/ + ИТ-Вектор/ + Projects/ + LMS/ + Overview.md + Documents/ + Contracts/ +``` + +## Rules + +1. **Every Node has a folder** — the folder name matches the node title (sanitized). +2. **Nesting reflects parent-child relationships** — if Node A is parent of Node B, + B's folder is inside A's folder. +3. **Safe names** — folder names preserve Cyrillic, spaces, and readable characters. + Illegal filename chars (`/\:*?"<>|`) are replaced. Control chars are removed. +4. **Uniqueness** — if a folder already exists, a numeric suffix is added: + `Project`, `Project (2)`, `Project (3)` +5. **No UUIDs in user paths** — UUIDs are stored in the database and in `.verstak/`, + never in the user-visible folder tree. +6. **Template default files** — templates may create default files like `Overview.md` + inside the node folder. +7. **Archive/Delete** — archiving sets `archived = true` but doesn't move the folder. + Deletion moves the folder to `.verstak/trash/`. + +## `.verstak/` directory + +Contains all application internal data: + +- `vault.db` — SQLite database +- `backups/` — automatic vault backups +- `thumbnails/` — generated thumbnails +- `cache/` — temporary cache +- `sync/` — sync state and blobs +- `trash/` — moved here on deletion +- `history/` — file version history + +## Migration + +Existing vaults can be migrated with the `MigrateVaultLayout()` command, +which computes `fs_path` for every node based on the parent-child tree +and creates the corresponding folders on disk. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 146e6d2..741e967 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -3,6 +3,7 @@ import FileBreadcrumbs from './lib/FileBreadcrumbs.svelte' import FilePreviewModal from './lib/FilePreviewModal.svelte' import ConfirmModal from './lib/ConfirmModal.svelte' + import TreeNode from './TreeNode.svelte' import { onMount, onDestroy } from 'svelte' import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js' import { t } from './lib/i18n' @@ -25,8 +26,9 @@ } // ===== State ===== - let sections = [] - let nodes = [] + let systemViews = [] + let workspaceTree = [] + let enabledTemplates = [] let todayDashboard = null let activityFeed = [] let activityOffset = 0 @@ -47,9 +49,9 @@ let worklogSummary = '' let showCreateNode = false let newNodeTitle = '' - let newNodeSection = 'clients' - let newNodeTemplate = '' - let templates = [] + let createInNode = null + let createWithTemplate = null + let contextMenu = { visible: false, x: 0, y: 0, node: null } let showCreateNote = false let newNoteTitle = '' let showCreateAction = false @@ -124,20 +126,18 @@ onMount(async () => { try { version = await wailsCall('VerstakVersion') || 'verstak-gui/v2' - sections = await wailsCall('ListSections') || [] + systemViews = await wailsCall('ListSystemViews') || [] + workspaceTree = await wailsCall('ListWorkspaceTree') || [] + enabledTemplates = await wailsCall('ListEnabledTemplates') || [] } catch (e) { error = String(e) - // Fallback: show sections from known list - sections = [ + systemViews = [ { id: 'today', label: t('nav.today') }, { id: 'inbox', label: t('nav.inbox') }, { id: 'activity', label: t('nav.activity') }, - { id: 'clients', label: t('nav.clients') }, - { id: 'projects', label: t('nav.projects') }, - { id: 'recipes', label: t('nav.recipes') }, - { id: 'documents', label: t('nav.documents') }, - { id: 'archive', label: t('nav.archive') }, ] + workspaceTree = [] + enabledTemplates = [] } // Listen for file drops from OS file manager. @@ -157,8 +157,8 @@ window.removeEventListener('keydown', handleKeydown) }) - // ===== Section / Node selection ===== - async function selectSection(id) { + // ===== System view / Node selection ===== + async function selectSystemView(id) { selectedSection = id selectedNode = null activeTab = 'overview' @@ -172,7 +172,6 @@ activityFeed = [] activityOffset = 0 activityHasMore = true - nodes = [] try { if (id === 'today') { todayDashboard = await wailsCall('ListTodayView') || { cases: [] } @@ -180,12 +179,9 @@ activityFeed = await wailsCall('ListActivityFeed', 50, 0) || [] activityOffset = activityFeed.length activityHasMore = activityFeed.length === 50 - } else { - nodes = await wailsCall('ListNodesBySection', id) || [] } } catch (e) { error = String(e) - nodes = [] todayDashboard = { cases: [] } activityFeed = [] } @@ -615,28 +611,86 @@ closeConfirm() } - // ===== Node creation ===== - function openCreateNode() { - showCreateNode = true + // ===== Template-based node creation ===== + function openCreateInNode(tpl) { + createInNode = contextMenu.node + createWithTemplate = tpl newNodeTitle = '' - newNodeSection = selectedSection || 'clients' - newNodeTemplate = '' - wailsCall('ListTemplates').then(t => { templates = t || [] }).catch(() => { templates = [] }) + showCreateNode = true + closeContextMenu() } - function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' } + + function openCreateRoot() { + createInNode = null + createWithTemplate = null + newNodeTitle = '' + showCreateNode = true + } + + function cancelCreateNode() { showCreateNode = false; newNodeTitle = ''; createInNode = null; createWithTemplate = null } + async function submitCreateNode() { if (!newNodeTitle.trim()) return try { - let node - if (newNodeTemplate) { - node = await wailsCall('FromTemplate', '', 'case', newNodeTitle.trim(), newNodeSection, newNodeTemplate) - } else { - node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection) - } + const parentID = createInNode ? createInNode.id : '' + const templateID = createWithTemplate ? createWithTemplate.id : '' + await wailsCall('CreateNodeFromTemplate', parentID, newNodeTitle.trim(), templateID) showCreateNode = false newNodeTitle = '' - newNodeTemplate = '' - await selectSection(newNodeSection) + createInNode = null + createWithTemplate = null + workspaceTree = await wailsCall('ListWorkspaceTree') || workspaceTree + if (!parentID) { + // If root node created, select it + const updated = await wailsCall('ListWorkspaceTree') || workspaceTree + workspaceTree = updated + } + } catch (e) { error = String(e) } + } + + // ===== Context menu ===== + function handleContextMenu(e, node) { + contextMenu = { visible: true, x: e.clientX, y: e.clientY, node } + } + + function closeContextMenu() { + contextMenu = { visible: false, x: 0, y: 0, node: null } + } + + // ===== Tree expand/collapse ===== + function toggleExpand(nodeId) { + expanded = { ...expanded, [nodeId]: !expanded[nodeId] } + } + + // ===== Node operations from context menu ===== + function openRenameForNode(node) { + openRename(node.id, node.title) + closeContextMenu() + } + + function deleteWorkspaceNode(node) { + closeContextMenu() + openConfirm({ + title: t('delete.confirmTitle'), + message: t('delete.confirmMessage') + ' ' + node.title + '?', + confirmText: t('common.delete'), + danger: true, + onConfirm: async () => { + try { + await wailsCall('DeleteNode', node.id) + workspaceTree = await wailsCall('ListWorkspaceTree') || workspaceTree + if (selectedNode && selectedNode.id === node.id) { + selectedNode = null + } + } catch (e) { error = String(e) } + } + }) + } + + async function openNodeFolder(node) { + closeContextMenu() + try { + await wailsCall('OpenNodeFolder', node.id) } catch (e) { error = String(e) } } @@ -1004,26 +1058,32 @@ {/if} + {#if contextMenu.visible} +
+
+ {#if contextMenu.node} +
{t('common.create')}
+ {#each (enabledTemplates.length > 0 ? enabledTemplates : [{ id: '', title: t('template.optionNone'), icon: '' }]) as tpl} + + {/each} +
+ {/if} + + + +
+
+ {/if} + {#if showCreateAction}