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:
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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": ["*"]
|
||||
}
|
||||
]
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user