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 "" } }