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
+134
View File
@@ -0,0 +1,134 @@
package templates
import (
"encoding/json"
"fmt"
"sort"
"sync"
)
// Registry holds all available templates (system + user overrides).
type Registry struct {
mu sync.RWMutex
templates map[string]*Template
}
func NewRegistry() *Registry {
return &Registry{templates: make(map[string]*Template)}
}
// LoadSystem reads system templates from embedded JSON.
func (r *Registry) LoadSystem() error {
data, err := systemTemplatesFS.ReadFile("system_templates.json")
if err != nil {
return err
}
var sysTemplates []Template
if err := json.Unmarshal(data, &sysTemplates); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
for _, t := range sysTemplates {
cp := t
r.templates[t.ID] = &cp
}
return nil
}
// Get returns a template by ID.
func (r *Registry) Get(id string) (*Template, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.templates[id]
return t, ok
}
// Enabled returns all enabled templates sorted by type+id.
func (r *Registry) Enabled() []*Template {
r.mu.RLock()
defer r.mu.RUnlock()
var result []*Template
for _, t := range r.templates {
if t.Enabled {
result = append(result, t)
}
}
sort.Slice(result, func(i, j int) bool {
if result[i].Type != result[j].Type {
return result[i].Type < result[j].Type
}
return result[i].ID < result[j].ID
})
return result
}
// All returns all registered templates sorted by type+id.
func (r *Registry) All() []*Template {
r.mu.RLock()
defer r.mu.RUnlock()
return sortedCopy(r.templates)
}
func sortedCopy(templates map[string]*Template) []*Template {
result := make([]*Template, 0, len(templates))
for _, t := range templates {
result = append(result, t)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Type != result[j].Type {
return result[i].Type < result[j].Type
}
return result[i].ID < result[j].ID
})
return result
}
// EnabledForParent returns templates that are allowed for a given parent type.
func (r *Registry) EnabledForParent(parentType string) []*Template {
r.mu.RLock()
defer r.mu.RUnlock()
var result []*Template
for _, t := range r.templates {
if !t.Enabled {
continue
}
for _, allowed := range t.AllowedParentTypes {
if allowed == "*" || allowed == parentType {
result = append(result, t)
break
}
}
}
sort.Slice(result, func(i, j int) bool {
if result[i].Type != result[j].Type {
return result[i].Type < result[j].Type
}
return result[i].ID < result[j].ID
})
return result
}
// Enable enables a template by ID.
func (r *Registry) Enable(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
t, ok := r.templates[id]
if !ok {
return fmt.Errorf("template %q not found", id)
}
t.Enabled = true
return nil
}
// Disable disables a template by ID.
func (r *Registry) Disable(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
t, ok := r.templates[id]
if !ok {
return fmt.Errorf("template %q not found", id)
}
t.Enabled = false
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package templates
import (
"testing"
)
func TestNewRegistry(t *testing.T) {
r := NewRegistry()
if r == nil {
t.Fatal("expected non-nil registry")
}
}
func TestLoadSystem(t *testing.T) {
r := NewRegistry()
if err := r.LoadSystem(); err != nil {
t.Fatalf("LoadSystem: %v", err)
}
// Check we have system templates
templates := r.All()
if len(templates) == 0 {
t.Fatal("expected at least one system template")
}
}
func TestEnabled(t *testing.T) {
r := NewRegistry()
if err := r.LoadSystem(); err != nil {
t.Fatalf("LoadSystem: %v", err)
}
enabled := r.Enabled()
if len(enabled) == 0 {
t.Fatal("expected at least one enabled template")
}
}
func TestGet(t *testing.T) {
r := NewRegistry()
if err := r.LoadSystem(); err != nil {
t.Fatalf("LoadSystem: %v", err)
}
tmpl, ok := r.Get("folder.default")
if !ok {
t.Fatal("expected to find folder.default template")
}
if tmpl.Type != "folder" {
t.Errorf("expected type 'folder', got %q", tmpl.Type)
}
if !tmpl.Enabled {
t.Error("expected folder.default to be enabled")
}
}
func TestEnabledForParent(t *testing.T) {
r := NewRegistry()
if err := r.LoadSystem(); err != nil {
t.Fatalf("LoadSystem: %v", err)
}
// folder template should be allowed in "root"
forParent := r.EnabledForParent("root")
if len(forParent) == 0 {
t.Fatal("expected templates for parent type 'root'")
}
// All templates should be allowed for root
all := r.Enabled()
if len(forParent) != len(all) {
t.Errorf("expected %d templates for root, got %d", len(all), len(forParent))
}
}
+62
View File
@@ -0,0 +1,62 @@
package templates
import (
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
)
// SafeDisplayNameToPathSegment converts a user-provided title to a safe
// filesystem path segment. It preserves human readability (Cyrillic, spaces)
// but removes or replaces characters illegal in filenames.
//
// If the resulting path would collide with an existing entry, callers should
// append a numeric suffix like " (2)".
func SafeDisplayNameToPathSegment(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "Без названия"
}
var result strings.Builder
for _, r := range title {
switch {
case r == '/' || r == '\\':
result.WriteRune('_')
case r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|':
result.WriteRune(' ')
case unicode.IsControl(r):
case r == '.' && result.Len() == 0:
result.WriteRune('_')
default:
result.WriteRune(r)
}
}
seg := strings.TrimSpace(result.String())
if seg == "" {
seg = "Без названия"
}
if len(seg) > 200 {
seg = seg[:200]
}
return seg
}
// UniquePath returns a unique path by appending a numeric suffix if needed.
func UniquePath(basePath string) string {
if _, err := os.Stat(basePath); os.IsNotExist(err) {
return basePath
}
ext := filepath.Ext(basePath)
stem := strings.TrimSuffix(basePath, ext)
for i := 2; i < 1000; i++ {
candidate := fmt.Sprintf("%s (%d)%s", stem, i, ext)
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
}
return fmt.Sprintf("%s_%d%s", stem, 1000, ext)
}
+41
View File
@@ -0,0 +1,41 @@
package templates
import (
"testing"
)
func TestSafeDisplayNameToPathSegment(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"Разработка серверной", "Разработка серверной"},
{"Проект/Подпроект", "Проект_Подпроект"},
{"File:Name*Test?\"Test", "File Name Test Test"},
{"../../evil", "_._.._evil"},
{".hidden", "_hidden"},
{" spaced ", "spaced"},
{"", "Без названия"},
{"AB", "AB"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := SafeDisplayNameToPathSegment(tt.input)
if got != tt.expected {
t.Errorf("SafeDisplayNameToPathSegment(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestSafeDisplayNameToPathSegment_Long(t *testing.T) {
long := ""
for i := 0; i < 300; i++ {
long += "a"
}
got := SafeDisplayNameToPathSegment(long)
if len(got) > 200 {
t.Errorf("expected max 200 chars, got %d", len(got))
}
}
+21
View File
@@ -0,0 +1,21 @@
package templates
import (
"encoding/json"
"embed"
)
//go:embed system_templates.json
var systemTemplatesFS embed.FS
func SystemTemplates() ([]Template, error) {
data, err := systemTemplatesFS.ReadFile("system_templates.json")
if err != nil {
return nil, err
}
var templates []Template
if err := json.Unmarshal(data, &templates); err != nil {
return nil, err
}
return templates, nil
}
@@ -0,0 +1,67 @@
[
{
"id": "folder.default",
"title": "template.folder",
"type": "folder",
"enabled": true,
"system": true,
"icon": "folder",
"default_modules": ["overview", "children", "activity"],
"default_folders": [],
"default_files": [],
"allowed_parent_types": ["folder", "project", "client", "root"],
"allowed_child_templates": ["*"]
},
{
"id": "project.default",
"title": "template.project",
"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": ["*"]
},
{
"id": "client.default",
"title": "template.client",
"type": "client",
"enabled": true,
"system": true,
"icon": "client",
"default_modules": ["overview", "notes", "files", "activity", "actions"],
"default_files": [{"path": "Overview.md", "content_template": "client_overview"}],
"default_folders": ["Notes", "Files"],
"allowed_parent_types": ["folder", "root"],
"allowed_child_templates": ["*"]
},
{
"id": "document.default",
"title": "template.document",
"type": "document",
"enabled": true,
"system": true,
"icon": "document",
"default_modules": ["overview", "files", "activity"],
"default_files": [],
"default_folders": [],
"allowed_parent_types": ["folder", "project", "client", "root"],
"allowed_child_templates": ["*"]
},
{
"id": "recipe.default",
"title": "template.recipe",
"type": "recipe",
"enabled": true,
"system": true,
"icon": "recipe",
"default_modules": ["overview", "notes", "files", "activity"],
"default_files": [{"path": "Overview.md", "content_template": "recipe_overview"}],
"default_folders": [],
"allowed_parent_types": ["folder", "root"],
"allowed_child_templates": ["*"]
}
]
+20
View File
@@ -0,0 +1,20 @@
package templates
type Template struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
System bool `json:"system"`
Icon string `json:"icon,omitempty"`
DefaultModules []string `json:"default_modules,omitempty"`
DefaultFiles []FileTemplate `json:"default_files,omitempty"`
DefaultFolders []string `json:"default_folders,omitempty"`
AllowedParentTypes []string `json:"allowed_parent_types,omitempty"`
AllowedChildTemplates []string `json:"allowed_child_templates,omitempty"`
}
type FileTemplate struct {
Path string `json:"path"`
ContentTemplate string `json:"content_template,omitempty"`
}