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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+15
-13
@@ -7,19 +7,21 @@ import (
|
||||
// Node is the central entity of Verstak — a tree item that can be
|
||||
// a case, folder, note, document, etc.
|
||||
type Node struct {
|
||||
ID string `json:"id"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
Section string `json:"section,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
Revision int `json:"revision"`
|
||||
DeviceID *string `json:"device_id,omitempty"`
|
||||
ID string `json:"id"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
TemplateID string `json:"template_id"`
|
||||
FsPath string `json:"fs_path"`
|
||||
Section string `json:"section,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Archived bool `json:"archived"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
Revision int `json:"revision"`
|
||||
DeviceID *string `json:"device_id,omitempty"`
|
||||
}
|
||||
|
||||
// IsDeleted reports whether the node has been soft-deleted.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package nodes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNodeIsRoot(t *testing.T) {
|
||||
n := &Node{ID: "1", ParentID: nil}
|
||||
if !n.IsRoot() {
|
||||
t.Error("expected node with nil parent to be root")
|
||||
}
|
||||
|
||||
pid := "parent"
|
||||
n2 := &Node{ID: "2", ParentID: &pid}
|
||||
if n2.IsRoot() {
|
||||
t.Error("expected node with parent to not be root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeIsDeleted(t *testing.T) {
|
||||
n := &Node{ID: "1", DeletedAt: nil}
|
||||
if n.IsDeleted() {
|
||||
t.Error("expected node with nil DeletedAt to not be deleted")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
n2 := &Node{ID: "2", DeletedAt: &now}
|
||||
if !n2.IsDeleted() {
|
||||
t.Error("expected node with DeletedAt set to be deleted")
|
||||
}
|
||||
}
|
||||
@@ -50,43 +50,40 @@ func now() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// Create inserts a root or child node.
|
||||
// parentID may be empty for root-level nodes.
|
||||
// For root nodes, section determines sidebar placement (may be empty = inbox).
|
||||
// section must be a valid section (clients, projects, etc.) or empty for inbox.
|
||||
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
|
||||
// columns used in all SELECT queries.
|
||||
var nodeColumns = "id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,created_at,updated_at,deleted_at,revision,device_id"
|
||||
|
||||
// Create inserts a node. parentID may be nil for root-level nodes.
|
||||
func (r *Repository) Create(parentID *string, typ, title string, sortOrder int, templateID, fsPath string) (*Node, error) {
|
||||
if !IsValidType(typ) {
|
||||
return nil, fmt.Errorf("invalid node type: %s", typ)
|
||||
}
|
||||
if title == "" {
|
||||
return nil, errors.New("title is required")
|
||||
}
|
||||
if section != "" && !IsValidSection(section) {
|
||||
return nil, fmt.Errorf("invalid section: %s", section)
|
||||
}
|
||||
|
||||
n := &Node{
|
||||
ID: util.UUID7(),
|
||||
Type: typ,
|
||||
Title: title,
|
||||
Slug: Slugify(title),
|
||||
Section: section,
|
||||
SortOrder: 0,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
Revision: 1,
|
||||
ID: util.UUID7(),
|
||||
Type: typ,
|
||||
Title: title,
|
||||
Slug: Slugify(title),
|
||||
TemplateID: templateID,
|
||||
FsPath: fsPath,
|
||||
SortOrder: sortOrder,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
Revision: 1,
|
||||
}
|
||||
if parentID != "" {
|
||||
n.ParentID = &parentID
|
||||
if parentID != nil {
|
||||
n.ParentID = parentID
|
||||
}
|
||||
|
||||
err := r.insertNode(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Bump parent's updated_at so it appears in today view.
|
||||
if parentID != "" {
|
||||
_ = r.touch(parentID)
|
||||
if parentID != nil {
|
||||
_ = r.touch(*parentID)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -105,17 +102,13 @@ func (r *Repository) insertNode(n *Node) error {
|
||||
if n.ParentID != nil {
|
||||
parent = *n.ParentID
|
||||
}
|
||||
var sec interface{}
|
||||
if n.Section != "" {
|
||||
sec = n.Section
|
||||
}
|
||||
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO nodes (id,parent_id,type,title,slug,path,section,sort_order,
|
||||
`INSERT INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,
|
||||
created_at,updated_at,deleted_at,revision,device_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
n.ID, parent, n.Type, n.Title, n.Slug, n.Path, sec,
|
||||
n.SortOrder, n.CreatedAt.Format(time.RFC3339), n.UpdatedAt.Format(time.RFC3339),
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
n.ID, parent, n.Type, n.Title, n.Slug, n.TemplateID, n.FsPath, n.Section,
|
||||
n.SortOrder, n.Archived, n.CreatedAt.Format(time.RFC3339), n.UpdatedAt.Format(time.RFC3339),
|
||||
n.DeletedAt, n.Revision, n.DeviceID,
|
||||
)
|
||||
return err
|
||||
@@ -124,9 +117,7 @@ func (r *Repository) insertNode(n *Node) error {
|
||||
// Get returns a plain node (even if soft-deleted).
|
||||
func (r *Repository) Get(id string) (*Node, error) {
|
||||
row := r.db.QueryRow(
|
||||
`SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
||||
created_at,updated_at,deleted_at,revision,device_id
|
||||
FROM nodes WHERE id = ?`, id)
|
||||
`SELECT `+nodeColumns+` FROM nodes WHERE id = ?`, id)
|
||||
return scanNode(row)
|
||||
}
|
||||
|
||||
@@ -145,9 +136,7 @@ func (r *Repository) GetActive(id string) (*Node, error) {
|
||||
// ListChildren returns direct children ordered by sort_order, then title.
|
||||
// IncludeDeleted lists soft-deleted children too.
|
||||
func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node, error) {
|
||||
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
||||
created_at,updated_at,deleted_at,revision,device_id
|
||||
FROM nodes WHERE parent_id = ?`
|
||||
q := `SELECT ` + nodeColumns + ` FROM nodes WHERE parent_id = ?`
|
||||
if !includeDeleted {
|
||||
q += " AND deleted_at IS NULL"
|
||||
}
|
||||
@@ -162,29 +151,14 @@ func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node,
|
||||
}
|
||||
|
||||
// ListRoots returns nodes with no parent (top-level).
|
||||
// When section is set, only returns roots with that exact section
|
||||
// (or section IS NULL when section="inbox").
|
||||
func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, error) {
|
||||
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
||||
created_at,updated_at,deleted_at,revision,device_id
|
||||
FROM nodes WHERE parent_id IS NULL`
|
||||
if section == "inbox" {
|
||||
q += " AND section IS NULL"
|
||||
} else if section != "" {
|
||||
q += " AND section = ?"
|
||||
}
|
||||
func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) {
|
||||
q := `SELECT ` + nodeColumns + ` FROM nodes WHERE parent_id IS NULL`
|
||||
if !includeDeleted {
|
||||
q += " AND deleted_at IS NULL"
|
||||
}
|
||||
q += " ORDER BY sort_order, title"
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if section != "" && section != "inbox" {
|
||||
rows, err = r.db.Query(q, section)
|
||||
} else {
|
||||
rows, err = r.db.Query(q)
|
||||
}
|
||||
rows, err := r.db.Query(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -192,6 +166,26 @@ func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, err
|
||||
return scanNodes(rows)
|
||||
}
|
||||
|
||||
// ListByParent returns children as *Node pointers. parentID must not be empty.
|
||||
func (r *Repository) ListByParent(parentID string) ([]*Node, error) {
|
||||
rows, err := r.db.Query(
|
||||
`SELECT `+nodeColumns+` FROM nodes WHERE parent_id = ? AND deleted_at IS NULL ORDER BY sort_order, title`, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*Node
|
||||
for rows.Next() {
|
||||
n, err := scanNode(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// todayBoundaries returns RFC3339 start and end strings for the current day
|
||||
// in UTC, so string comparison against UTC-stored DB timestamps is correct.
|
||||
func todayBoundaries() (string, string) {
|
||||
@@ -203,14 +197,9 @@ func todayBoundaries() (string, string) {
|
||||
}
|
||||
|
||||
// ListTodayNodes returns active root-level nodes created or updated today.
|
||||
// This is a dynamic view, not a section — it shows the day's activity.
|
||||
// Child nodes (notes, files, folders) are not listed directly; instead,
|
||||
// their parent is bumped via touch() on creation.
|
||||
func (r *Repository) ListTodayNodes() ([]Node, error) {
|
||||
start, end := todayBoundaries()
|
||||
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
||||
created_at,updated_at,deleted_at,revision,device_id
|
||||
FROM nodes
|
||||
q := `SELECT ` + nodeColumns + ` FROM nodes
|
||||
WHERE deleted_at IS NULL
|
||||
AND parent_id IS NULL
|
||||
AND (
|
||||
@@ -248,12 +237,47 @@ func (r *Repository) UpdateTitle(id, title string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateFsPath updates the fs_path of a single node.
|
||||
func (r *Repository) UpdateFsPath(id, fsPath string) error {
|
||||
t := now()
|
||||
res, err := r.db.Exec(
|
||||
`UPDATE nodes SET fs_path=?, updated_at=?, revision=revision+1
|
||||
WHERE id=? AND deleted_at IS NULL`,
|
||||
fsPath, t, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateFsPathRecursive updates fs_path for a node and all its descendants.
|
||||
func (r *Repository) UpdateFsPathRecursive(id, newFsPath string) error {
|
||||
if err := r.UpdateFsPath(id, newFsPath); err != nil {
|
||||
return err
|
||||
}
|
||||
children, err := r.ListChildren(id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, child := range children {
|
||||
childPath := newFsPath + "/" + child.Slug
|
||||
if err := r.UpdateFsPathRecursive(child.ID, childPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Move changes the parent and/or sort order of a node.
|
||||
// parentID="" means move to root.
|
||||
func (r *Repository) Move(id, parentID string, sortOrder int) error {
|
||||
// newParentID = nil means move to root.
|
||||
func (r *Repository) Move(id string, newParentID *string, sortOrder int) error {
|
||||
var parent interface{}
|
||||
if parentID != "" {
|
||||
parent = parentID
|
||||
if newParentID != nil {
|
||||
parent = *newParentID
|
||||
}
|
||||
t := now()
|
||||
res, err := r.db.Exec(
|
||||
@@ -270,6 +294,23 @@ func (r *Repository) Move(id, parentID string, sortOrder int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetArchived sets the archived flag on a node.
|
||||
func (r *Repository) SetArchived(id string, archived bool) error {
|
||||
t := now()
|
||||
res, err := r.db.Exec(
|
||||
`UPDATE nodes SET archived=?, updated_at=?, revision=revision+1
|
||||
WHERE id=? AND deleted_at IS NULL`,
|
||||
archived, t, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SoftDelete marks a node as deleted (does not touch its children).
|
||||
func (r *Repository) SoftDelete(id string) error {
|
||||
t := now()
|
||||
@@ -338,12 +379,13 @@ type scanner interface {
|
||||
|
||||
func scanNode(s scanner) (*Node, error) {
|
||||
var n Node
|
||||
var parentID, path, section, deletedAt, deviceID sql.NullString
|
||||
var parentID, templateID, fsPath, section, deletedAt, deviceID sql.NullString
|
||||
var archived int
|
||||
var createdStr, updatedStr string
|
||||
|
||||
err := s.Scan(
|
||||
&n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &path, §ion,
|
||||
&n.SortOrder, &createdStr, &updatedStr, &deletedAt,
|
||||
&n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &templateID, &fsPath,
|
||||
§ion, &n.SortOrder, &archived, &createdStr, &updatedStr, &deletedAt,
|
||||
&n.Revision, &deviceID,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -356,8 +398,11 @@ func scanNode(s scanner) (*Node, error) {
|
||||
if parentID.Valid {
|
||||
n.ParentID = &parentID.String
|
||||
}
|
||||
if path.Valid {
|
||||
n.Path = &path.String
|
||||
if templateID.Valid {
|
||||
n.TemplateID = templateID.String
|
||||
}
|
||||
if fsPath.Valid {
|
||||
n.FsPath = fsPath.String
|
||||
}
|
||||
if section.Valid {
|
||||
n.Section = section.String
|
||||
@@ -369,6 +414,7 @@ func scanNode(s scanner) (*Node, error) {
|
||||
if deviceID.Valid {
|
||||
n.DeviceID = &deviceID.String
|
||||
}
|
||||
n.Archived = archived != 0
|
||||
|
||||
n.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||
n.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||
|
||||
@@ -19,6 +19,8 @@ func openTestDB(t *testing.T) *storage.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func nodePtr(s string) *string { return &s }
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"ООО Ромашка", "ооо-ромашка"},
|
||||
@@ -40,7 +42,7 @@ func TestCreateAndGet(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
n, err := repo.Create("", TypeCase, "Test Case", "")
|
||||
n, err := repo.Create(nil, TypeCase, "Test Case", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
@@ -69,12 +71,12 @@ func TestCreateChild(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
parent, err := repo.Create("", TypeFolder, "Folder", "")
|
||||
parent, err := repo.Create(nil, TypeFolder, "Folder", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
child, err := repo.Create(parent.ID, TypeCase, "Child", "")
|
||||
child, err := repo.Create(nodePtr(parent.ID), TypeCase, "Child", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -87,9 +89,9 @@ func TestListChildren(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
parent, _ := repo.Create("", TypeFolder, "Folder", "")
|
||||
repo.Create(parent.ID, TypeCase, "A", "")
|
||||
repo.Create(parent.ID, TypeCase, "B", "")
|
||||
parent, _ := repo.Create(nil, TypeFolder, "Folder", 0, "", "")
|
||||
repo.Create(nodePtr(parent.ID), TypeCase, "A", 0, "", "")
|
||||
repo.Create(nodePtr(parent.ID), TypeCase, "B", 0, "", "")
|
||||
|
||||
children, err := repo.ListChildren(parent.ID, false)
|
||||
if err != nil {
|
||||
@@ -108,10 +110,10 @@ func TestListRoots(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
repo.Create("", TypeCase, "One", "")
|
||||
repo.Create("", TypeCase, "Two", "")
|
||||
repo.Create(nil, TypeCase, "One", 0, "", "")
|
||||
repo.Create(nil, TypeCase, "Two", 0, "", "")
|
||||
|
||||
roots, err := repo.ListRoots(false, "")
|
||||
roots, err := repo.ListRoots(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -124,7 +126,7 @@ func TestUpdateTitle(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
n, _ := repo.Create("", TypeCase, "Old", "")
|
||||
n, _ := repo.Create(nil, TypeCase, "Old", 0, "", "")
|
||||
if err := repo.UpdateTitle(n.ID, "New Title"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -142,12 +144,12 @@ func TestMove(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
a, _ := repo.Create("", TypeFolder, "A", "")
|
||||
b, _ := repo.Create("", TypeFolder, "B", "")
|
||||
child, _ := repo.Create(a.ID, TypeCase, "Child", "")
|
||||
a, _ := repo.Create(nil, TypeFolder, "A", 0, "", "")
|
||||
b, _ := repo.Create(nil, TypeFolder, "B", 0, "", "")
|
||||
child, _ := repo.Create(nodePtr(a.ID), TypeCase, "Child", 0, "", "")
|
||||
|
||||
// Move child from A to B.
|
||||
if err := repo.Move(child.ID, b.ID, 0); err != nil {
|
||||
if err := repo.Move(child.ID, nodePtr(b.ID), 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -157,7 +159,7 @@ func TestMove(t *testing.T) {
|
||||
}
|
||||
|
||||
// Move to root.
|
||||
if err := repo.Move(child.ID, "", 0); err != nil {
|
||||
if err := repo.Move(child.ID, nil, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got2, _ := repo.Get(child.ID)
|
||||
@@ -170,7 +172,7 @@ func TestSoftDelete(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
n, _ := repo.Create("", TypeCase, "To Delete", "")
|
||||
n, _ := repo.Create(nil, TypeCase, "To Delete", 0, "", "")
|
||||
if err := repo.SoftDelete(n.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -189,8 +191,8 @@ func TestSoftDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
// ListChildren without includeDeleted must skip it.
|
||||
parent, _ := repo.Create("", TypeFolder, "P", "")
|
||||
child, _ := repo.Create(parent.ID, TypeCase, "Kid", "")
|
||||
parent, _ := repo.Create(nil, TypeFolder, "P", 0, "", "")
|
||||
child, _ := repo.Create(nodePtr(parent.ID), TypeCase, "Kid", 0, "", "")
|
||||
repo.SoftDelete(child.ID)
|
||||
|
||||
kids, _ := repo.ListChildren(parent.ID, false)
|
||||
@@ -208,7 +210,7 @@ func TestMetaKV(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
n, _ := repo.Create("", TypeCase, "M", "")
|
||||
n, _ := repo.Create(nil, TypeCase, "M", 0, "", "")
|
||||
if err := repo.MetaSet(n.ID, "status", "active"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -249,7 +251,7 @@ func TestNotFound(t *testing.T) {
|
||||
if err := repo.SoftDelete("nonexistent"); err != ErrNotFound {
|
||||
t.Errorf("SoftDelete returned %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := repo.Move("nonexistent", "", 0); err != ErrNotFound {
|
||||
if err := repo.Move("nonexistent", nil, 0); err != ErrNotFound {
|
||||
t.Errorf("Move returned %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -266,7 +268,7 @@ func TestInitEndToEnd(t *testing.T) {
|
||||
defer db.Close()
|
||||
|
||||
repo := NewRepository(db)
|
||||
n, err := repo.Create("", TypeCase, "Integration Case", "")
|
||||
n, err := repo.Create(nil, TypeCase, "Integration Case", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -5,16 +5,18 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Valid node types.
|
||||
// Node types.
|
||||
const (
|
||||
TypeFolder = "folder"
|
||||
TypeProject = "project"
|
||||
TypeClient = "client"
|
||||
TypeDocument = "document"
|
||||
TypeRecipe = "recipe"
|
||||
TypeSpace = "space"
|
||||
TypeCase = "case"
|
||||
TypeFolder = "folder"
|
||||
TypeNote = "note"
|
||||
TypeDocument = "document"
|
||||
TypeFile = "file"
|
||||
TypeAction = "action"
|
||||
TypeRecipe = "recipe"
|
||||
TypeSecret = "secret"
|
||||
TypeWorklog = "worklog"
|
||||
TypeLink = "link"
|
||||
@@ -22,34 +24,24 @@ const (
|
||||
|
||||
// TypeSet for quick validation.
|
||||
var TypeSet = map[string]struct{}{
|
||||
TypeFolder: {},
|
||||
TypeProject: {},
|
||||
TypeClient: {},
|
||||
TypeDocument: {},
|
||||
TypeRecipe: {},
|
||||
TypeSpace: {},
|
||||
TypeCase: {},
|
||||
TypeFolder: {},
|
||||
TypeNote: {},
|
||||
TypeDocument: {},
|
||||
TypeFile: {},
|
||||
TypeAction: {},
|
||||
TypeRecipe: {},
|
||||
TypeSecret: {},
|
||||
TypeWorklog: {},
|
||||
TypeLink: {},
|
||||
}
|
||||
|
||||
// Valid sections for root-level nodes.
|
||||
// today and inbox are service sections, not stored in nodes.section.
|
||||
var validSections = map[string]struct{}{
|
||||
"clients": {},
|
||||
"projects": {},
|
||||
"recipes": {},
|
||||
"documents": {},
|
||||
"archive": {},
|
||||
}
|
||||
|
||||
// serviceSections are sidebar entries that are not stored as node sections.
|
||||
var serviceSections = map[string]struct{}{
|
||||
"today": {},
|
||||
"inbox": {},
|
||||
"activity": {},
|
||||
// RootTypes returns the node types that can appear at workspace root.
|
||||
func RootTypes() []string {
|
||||
return []string{TypeFolder, TypeProject, TypeClient, TypeDocument, TypeRecipe, TypeSpace, TypeCase}
|
||||
}
|
||||
|
||||
// IsValidType checks whether a type string is recognized.
|
||||
@@ -58,18 +50,6 @@ func IsValidType(t string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsValidSection returns true for sections that can be stored on a node.
|
||||
func IsValidSection(s string) bool {
|
||||
_, ok := validSections[s]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsServiceSection returns true for sidebar-only sections (today, inbox).
|
||||
func IsServiceSection(s string) bool {
|
||||
_, ok := serviceSections[s]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Slugify converts a title into a filesystem-safe slug.
|
||||
// Examples:
|
||||
//
|
||||
|
||||
@@ -35,7 +35,7 @@ func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository, fi
|
||||
|
||||
// Create makes a new note node, an empty .md file, and links them.
|
||||
func (s *Service) Create(parentID, title, section string) (*nodes.Node, *files.Record, error) {
|
||||
node, err := s.nodes.Create(parentID, nodes.TypeNote, title, section)
|
||||
node, err := s.nodes.Create(strPtr(parentID), nodes.TypeNote, title, 0, "", "")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create node: %w", err)
|
||||
}
|
||||
@@ -201,3 +201,10 @@ func mustRead(path string) []byte {
|
||||
func utcNow() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ func TestMVPSmoke(t *testing.T) {
|
||||
searchSvc := search.NewService(db)
|
||||
|
||||
// 2. Create client case structure.
|
||||
client, err := nodeRepo.Create("", nodes.TypeCase, "ООО Ромашка", "clients")
|
||||
client, err := nodeRepo.Create(nil, nodes.TypeCase, "ООО Ромашка", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
project, err := nodeRepo.Create(client.ID, nodes.TypeCase, "Сайт", "")
|
||||
project, err := nodeRepo.Create(&client.ID, nodes.TypeCase, "Сайт", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
@@ -193,9 +193,9 @@ func TestMVPSmoke(t *testing.T) {
|
||||
}
|
||||
|
||||
// 16. Verify section filtering.
|
||||
roots, err := nodeRepo.ListRoots(false, "clients")
|
||||
roots, err := nodeRepo.ListRoots(false)
|
||||
if err != nil {
|
||||
t.Fatalf("list roots by section: %v", err)
|
||||
t.Fatalf("list roots: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, r := range roots {
|
||||
@@ -205,7 +205,7 @@ func TestMVPSmoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("client not found in section 'clients'")
|
||||
t.Error("client not found in roots")
|
||||
}
|
||||
|
||||
// 17. Soft delete node and verify.
|
||||
@@ -251,7 +251,7 @@ func TestMVPSmoke_WorklogReport(t *testing.T) {
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
worklogSvc := worklog.NewService(db)
|
||||
|
||||
n, err := nodeRepo.Create("", nodes.TypeCase, "Test", "")
|
||||
n, err := nodeRepo.Create(nil, nodes.TypeCase, "Test", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package storage
|
||||
|
||||
// migration012 — add template_id, fs_path, archived columns to nodes.
|
||||
const migration012 = `
|
||||
ALTER TABLE nodes ADD COLUMN template_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE nodes ADD COLUMN fs_path TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE nodes ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
|
||||
`
|
||||
@@ -68,6 +68,7 @@ var migrationFiles = map[int]string{
|
||||
9: migration009,
|
||||
10: migration010,
|
||||
11: migration011,
|
||||
12: migration012,
|
||||
}
|
||||
|
||||
func (db *DB) runInitialSchema() error {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
+14
-8
@@ -107,6 +107,13 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
t.Execute(w, nil)
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func jsonOK(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
@@ -118,16 +125,15 @@ func jsonErr(w http.ResponseWriter, code int, msg string) {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// GET /api/nodes[?parent=ID§ion=X] POST /api/nodes
|
||||
// GET /api/nodes[?parent=ID] POST /api/nodes
|
||||
func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
parent := r.URL.Query().Get("parent")
|
||||
section := r.URL.Query().Get("section")
|
||||
var list interface{}
|
||||
var err error
|
||||
if parent == "" {
|
||||
list, err = s.nodes.ListRoots(false, section)
|
||||
list, err = s.nodes.ListRoots(false)
|
||||
} else {
|
||||
list, err = s.nodes.ListChildren(parent, false)
|
||||
}
|
||||
@@ -147,7 +153,7 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
n, err := s.nodes.Create(req.ParentID, req.Type, req.Title, req.Section)
|
||||
n, err := s.nodes.Create(strPtr(req.ParentID), req.Type, req.Title, 0, "", "")
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -187,7 +193,7 @@ func (s *Server) handleNodeFromTemplate(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Create root node.
|
||||
root, err := s.nodes.Create(req.ParentID, tmpl.RootType, req.Title, req.Section)
|
||||
root, err := s.nodes.Create(strPtr(req.ParentID), tmpl.RootType, req.Title, 0, "", "")
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -197,7 +203,7 @@ func (s *Server) handleNodeFromTemplate(w http.ResponseWriter, r *http.Request)
|
||||
var createTree func(parentID string, nodes []plugins.TreeNode) error
|
||||
createTree = func(parentID string, nodes []plugins.TreeNode) error {
|
||||
for _, tn := range nodes {
|
||||
child, err := s.nodes.Create(parentID, tn.Type, tn.Title, "")
|
||||
child, err := s.nodes.Create(strPtr(parentID), tn.Type, tn.Title, 0, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -244,7 +250,7 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
|
||||
s.nodes.UpdateTitle(id, req.Title)
|
||||
}
|
||||
if req.ParentID != "" {
|
||||
s.nodes.Move(id, req.ParentID, req.Sort)
|
||||
s.nodes.Move(id, &req.ParentID, req.Sort)
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "ok"})
|
||||
case "DELETE":
|
||||
@@ -466,7 +472,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
results, err := s.search.Search(q)
|
||||
if err != nil || len(results) == 0 {
|
||||
// Fallback: search node titles directly.
|
||||
roots, _ := s.nodes.ListRoots(false, "")
|
||||
roots, _ := s.nodes.ListRoots(false)
|
||||
ql := strings.ToLower(q)
|
||||
for _, n := range roots {
|
||||
if strings.Contains(strings.ToLower(n.Title), ql) {
|
||||
|
||||
@@ -102,5 +102,25 @@
|
||||
"server.emailConfirmSubject": "Confirm your Verstak Sync account",
|
||||
"server.emailConfirmBody": "Welcome to Verstak Sync!\n\nPlease confirm your email by clicking:\n%s\n\nIf you did not register, ignore this message.",
|
||||
"server.emailResetSubject": "Verstak Sync password reset",
|
||||
"server.emailResetBody": "Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour."
|
||||
"server.emailResetBody": "Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour.",
|
||||
|
||||
"nav.system": "System",
|
||||
"nav.workspace": "Workspace",
|
||||
"nav.noNodes": "No nodes",
|
||||
"nav.openFolder": "Open folder",
|
||||
"nav.createInside": "Create inside",
|
||||
|
||||
"template.folder": "Folder",
|
||||
"template.project": "Project",
|
||||
"template.client": "Client",
|
||||
"template.document": "Document",
|
||||
"template.recipe": "Recipe",
|
||||
|
||||
"common.archive": "Archive",
|
||||
|
||||
"migrate.dryRun": "Dry run",
|
||||
"migrate.foldersCreated": "Folders created",
|
||||
"migrate.templatesSet": "Templates set",
|
||||
"migrate.errors": "Errors",
|
||||
"migrate.noNodes": "No nodes to migrate"
|
||||
}
|
||||
|
||||
@@ -384,5 +384,25 @@
|
||||
"server.emailConfirmSubject": "Подтверждение аккаунта Verstak Sync",
|
||||
"server.emailConfirmBody": "Добро пожаловать в Verstak Sync!\n\nПодтвердите email, перейдя по ссылке:\n%s\n\nЕсли вы не регистрировались, проигнорируйте это письмо.",
|
||||
"server.emailResetSubject": "Сброс пароля Verstak Sync",
|
||||
"server.emailResetBody": "Сброс пароля Verstak Sync:\n\n%s\n\nСсылка действительна 1 час."
|
||||
"server.emailResetBody": "Сброс пароля Verstak Sync:\n\n%s\n\nСсылка действительна 1 час.",
|
||||
|
||||
"nav.system": "Системное",
|
||||
"nav.workspace": "Рабочее пространство",
|
||||
"nav.noNodes": "Нет узлов",
|
||||
"nav.openFolder": "Открыть папку",
|
||||
"nav.createInside": "Создать внутри",
|
||||
|
||||
"template.folder": "Папка",
|
||||
"template.project": "Проект",
|
||||
"template.client": "Клиент",
|
||||
"template.document": "Документ",
|
||||
"template.recipe": "Рецепт",
|
||||
|
||||
"common.archive": "Архивировать",
|
||||
|
||||
"migrate.dryRun": "Пробный запуск",
|
||||
"migrate.foldersCreated": "Папок создано",
|
||||
"migrate.templatesSet": "Шаблонов назначено",
|
||||
"migrate.errors": "Ошибки",
|
||||
"migrate.noNodes": "Нет узлов для миграции"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user