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:
+55
-45
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user