fix: complete vault layout transition — fs_path everywhere, no more spaces/

- 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
This commit is contained in:
mirivlad 2026-06-02 15:43:40 +08:00
parent 0b26f7e5b3
commit 4f01f2de2e
11 changed files with 599 additions and 33 deletions

View File

@ -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)
}

View File

@ -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)

View File

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

View File

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

View File

@ -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 <vaultRoot>/spaces/<nodeSlug>/<filename>.
func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, error) {
// The file lands at <vaultRoot>/<parentFsPath>/<filename>.
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)
}

View File

@ -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)

View File

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

View File

@ -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")

View File

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

View File

@ -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

View File

@ -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 ==="