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.
This commit is contained in:
2026-06-02 12:47:06 +08:00
parent 12f2916a24
commit 0b26f7e5b3
37 changed files with 1479 additions and 338 deletions
+55 -45
View File
@@ -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
}
+7 -7
View File
@@ -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")},
}
}
+169 -13
View File
@@ -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
}
+7 -12
View File
@@ -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
}
+18 -11
View File
@@ -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{
+6 -6
View File
@@ -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
+134
View File
@@ -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 ""
}
}