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:
2026-06-02 15:43:40 +08:00
parent 0b26f7e5b3
commit 4f01f2de2e
11 changed files with 599 additions and 33 deletions
+50 -11
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)
}
+3 -3
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)
+4 -1
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
}
+21 -10
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")
+10 -5
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")
}
}
+6 -2
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