From 4f01f2de2e0939dfffd6ebdb9aefbf2d2a4ee1df Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 2 Jun 2026 15:43:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20complete=20vault=20layout=20transition?= =?UTF-8?q?=20=E2=80=94=20fs=5Fpath=20everywhere,=20no=20more=20spaces/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notes.Create(): .md files stored in parent node's fs_path folder - files.CopyIntoVault/CreateEmptyFile/Duplicate: use parent fs_path - files.AddPathCopy/AddPathLink: use parent fs_path, set folder fs_path - files.DeleteNodeAndChildren: move physical folder to .verstak/trash - UpdateFsPathRecursive: use SafeDisplayNameToPathSegment(child.Title) - sync_apply.go note ops: use fs_path instead of spaces/ - internal/gui/server.go file upload: use n.FsPath instead of nodeSlug - VaultCheck diagnostic: walk nodes/files, verify paths on disk - Tests: create/rename/move/delete/name-conflict/vault-check all pass --- cmd/verstak-gui/bindings_nodes.go | 13 + cmd/verstak-gui/sync_apply.go | 11 +- cmd/verstak-gui/vault_check.go | 110 ++++++++ cmd/verstak-gui/vault_layout_test.go | 371 +++++++++++++++++++++++++++ internal/core/files/file.go | 61 ++++- internal/core/files/file_test.go | 6 +- internal/core/nodes/repository.go | 5 +- internal/core/notes/note.go | 31 ++- internal/core/notes/note_test.go | 15 +- internal/gui/server.go | 8 +- scripts/check-i18n.sh | 1 + 11 files changed, 599 insertions(+), 33 deletions(-) create mode 100644 cmd/verstak-gui/vault_check.go create mode 100644 cmd/verstak-gui/vault_layout_test.go diff --git a/cmd/verstak-gui/bindings_nodes.go b/cmd/verstak-gui/bindings_nodes.go index bb9727f..4341f53 100644 --- a/cmd/verstak-gui/bindings_nodes.go +++ b/cmd/verstak-gui/bindings_nodes.go @@ -66,6 +66,8 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD physPath := filepath.Join(a.vault, fsPath) physPath = templates.UniquePath(physPath) + rel, _ := filepath.Rel(a.vault, physPath) + fsPath = rel var pID *string if parentID != "" { @@ -139,6 +141,17 @@ func (a *App) DeleteNode(id string) error { } _ = a.activity.Record(pid, targetType, id, "", evType, n.Title, "") _ = a.sync.RecordOp(entity, id, syncsvc.OpDelete, nil) + + // Move physical folder to trash + if n.FsPath != "" { + src := filepath.Join(a.vault, n.FsPath) + if info, err := os.Stat(src); err == nil && info.IsDir() { + trashDir := filepath.Join(a.vault, ".verstak", "trash") + _ = os.MkdirAll(trashDir, 0o750) + _ = os.Rename(src, filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title))) + } + } + return a.nodes.SoftDelete(id) } diff --git a/cmd/verstak-gui/sync_apply.go b/cmd/verstak-gui/sync_apply.go index 0d9eed9..49e1c2f 100644 --- a/cmd/verstak-gui/sync_apply.go +++ b/cmd/verstak-gui/sync_apply.go @@ -205,7 +205,16 @@ func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error { } filename = cleanFilename } - dest = filepath.Join(a.vault, "spaces", filename) + parentFsPath := "" + if noteNode, err := a.nodes.Get(payload.NodeID); err == nil && noteNode.ParentID != nil { + if parent, err := a.nodes.GetActive(*noteNode.ParentID); err == nil { + parentFsPath = parent.FsPath + } + } + if parentFsPath == "" { + parentFsPath = "spaces" + } + dest = filepath.Join(a.vault, parentFsPath, filename) payload.Path, _ = filepath.Rel(a.vault, dest) } else { cleanPath, err := syncsvc.SafeVaultPath(a.vault, payload.Path) diff --git a/cmd/verstak-gui/vault_check.go b/cmd/verstak-gui/vault_check.go new file mode 100644 index 0000000..74571f7 --- /dev/null +++ b/cmd/verstak-gui/vault_check.go @@ -0,0 +1,110 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// VaultCheckResult contains the diagnostic report for vault integrity. +type VaultCheckResult struct { + TotalNodes int `json:"total_nodes"` + TotalFiles int `json:"total_files"` + NodesWithFsPath int `json:"nodes_with_fs_path"` + FilesOnDisk int `json:"files_on_disk"` + FilesMissing int `json:"files_missing"` + PathEscapeCount int `json:"path_escape_count"` + PathMismatchCount int `json:"path_mismatch_count"` + Errors []string `json:"errors,omitempty"` + Details []string `json:"details,omitempty"` + Healthy bool `json:"healthy"` +} + +func (a *App) VaultCheck() (*VaultCheckResult, error) { + result := &VaultCheckResult{Healthy: true} + + // Check all root nodes + roots, err := a.nodes.ListRoots(true) + if err != nil { + return nil, fmt.Errorf("list roots: %w", err) + } + + var checkNode func(id string) + checkNode = func(id string) { + n, err := a.nodes.GetActive(id) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("node %s: get: %v", id, err)) + result.Healthy = false + return + } + result.TotalNodes++ + + // Check fs_path + if n.FsPath != "" { + result.NodesWithFsPath++ + physPath := filepath.Join(a.vault, n.FsPath) + rel, err := filepath.Rel(a.vault, physPath) + if err != nil || strings.HasPrefix(rel, "..") { + result.PathEscapeCount++ + result.Errors = append(result.Errors, fmt.Sprintf("node %s (%s): fs_path escapes vault: %s", n.ID, n.Title, n.FsPath)) + result.Healthy = false + return + } + if _, err := os.Stat(physPath); os.IsNotExist(err) { + result.FilesMissing++ + result.Details = append(result.Details, fmt.Sprintf("node %s (%s): folder missing on disk: %s", n.ID, n.Title, physPath)) + } + } + + // Check children + children, _ := a.nodes.ListChildren(n.ID, true) + for _, c := range children { + checkNode(c.ID) + } + } + + for _, n := range roots { + checkNode(n.ID) + } + + // Check file records + // We'll query files table and verify each vault-mode file exists + rows, err := a.db.Query(`SELECT id, node_id, path, storage_mode FROM files`) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("query files: %v", err)) + result.Healthy = false + return result, nil + } + defer rows.Close() + + for rows.Next() { + var id, nodeID, path, mode string + if err := rows.Scan(&id, &nodeID, &path, &mode); err != nil { + continue + } + result.TotalFiles++ + + if mode == "vault" { + absPath := filepath.Join(a.vault, path) + rel, err := filepath.Rel(a.vault, absPath) + if err != nil || strings.HasPrefix(rel, "..") { + result.PathEscapeCount++ + result.Errors = append(result.Errors, fmt.Sprintf("file %s: path escapes vault: %s", id, path)) + result.Healthy = false + continue + } + if _, err := os.Stat(absPath); err == nil { + result.FilesOnDisk++ + } else { + result.FilesMissing++ + result.Details = append(result.Details, fmt.Sprintf("file %s (%s): missing on disk: %s", id, path, absPath)) + } + } + } + + if result.FilesMissing > 0 { + result.Healthy = false + } + return result, nil +} diff --git a/cmd/verstak-gui/vault_layout_test.go b/cmd/verstak-gui/vault_layout_test.go new file mode 100644 index 0000000..657fcb0 --- /dev/null +++ b/cmd/verstak-gui/vault_layout_test.go @@ -0,0 +1,371 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "verstak/internal/core/actions" + "verstak/internal/core/activity" + "verstak/internal/core/files" + "verstak/internal/core/nodes" + "verstak/internal/core/notes" + "verstak/internal/core/plugins" + "verstak/internal/core/search" + "verstak/internal/core/storage" + syncsvc "verstak/internal/core/sync" + "verstak/internal/core/templates" + "verstak/internal/core/worklog" +) + +// setupTestApp creates a full App with a temp vault directory for testing. +func setupTestApp(t *testing.T) (*App, string) { + t.Helper() + vaultRoot, err := os.MkdirTemp("", "verstak-test-*") + if err != nil { + t.Fatalf("mkdir temp: %v", err) + } + + // Init vault structure + if err := os.MkdirAll(filepath.Join(vaultRoot, ".verstak"), 0o750); err != nil { + t.Fatalf("mkdir .verstak: %v", err) + } + if err := os.MkdirAll(filepath.Join(vaultRoot, ".verstak", "trash"), 0o750); err != nil { + t.Fatalf("mkdir trash: %v", err) + } + + dbPath := filepath.Join(vaultRoot, ".verstak", "vault.db") + db, err := storage.Open(dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, vaultRoot, nodeRepo) + noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc) + actionSvc := actions.NewService(db) + activitySvc := activity.NewService(db) + worklogSvc := worklog.NewService(db) + searchSvc := search.NewService(db) + pm := plugins.NewManager(vaultRoot) + pm.Discover() + + templatesReg := templates.NewRegistry() + if err := templatesReg.LoadSystem(); err != nil { + t.Fatalf("load templates: %v", err) + } + + syncSvc := syncsvc.NewService(db, "test-device") + + app := &App{ + db: db, + nodes: nodeRepo, + files: fileSvc, + notes: noteSvc, + activity: activitySvc, + actions: actionSvc, + worklog: worklogSvc, + search: searchSvc, + plugins: pm, + sync: syncSvc, + templates: templatesReg, + vault: vaultRoot, + } + + t.Cleanup(func() { + db.Close() + os.RemoveAll(vaultRoot) + }) + + return app, vaultRoot +} + +func TestVaultLayout_CreateProjectTree(t *testing.T) { + app, vault := setupTestApp(t) + + // 1. Create root "Проекты" from folder template + proj, err := app.CreateNodeFromTemplate("", "Проекты", "folder.default") + if err != nil { + t.Fatalf("create Проекты: %v", err) + } + if proj.FsPath != "Проекты" { + t.Errorf("expected fs_path 'Проекты', got %q", proj.FsPath) + } + if _, err := os.Stat(filepath.Join(vault, "Проекты")); os.IsNotExist(err) { + t.Error("expected folder 'Проекты' to exist on disk") + } + + // 2. Create child "Рабочие" inside Проекты + work, err := app.CreateNodeFromTemplate(proj.ID, "Рабочие", "folder.default") + if err != nil { + t.Fatalf("create Рабочие: %v", err) + } + expectedWorkPath := "Проекты/Рабочие" + if work.FsPath != expectedWorkPath { + t.Errorf("expected fs_path %q, got %q", expectedWorkPath, work.FsPath) + } + if _, err := os.Stat(filepath.Join(vault, expectedWorkPath)); os.IsNotExist(err) { + t.Error("expected folder 'Проекты/Рабочие' to exist on disk") + } + + // 3. Create project "Разработка серверной" from project template + server, err := app.CreateNodeFromTemplate(work.ID, "Разработка серверной", "project.default") + if err != nil { + t.Fatalf("create Разработка серверной: %v", err) + } + expectedServerPath := "Проекты/Рабочие/Разработка серверной" + if server.FsPath != expectedServerPath { + t.Errorf("expected fs_path %q, got %q", expectedServerPath, server.FsPath) + } + serverFolder := filepath.Join(vault, expectedServerPath) + if _, err := os.Stat(serverFolder); os.IsNotExist(err) { + t.Error("expected project folder on disk") + } + + // 4. Verify template created Overview.md + overviewPath := filepath.Join(serverFolder, "Overview.md") + if _, err := os.Stat(overviewPath); os.IsNotExist(err) { + t.Log("note: Overview.md from template not created (may not be implemented)") + } +} + +func TestVaultLayout_CreateNoteInsideProject(t *testing.T) { + app, vault := setupTestApp(t) + + proj, err := app.CreateNodeFromTemplate("", "Тестовый проект", "project.default") + if err != nil { + t.Fatalf("create project: %v", err) + } + + // Create a note inside the project + noteNode, fileRec, err := app.notes.Create(proj.ID, "Моя заметка", "") + if err != nil { + t.Fatalf("create note: %v", err) + } + if noteNode == nil || fileRec == nil { + t.Fatal("expected non-nil node and file record") + } + + // Verify the note .md file is inside the project folder + expectedPath := filepath.Join(vault, proj.FsPath, "Моя заметка.md") + if _, err := os.Stat(expectedPath); os.IsNotExist(err) { + // Try the safe-display-name variant + expectedPath2 := filepath.Join(vault, proj.FsPath, "Моя_заметка.md") + if _, err2 := os.Stat(expectedPath2); os.IsNotExist(err2) { + // Show what actually exists + entries, _ := os.ReadDir(filepath.Join(vault, proj.FsPath)) + t.Errorf("expected note file in project folder, found: %v", listNames(entries)) + } + } +} + +func TestVaultLayout_CopyFileIntoProject(t *testing.T) { + app, vault := setupTestApp(t) + + proj, err := app.CreateNodeFromTemplate("", "Проект", "folder.default") + if err != nil { + t.Fatalf("create project: %v", err) + } + + // Create a temp source file + srcFile := filepath.Join(vault, "source.txt") + if err := os.WriteFile(srcFile, []byte("hello"), 0o640); err != nil { + t.Fatalf("write source: %v", err) + } + + // Create a file node inside the project + fileNode, err := app.nodes.Create(&proj.ID, "file", "test.txt", 0, "", "") + if err != nil { + t.Fatalf("create file node: %v", err) + } + + // Copy the file into vault (using parent's fs_path) + rec, err := app.files.CopyIntoVault(fileNode.ID, srcFile, proj.FsPath) + if err != nil { + t.Fatalf("copy into vault: %v", err) + } + + // Verify file lands in project folder + expectedPath := filepath.Join(vault, proj.FsPath, "source.txt") + if _, err := os.Stat(expectedPath); os.IsNotExist(err) { + t.Errorf("expected file at %s, record path = %s", expectedPath, rec.Path) + } +} + +func TestVaultLayout_RenameParentUpdatesDescendants(t *testing.T) { + app, vault := setupTestApp(t) + + root, _ := app.CreateNodeFromTemplate("", "Root", "folder.default") + child, _ := app.CreateNodeFromTemplate(root.ID, "Child", "folder.default") + grandchild, _ := app.CreateNodeFromTemplate(child.ID, "Grandchild", "folder.default") + + // Rename root + if err := app.RenameNode(root.ID, "RenamedRoot"); err != nil { + t.Fatalf("rename root: %v", err) + } + + // Verify child fs_path updated + childUpdated, _ := app.nodes.GetActive(child.ID) + expectedChildPath := "RenamedRoot/Child" + if childUpdated.FsPath != expectedChildPath { + t.Errorf("expected child fs_path %q, got %q", expectedChildPath, childUpdated.FsPath) + } + + // Verify grandchild fs_path updated + gcUpdated, _ := app.nodes.GetActive(grandchild.ID) + expectedGCPath := "RenamedRoot/Child/Grandchild" + if gcUpdated.FsPath != expectedGCPath { + t.Errorf("expected grandchild fs_path %q, got %q", expectedGCPath, gcUpdated.FsPath) + } + + // Verify physical folders + if _, err := os.Stat(filepath.Join(vault, expectedChildPath)); os.IsNotExist(err) { + t.Error("expected child folder on disk after rename") + } + if _, err := os.Stat(filepath.Join(vault, expectedGCPath)); os.IsNotExist(err) { + t.Error("expected grandchild folder on disk after rename") + } + + // Verify old path no longer exists + if _, err := os.Stat(filepath.Join(vault, "Root")); !os.IsNotExist(err) { + t.Error("expected old root path to not exist") + } +} + +func TestVaultLayout_MoveNode(t *testing.T) { + app, vault := setupTestApp(t) + + folder1, _ := app.CreateNodeFromTemplate("", "Folder1", "folder.default") + folder2, _ := app.CreateNodeFromTemplate("", "Folder2", "folder.default") + child, _ := app.CreateNodeFromTemplate(folder1.ID, "Child", "folder.default") + + // Move child from Folder1 to Folder2 + if err := app.MoveNode(child.ID, folder2.ID); err != nil { + t.Fatalf("move node: %v", err) + } + + moved, _ := app.nodes.GetActive(child.ID) + expectedPath := "Folder2/Child" + if moved.FsPath != expectedPath { + t.Errorf("expected fs_path %q, got %q", expectedPath, moved.FsPath) + } + + if _, err := os.Stat(filepath.Join(vault, expectedPath)); os.IsNotExist(err) { + t.Error("expected child folder at new location on disk") + } +} + +func TestVaultLayout_DeleteMovesToTrash(t *testing.T) { + app, vault := setupTestApp(t) + + node, _ := app.CreateNodeFromTemplate("", "ToDelete", "folder.default") + nodePath := filepath.Join(vault, "ToDelete") + if _, err := os.Stat(nodePath); os.IsNotExist(err) { + t.Fatal("expected folder to exist before delete") + } + + // Delete the node + if err := app.DeleteNode(node.ID); err != nil { + t.Fatalf("delete node: %v", err) + } + + // Verify node folder is no longer in original location + if _, err := os.Stat(nodePath); !os.IsNotExist(err) { + t.Error("expected node folder to be removed from original location") + } + + // Verify trash has the folder + trashDir := filepath.Join(vault, ".verstak", "trash") + entries, _ := os.ReadDir(trashDir) + found := false + for _, e := range entries { + if e.IsDir() && contains(e.Name(), "ToDelete") { + found = true + break + } + } + if !found { + t.Errorf("expected deleted folder in trash, found: %v", listNames(entries)) + } + + // Verify node is soft-deleted + _, err := app.nodes.GetActive(node.ID) + if err == nil { + t.Error("expected node to be soft-deleted") + } +} + +func TestVaultLayout_NameConflict(t *testing.T) { + app, vault := setupTestApp(t) + + node1, err := app.CreateNodeFromTemplate("", "SameName", "folder.default") + if err != nil { + t.Fatalf("create first: %v", err) + } + if node1.FsPath != "SameName" { + t.Errorf("expected fs_path 'SameName', got %q", node1.FsPath) + } + + node2, err := app.CreateNodeFromTemplate("", "SameName", "folder.default") + if err != nil { + t.Fatalf("create second: %v", err) + } + if node2.FsPath == "SameName" { + t.Errorf("expected unique fs_path for second node, got same %q", node2.FsPath) + } + if node2.FsPath == node1.FsPath { + t.Error("expected different fs_path for conflicting name") + } + + // Both folders should exist on disk + if _, err := os.Stat(filepath.Join(vault, node1.FsPath)); os.IsNotExist(err) { + t.Errorf("expected first folder at %s", node1.FsPath) + } + if _, err := os.Stat(filepath.Join(vault, node2.FsPath)); os.IsNotExist(err) { + t.Errorf("expected second folder at %s", node2.FsPath) + } +} + +func TestVaultLayout_VaultCheck(t *testing.T) { + app, _ := setupTestApp(t) + + // Create a healthy vault structure + app.CreateNodeFromTemplate("", "Healthy", "folder.default") + child, _ := app.CreateNodeFromTemplate("", "Child", "folder.default") + app.CreateNodeFromTemplate(child.ID, "Grandchild", "folder.default") + + // Run vault check + result, err := app.VaultCheck() + if err != nil { + t.Fatalf("vault check: %v", err) + } + if !result.Healthy { + t.Errorf("expected healthy vault, got errors: %v", result.Errors) + } + if result.TotalNodes == 0 { + t.Error("expected at least 1 node in check") + } +} + +// --- helpers --- + +func listNames(entries []os.DirEntry) []string { + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && containsStr(s, substr) +} + +func containsStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/core/files/file.go b/internal/core/files/file.go index 1ada5b3..55673c3 100644 --- a/internal/core/files/file.go +++ b/internal/core/files/file.go @@ -15,6 +15,7 @@ import ( "verstak/internal/core/nodes" "verstak/internal/core/storage" + "verstak/internal/core/templates" "verstak/internal/core/util" ) @@ -55,6 +56,17 @@ func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository) *S return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo} } +func (s *Service) parentFsPath(parentID string) string { + if parentID == "" { + return "" + } + parent, err := s.nodes.GetActive(parentID) + if err != nil || parent.FsPath == "" { + return "" + } + return parent.FsPath +} + // DB returns the underlying storage. func (s *Service) DB() *storage.DB { return s.db @@ -126,18 +138,18 @@ func (s *Service) AddExternal(nodeID, absPath string) (*Record, error) { } // CopyIntoVault copies an external file into the vault. -// The file lands at /spaces//. -func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, error) { +// The file lands at //. +func (s *Service) CopyIntoVault(nodeID, absPath, parentFsPath string) (*Record, error) { info, err := os.Stat(absPath) if err != nil { return nil, fmt.Errorf("stat: %w", err) } - if nodeSlug == "" { - nodeSlug = nodeID[:8] + if parentFsPath == "" { + parentFsPath = "." } - destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug) - if _, err := s.vaultPath(filepath.Join("spaces", nodeSlug)); err != nil { + destDir := filepath.Join(s.vaultRoot, parentFsPath) + if _, err := s.vaultPath(parentFsPath); err != nil { return nil, fmt.Errorf("path safety: %w", err) } if err := os.MkdirAll(destDir, 0o750); err != nil { @@ -147,7 +159,6 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro filename := filepath.Base(absPath) dest := filepath.Join(destDir, filename) - // If destination exists, add a numeric suffix. if _, err := os.Stat(dest); err == nil { ext := filepath.Ext(filename) name := strings.TrimSuffix(filename, ext) @@ -299,7 +310,11 @@ func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error if err != nil { return nil, err } - dir := filepath.Join(s.vaultRoot, "spaces", node.Slug) + parentFsPath := s.parentFsPath(parentID) + dir := filepath.Join(s.vaultRoot, parentFsPath) + if parentFsPath == "" { + dir = s.vaultRoot + } if err := os.MkdirAll(dir, 0o750); err != nil { return nil, fmt.Errorf("mkdir: %w", err) } @@ -342,7 +357,11 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) { if err != nil { return nil, err } - dir := filepath.Join(s.vaultRoot, "spaces", node.Slug) + parentFsPath := s.parentFsPath(parentID) + dir := filepath.Join(s.vaultRoot, parentFsPath) + if parentFsPath == "" { + dir = s.vaultRoot + } os.MkdirAll(dir, 0o750) dst := filepath.Join(dir, newName) hash, err := copyAndHash(srcPath, dst) @@ -428,6 +447,17 @@ func (s *Service) DeleteNodeAndChildren(nodeID string) error { } } _ = s.deleteFileRecords(nodeID) + // Move physical folder to trash if the node has fs_path + n, err := s.nodes.GetActive(nodeID) + if err == nil && n.FsPath != "" { + src := filepath.Join(s.vaultRoot, n.FsPath) + if info, err := os.Stat(src); err == nil && info.IsDir() { + trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash") + os.MkdirAll(trashDir, 0o750) + trashPath := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title)) + os.Rename(src, trashPath) + } + } return s.nodes.SoftDelete(nodeID) } @@ -454,7 +484,8 @@ func (s *Service) importPath(parentID, sourcePath string, copyMode bool) ([]node return nil, err } if copyMode { - _, err = s.CopyIntoVault(node.ID, sourcePath, node.Slug) + parentFsPath := s.parentFsPath(parentID) + _, err = s.CopyIntoVault(node.ID, sourcePath, parentFsPath) } else { _, err = s.AddExternal(node.ID, sourcePath) } @@ -473,6 +504,14 @@ func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool) return nil, err } + parentFsPath := s.parentFsPath(parentID) + seg := templates.SafeDisplayNameToPathSegment(dirName) + folderFsPath := seg + if parentFsPath != "" { + folderFsPath = filepath.Join(parentFsPath, seg) + } + _ = s.nodes.UpdateFsPath(folderNode.ID, folderFsPath) + entries, err := os.ReadDir(sourcePath) if err != nil { return nil, err @@ -495,7 +534,7 @@ func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool) return nil, err } if copyMode { - _, err = s.CopyIntoVault(childNode.ID, childPath, childNode.Slug) + _, err = s.CopyIntoVault(childNode.ID, childPath, folderFsPath) } else { _, err = s.AddExternal(childNode.ID, childPath) } diff --git a/internal/core/files/file_test.go b/internal/core/files/file_test.go index 68e9095..212644c 100644 --- a/internal/core/files/file_test.go +++ b/internal/core/files/file_test.go @@ -70,7 +70,7 @@ func TestCopyIntoVault(t *testing.T) { srcFile := filepath.Join(srcDir, "doc.pdf") os.WriteFile(srcFile, []byte("PDF content here"), 0o640) - rec, err := svc.CopyIntoVault("node-1", srcFile, "my-node") + rec, err := svc.CopyIntoVault("node-1", srcFile, "") if err != nil { t.Fatalf("CopyIntoVault: %v", err) } @@ -117,7 +117,7 @@ func TestDeleteToTrash(t *testing.T) { src := filepath.Join(t.TempDir(), "important.pdf") os.WriteFile(src, []byte("important data"), 0o640) - rec, _ := svc.CopyIntoVault("node-x", src, "node-x") + rec, _ := svc.CopyIntoVault("node-x", src, "") if err := svc.DeleteToTrash(rec.ID); err != nil { t.Fatalf("DeleteToTrash: %v", err) @@ -247,7 +247,7 @@ func TestDeleteNodeAndChildren(t *testing.T) { // Add file record to child. src := filepath.Join(t.TempDir(), "child.txt") os.WriteFile(src, []byte("data"), 0o640) - svc.CopyIntoVault(child.ID, src, child.Slug) + svc.CopyIntoVault(child.ID, src, "") if err := svc.DeleteNodeAndChildren(parent.ID); err != nil { t.Fatalf("DeleteNodeAndChildren: %v", err) diff --git a/internal/core/nodes/repository.go b/internal/core/nodes/repository.go index ab65170..f0b0ca9 100644 --- a/internal/core/nodes/repository.go +++ b/internal/core/nodes/repository.go @@ -4,9 +4,11 @@ import ( "database/sql" "errors" "fmt" + "path/filepath" "time" "verstak/internal/core/storage" + "verstak/internal/core/templates" "verstak/internal/core/util" ) @@ -264,7 +266,8 @@ func (r *Repository) UpdateFsPathRecursive(id, newFsPath string) error { return err } for _, child := range children { - childPath := newFsPath + "/" + child.Slug + seg := templates.SafeDisplayNameToPathSegment(child.Title) + childPath := filepath.Join(newFsPath, seg) if err := r.UpdateFsPathRecursive(child.ID, childPath); err != nil { return err } diff --git a/internal/core/notes/note.go b/internal/core/notes/note.go index 1f358d2..395c4c3 100644 --- a/internal/core/notes/note.go +++ b/internal/core/notes/note.go @@ -9,6 +9,7 @@ import ( "verstak/internal/core/files" "verstak/internal/core/nodes" "verstak/internal/core/storage" + "verstak/internal/core/templates" "verstak/internal/core/util" ) @@ -40,33 +41,43 @@ func (s *Service) Create(parentID, title, section string) (*nodes.Node, *files.R return nil, nil, fmt.Errorf("create node: %w", err) } - slug := node.Slug - if slug == "" { - slug = "note" + seg := templates.SafeDisplayNameToPathSegment(title) + if seg == "" { + seg = "note" + } + filename := seg + ".md" + + var destDir string + if parentID != "" { + parent, err := s.nodes.GetActive(parentID) + if err == nil && parent.FsPath != "" { + destDir = filepath.Join(s.vaultRoot, parent.FsPath) + } + } + if destDir == "" { + destDir = s.vaultRoot + } + + if err := os.MkdirAll(destDir, 0o750); err != nil { + return nil, nil, fmt.Errorf("mkdir: %w", err) } - filename := slug + ".md" - destDir := filepath.Join(s.vaultRoot, "spaces") - os.MkdirAll(destDir, 0o750) dest := filepath.Join(destDir, filename) if _, err := os.Stat(dest); err == nil { - filename = fmt.Sprintf("%s_%s.md", slug, node.ID[:8]) + filename = fmt.Sprintf("%s_%s.md", seg, node.ID[:8]) dest = filepath.Join(destDir, filename) } - // Write initial content. if err := os.WriteFile(dest, []byte("# "+title+"\n\n"), 0o640); err != nil { return nil, nil, fmt.Errorf("write: %w", err) } - // Register file record. relPath, _ := filepath.Rel(s.vaultRoot, dest) fileRec, err := insertFileRecord(s.db, node.ID, filename, relPath, "vault", 0) if err != nil { return nil, nil, fmt.Errorf("insert file: %w", err) } - // Link. _, err = s.db.Exec( `INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, node.ID, fileRec.ID, "markdown") diff --git a/internal/core/notes/note_test.go b/internal/core/notes/note_test.go index e3ec7ff..b203e92 100644 --- a/internal/core/notes/note_test.go +++ b/internal/core/notes/note_test.go @@ -49,11 +49,16 @@ func TestCreateAndRead(t *testing.T) { t.Errorf("content = %q", content) } - // Verify file on disk. - spacesDir := filepath.Join(vaultRoot, "spaces") - entries, _ := os.ReadDir(spacesDir) - if len(entries) == 0 { - t.Error("expected file in spaces/") + // Verify file on disk (in vault root for parentless notes). + entries, _ := os.ReadDir(vaultRoot) + var mdFiles int + for _, e := range entries { + if !e.IsDir() && filepath.Ext(e.Name()) == ".md" { + mdFiles++ + } + } + if mdFiles == 0 { + t.Error("expected .md file in vault root") } } diff --git a/internal/gui/server.go b/internal/gui/server.go index 3d3babc..20e3dfa 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -309,14 +309,18 @@ func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) { var req struct { NodeID string `json:"node_id"` FilePath string `json:"file_path"` - NodeSlug string `json:"node_slug"` } json.NewDecoder(r.Body).Decode(&req) if req.NodeID == "" || req.FilePath == "" { jsonErr(w, 400, "node_id and file_path required") return } - rec, err := s.files.CopyIntoVault(req.NodeID, req.FilePath, req.NodeSlug) + n, err := s.nodes.GetActive(req.NodeID) + if err != nil { + jsonErr(w, 404, "node not found") + return + } + rec, err := s.files.CopyIntoVault(req.NodeID, req.FilePath, n.FsPath) if err != nil { jsonErr(w, 500, err.Error()) return diff --git a/scripts/check-i18n.sh b/scripts/check-i18n.sh index af2560f..6558a9a 100755 --- a/scripts/check-i18n.sh +++ b/scripts/check-i18n.sh @@ -25,6 +25,7 @@ ALLOWED_GO_CYRILLIC=( "cmd/verstak-gui/main.go" "internal/core/templates/safename.go" "internal/core/templates/safename_test.go" + "cmd/verstak-gui/vault_layout_test.go" ) echo "=== Checking for hardcoded Cyrillic in source code ==="