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
+12 -5
View File
@@ -295,7 +295,7 @@ func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error
return nil, fmt.Errorf("invalid filename: %w", err)
}
filename = s.uniqueTitle(parentID, filename)
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
node, err := s.nodes.Create(strPtr(parentID), nodes.TypeFile, filename, 0, "", "")
if err != nil {
return nil, err
}
@@ -329,7 +329,7 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
parentID = *original.ParentID
}
newName := s.copyTitle(parentID, original.Title)
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
node, err := s.nodes.Create(strPtr(parentID), original.Type, newName, 0, "", "")
if err != nil {
return nil, err
}
@@ -449,7 +449,7 @@ func (s *Service) importPath(parentID, sourcePath string, copyMode bool) ([]node
}
if !info.IsDir() {
title := s.uniqueTitle(parentID, filepath.Base(sourcePath))
node, err := s.nodes.Create(parentID, nodes.TypeFile, title, "")
node, err := s.nodes.Create(strPtr(parentID), nodes.TypeFile, title, 0, "", "")
if err != nil {
return nil, err
}
@@ -468,7 +468,7 @@ func (s *Service) importPath(parentID, sourcePath string, copyMode bool) ([]node
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, "")
folderNode, err := s.nodes.Create(strPtr(parentID), nodes.TypeFolder, dirName, 0, "", "")
if err != nil {
return nil, err
}
@@ -490,7 +490,7 @@ func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool)
}
all = append(all, children...)
} else {
childNode, err := s.nodes.Create(folderNode.ID, nodes.TypeFile, entry.Name(), "")
childNode, err := s.nodes.Create(strPtr(folderNode.ID), nodes.TypeFile, entry.Name(), 0, "", "")
if err != nil {
return nil, err
}
@@ -680,3 +680,10 @@ func scanRecords(rows *sql.Rows) ([]Record, error) {
}
return out, rows.Err()
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
+6 -6
View File
@@ -147,7 +147,7 @@ func TestAddPathCopySingleFile(t *testing.T) {
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
parent, _ := nodeRepo.Create(nil, "case", "Test Case", 0, "", "")
src := filepath.Join(t.TempDir(), "doc.pdf")
os.WriteFile(src, []byte("file content"), 0o640)
@@ -178,7 +178,7 @@ func TestAddPathLinkSingleFile(t *testing.T) {
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
parent, _ := nodeRepo.Create(nil, "case", "Test Case", 0, "", "")
src := filepath.Join(t.TempDir(), "linked.pdf")
os.WriteFile(src, []byte("linked"), 0o640)
@@ -205,7 +205,7 @@ func TestAddPathCopyDirectory(t *testing.T) {
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
parent, _ := nodeRepo.Create(nil, "case", "Test Case", 0, "", "")
srcDir := t.TempDir()
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0o640)
@@ -242,8 +242,8 @@ func TestDeleteNodeAndChildren(t *testing.T) {
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "To Delete", "")
child, _ := nodeRepo.Create(parent.ID, "file", "child.txt", "")
parent, _ := nodeRepo.Create(nil, "case", "To Delete", 0, "", "")
child, _ := nodeRepo.Create(&parent.ID, "file", "child.txt", 0, "", "")
// Add file record to child.
src := filepath.Join(t.TempDir(), "child.txt")
os.WriteFile(src, []byte("data"), 0o640)
@@ -268,7 +268,7 @@ func TestNameConflict(t *testing.T) {
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "Test", "")
parent, _ := nodeRepo.Create(nil, "case", "Test", 0, "", "")
src := filepath.Join(t.TempDir(), "conflict.pdf")
os.WriteFile(src, []byte("data"), 0o640)