fix: third stabilization pass — template children as nodes, atomicity, fs_path validation, sync_apply compat, smoke test
This commit is contained in:
parent
49c0fda61c
commit
a31f5fd702
|
|
@ -321,8 +321,14 @@ func (a *App) filePayload(n *nodes.Node) map[string]interface{} {
|
|||
}
|
||||
|
||||
func notePayload(node *nodes.Node, fileRec *files.Record, content string) map[string]interface{} {
|
||||
pid := ""
|
||||
if node.ParentID != nil {
|
||||
pid = *node.ParentID
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"node_id": node.ID,
|
||||
"parent_id": pid,
|
||||
"title": node.Title,
|
||||
"file_id": fileRec.ID,
|
||||
"format": "markdown",
|
||||
"content": content,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
func (a *App) ListWorkspaceTree() ([]NodeDTO, error) {
|
||||
|
|
@ -70,6 +71,10 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
|
|||
rel, _ := filepath.Rel(a.vault, physPath)
|
||||
fsPath = rel
|
||||
|
||||
if _, err := syncsvc.SafeVaultPath(a.vault, fsPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
|
||||
var pID *string
|
||||
if parentID != "" {
|
||||
pID = &parentID
|
||||
|
|
@ -82,21 +87,84 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
|
|||
}
|
||||
|
||||
if err := os.MkdirAll(physPath, 0o755); err != nil {
|
||||
_ = a.nodes.SoftDelete(n.ID)
|
||||
return nil, fmt.Errorf("create folder: %w", err)
|
||||
}
|
||||
|
||||
// Create child nodes for default files (proper DB nodes + file records)
|
||||
nowRFC := time.Now().UTC().Format(time.RFC3339)
|
||||
for _, df := range tmpl.DefaultFiles {
|
||||
fpath := filepath.Join(physPath, df.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
|
||||
continue
|
||||
}
|
||||
fileTitle := strings.TrimSuffix(filepath.Base(df.Path), filepath.Ext(df.Path))
|
||||
if fileTitle == "" {
|
||||
fileTitle = "Overview"
|
||||
}
|
||||
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeNote, fileTitle, 0, "", "")
|
||||
if childErr != nil {
|
||||
continue
|
||||
}
|
||||
content := fmt.Sprintf("# %s\n\n", title)
|
||||
_ = os.WriteFile(fpath, []byte(content), 0o640)
|
||||
if err := os.WriteFile(fpath, []byte(content), 0o640); err != nil {
|
||||
_ = a.nodes.SoftDelete(childNode.ID)
|
||||
continue
|
||||
}
|
||||
relPath, _ := filepath.Rel(a.vault, fpath)
|
||||
fi, _ := os.Stat(fpath)
|
||||
size := int64(0)
|
||||
if fi != nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
fileID := util.UUID7()
|
||||
_, _ = a.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`,
|
||||
fileID, childNode.ID, filepath.Base(fpath), relPath, size, nowRFC, nowRFC)
|
||||
_, _ = a.db.Exec(
|
||||
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
||||
childNode.ID, fileID, "markdown")
|
||||
_ = a.activity.Record(n.ID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, childNode.ID, syncsvc.OpCreate, map[string]interface{}{
|
||||
"node_id": childNode.ID,
|
||||
"parent_id": n.ID,
|
||||
"title": fileTitle,
|
||||
"file_id": fileID,
|
||||
"format": "markdown",
|
||||
"content": content,
|
||||
"filename": filepath.Base(fpath),
|
||||
"path": relPath,
|
||||
"created_at": nowRFC,
|
||||
"updated_at": nowRFC,
|
||||
})
|
||||
}
|
||||
|
||||
for _, folder := range tmpl.DefaultFolders {
|
||||
fpath := filepath.Join(physPath, folder)
|
||||
_ = os.MkdirAll(fpath, 0o755)
|
||||
// Create child nodes for default folders (proper DB nodes + physical folders)
|
||||
for _, folderName := range tmpl.DefaultFolders {
|
||||
folderSeg := templates.SafeDisplayNameToPathSegment(folderName)
|
||||
if folderSeg == "" {
|
||||
folderSeg = "folder"
|
||||
}
|
||||
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeFolder, folderName, 0, "", "")
|
||||
if childErr != nil {
|
||||
continue
|
||||
}
|
||||
childFsPath := folderSeg
|
||||
if fsPath != "" {
|
||||
childFsPath = filepath.Join(fsPath, folderSeg)
|
||||
}
|
||||
childPhysPath := filepath.Join(a.vault, childFsPath)
|
||||
childPhysPath = templates.UniquePath(childPhysPath)
|
||||
childRel, _ := filepath.Rel(a.vault, childPhysPath)
|
||||
childFsPath = childRel
|
||||
_ = a.nodes.UpdateFsPath(childNode.ID, childFsPath)
|
||||
if err := os.MkdirAll(childPhysPath, 0o755); err != nil {
|
||||
_ = a.nodes.SoftDelete(childNode.ID)
|
||||
continue
|
||||
}
|
||||
_ = a.activity.Record(n.ID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode))
|
||||
}
|
||||
|
||||
pid := ""
|
||||
|
|
@ -184,6 +252,10 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||
rel, _ := filepath.Rel(a.vault, newPhysPath)
|
||||
newFsPath = rel
|
||||
|
||||
if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil {
|
||||
return fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
|
||||
oldTitle := n.Title
|
||||
|
||||
// Check source exists before filesystem rename
|
||||
|
|
@ -356,12 +428,39 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (a *App) isDescendant(ancestorID, nodeID string) error {
|
||||
if nodeID == "" || ancestorID == "" {
|
||||
return nil
|
||||
}
|
||||
current := nodeID
|
||||
depth := 0
|
||||
for current != "" && depth < 1000 {
|
||||
if current == ancestorID {
|
||||
return fmt.Errorf("cannot move a node into its own descendant")
|
||||
}
|
||||
n, err := a.nodes.Get(current)
|
||||
if err != nil || n.ParentID == nil {
|
||||
return nil
|
||||
}
|
||||
current = *n.ParentID
|
||||
depth++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||
node, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Prevent moving node into its own descendant
|
||||
if newParentID != "" {
|
||||
if err := a.isDescendant(newParentID, nodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
isFolderLike := node.Type != nodes.TypeNote && node.Type != nodes.TypeFile
|
||||
|
||||
// Resolve new parent
|
||||
|
|
@ -411,6 +510,10 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
|||
rel, _ := filepath.Rel(a.vault, newPhysPath)
|
||||
newFsPath = rel
|
||||
|
||||
if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil {
|
||||
return fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
|
||||
// Check source exists and do filesystem rename first
|
||||
if _, err := os.Stat(oldPhysPath); err != nil {
|
||||
return fmt.Errorf("source folder not found: %w", err)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import (
|
|||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
|
|
@ -145,6 +147,117 @@ func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error {
|
|||
}
|
||||
}
|
||||
|
||||
// If the node was created from a template, also create child nodes
|
||||
// for any default_files and default_folders that were not already synced
|
||||
// as individual ops (backward compatibility with devices that do not
|
||||
// sync template children).
|
||||
_ = a.ensureTemplateChildren(payload.ID, payload.TemplateID, fsPath, payload.Title)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureTemplateChildren creates child nodes for a template's default files
|
||||
// and folders if they don't already exist. This handles backward compatibility
|
||||
// with devices that do not sync template children as individual ops.
|
||||
func (a *App) ensureTemplateChildren(nodeID, templateID, parentFsPath, title string) error {
|
||||
if templateID == "" {
|
||||
return nil
|
||||
}
|
||||
tmpl, ok := a.templates.Get(templateID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
nowRFC := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
if len(tmpl.DefaultFolders) == 0 && len(tmpl.DefaultFiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check existing children to avoid duplicates.
|
||||
existing, err := a.nodes.ListChildren(nodeID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exists := make(map[string]bool, len(existing))
|
||||
for i := range existing {
|
||||
exists[existing[i].Title] = true
|
||||
}
|
||||
|
||||
for _, folderName := range tmpl.DefaultFolders {
|
||||
if exists[folderName] {
|
||||
continue
|
||||
}
|
||||
folderSeg := templates.SafeDisplayNameToPathSegment(folderName)
|
||||
if folderSeg == "" {
|
||||
folderSeg = "folder"
|
||||
}
|
||||
childNode, childErr := a.nodes.Create(&nodeID, nodes.TypeFolder, folderName, 0, "", "")
|
||||
if childErr != nil {
|
||||
continue
|
||||
}
|
||||
childFsPath := folderSeg
|
||||
if parentFsPath != "" {
|
||||
childFsPath = filepath.Join(parentFsPath, folderSeg)
|
||||
}
|
||||
fullPath := filepath.Join(a.vault, childFsPath)
|
||||
fullPath = templates.UniquePath(fullPath)
|
||||
rel, _ := filepath.Rel(a.vault, fullPath)
|
||||
childFsPath = rel
|
||||
_ = a.nodes.UpdateFsPath(childNode.ID, childFsPath)
|
||||
_ = os.MkdirAll(fullPath, 0o755)
|
||||
|
||||
_ = a.activity.Record(nodeID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode))
|
||||
}
|
||||
|
||||
for _, df := range tmpl.DefaultFiles {
|
||||
fileTitle := strings.TrimSuffix(filepath.Base(df.Path), filepath.Ext(df.Path))
|
||||
if fileTitle == "" {
|
||||
fileTitle = "Overview"
|
||||
}
|
||||
if exists[fileTitle] {
|
||||
continue
|
||||
}
|
||||
|
||||
childNode, childErr := a.nodes.Create(&nodeID, nodes.TypeNote, fileTitle, 0, "", "")
|
||||
if childErr != nil {
|
||||
continue
|
||||
}
|
||||
content := fmt.Sprintf("# %s\n\n", title)
|
||||
fpath := filepath.Join(a.vault, parentFsPath, df.Path)
|
||||
_ = os.MkdirAll(filepath.Dir(fpath), 0o750)
|
||||
if err := os.WriteFile(fpath, []byte(content), 0o640); err != nil {
|
||||
_ = a.nodes.SoftDelete(childNode.ID)
|
||||
continue
|
||||
}
|
||||
relPath, _ := filepath.Rel(a.vault, fpath)
|
||||
fi, _ := os.Stat(fpath)
|
||||
size := int64(0)
|
||||
if fi != nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
fileID := util.UUID7()
|
||||
_, _ = a.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`,
|
||||
fileID, childNode.ID, filepath.Base(fpath), relPath, size, nowRFC, nowRFC)
|
||||
_, _ = a.db.Exec(
|
||||
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
||||
childNode.ID, fileID, "markdown")
|
||||
_ = a.activity.Record(nodeID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, childNode.ID, syncsvc.OpCreate, map[string]interface{}{
|
||||
"node_id": childNode.ID,
|
||||
"parent_id": nodeID,
|
||||
"title": fileTitle,
|
||||
"file_id": fileID,
|
||||
"format": "markdown",
|
||||
"content": content,
|
||||
"filename": filepath.Base(fpath),
|
||||
"path": relPath,
|
||||
"created_at": nowRFC,
|
||||
"updated_at": nowRFC,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -310,6 +423,8 @@ func (a *App) applyRemoteNoteOp(op syncsvc.Op) error {
|
|||
func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Title string `json:"title"`
|
||||
FileID string `json:"file_id"`
|
||||
Format string `json:"format"`
|
||||
Content string `json:"content"`
|
||||
|
|
@ -326,16 +441,29 @@ func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
|
|||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
title := payload.Title
|
||||
if title == "" {
|
||||
title = "remote-note"
|
||||
}
|
||||
slug := nodes.Slugify(title)
|
||||
|
||||
if _, err := a.nodes.Get(payload.NodeID); err != nil {
|
||||
slug := nodes.Slugify("remote-note")
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
_, e := a.db.Exec(
|
||||
`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)
|
||||
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,created_at,updated_at,revision)
|
||||
VALUES (?,?,'note',?,?,'','',?,?,1)`,
|
||||
payload.NodeID, parent, title, slug, now, now)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
} else if payload.ParentID != "" {
|
||||
// Update parent_id on existing node (e.g., created by old version without parent_id).
|
||||
_, _ = a.db.Exec(
|
||||
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=? AND (parent_id IS NULL OR parent_id='')`,
|
||||
payload.ParentID, now, payload.NodeID)
|
||||
}
|
||||
|
||||
var dest string
|
||||
|
|
@ -379,8 +507,8 @@ func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
|
|||
fileID = util.UUID7()
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',?,'text/plain',?,?,0)`,
|
||||
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`,
|
||||
fileID, payload.NodeID, filepath.Base(dest), payload.Path, size, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -1070,6 +1070,385 @@ func TestVaultLayout_FolderRenameDoesNotUpdateDBIfOsRenameFails(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_MoveNodeIntoDescendantRejected(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
parent, _ := app.CreateNodeFromTemplate("", "Parent", "folder.default")
|
||||
child, _ := app.CreateNodeFromTemplate(parent.ID, "Child", "folder.default")
|
||||
grandchild, _ := app.CreateNodeFromTemplate(child.ID, "Grandchild", "folder.default")
|
||||
|
||||
// Try to move parent into grandchild
|
||||
if err := app.MoveNode(parent.ID, grandchild.ID); err == nil {
|
||||
t.Error("expected error when moving parent into descendant")
|
||||
}
|
||||
|
||||
// Try to move child into its own descendant
|
||||
if err := app.MoveNode(child.ID, grandchild.ID); err == nil {
|
||||
t.Error("expected error when moving node into own descendant")
|
||||
}
|
||||
|
||||
// Verify nothing changed
|
||||
n, _ := app.nodes.GetActive(parent.ID)
|
||||
if n.ParentID != nil {
|
||||
t.Error("expected parent to remain root")
|
||||
}
|
||||
movedChild, _ := app.nodes.GetActive(child.ID)
|
||||
if movedChild.ParentID == nil || *movedChild.ParentID != parent.ID {
|
||||
t.Error("expected child to remain under parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_TemplateDefaultFoldersCreatedAsNodes(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// The project.default template has DefaultFolders: ["Documents", "Notes", "Files"]
|
||||
proj, err := app.CreateNodeFromTemplate("", "TestProject", "project.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
// Verify children nodes exist for each default folder
|
||||
children, err := app.nodes.ListChildren(proj.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list children: %v", err)
|
||||
}
|
||||
|
||||
expected := map[string]string{
|
||||
"Documents": "folder",
|
||||
"Notes": "folder",
|
||||
"Files": "folder",
|
||||
"Overview": "note",
|
||||
}
|
||||
for _, child := range children {
|
||||
expectedType, ok := expected[child.Title]
|
||||
if !ok {
|
||||
t.Errorf("unexpected child %q (type=%q)", child.Title, child.Type)
|
||||
continue
|
||||
}
|
||||
if child.Type != expectedType {
|
||||
t.Errorf("child %q expected type %q, got %q", child.Title, expectedType, child.Type)
|
||||
}
|
||||
if child.FsPath == "" && child.Type == "folder" {
|
||||
t.Errorf("child %q has empty fs_path", child.Title)
|
||||
}
|
||||
if child.Type == "folder" {
|
||||
physPath := filepath.Join(vault, child.FsPath)
|
||||
if info, err := os.Stat(physPath); err != nil || !info.IsDir() {
|
||||
t.Errorf("expected physical folder at %s", physPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(children) < 4 {
|
||||
t.Errorf("expected at least 4 children (3 folders + 1 note), got %d", len(children))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_TemplateDefaultFileCreatedAsNodeWithFileRecord(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// The project.default template has DefaultFiles: [{"path": "Overview.md"}]
|
||||
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
// Find the Overview note child
|
||||
children, err := app.nodes.ListChildren(proj.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list children: %v", err)
|
||||
}
|
||||
|
||||
var overview *nodes.Node
|
||||
for i := range children {
|
||||
if children[i].Title == "Overview" {
|
||||
overview = &children[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if overview == nil {
|
||||
t.Fatal("expected 'Overview' child node from template")
|
||||
}
|
||||
if overview.Type != "note" {
|
||||
t.Errorf("expected type 'note', got %q", overview.Type)
|
||||
}
|
||||
|
||||
// Verify file record exists
|
||||
records, err := app.files.ListByNode(overview.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list file records: %v", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
t.Fatal("expected file record for Overview")
|
||||
}
|
||||
rec := records[0]
|
||||
if rec.Filename != "Overview.md" {
|
||||
t.Errorf("expected filename 'Overview.md', got %q", rec.Filename)
|
||||
}
|
||||
if rec.StorageMode != "vault" {
|
||||
t.Errorf("expected storage mode 'vault', got %q", rec.StorageMode)
|
||||
}
|
||||
|
||||
// Verify physical file exists
|
||||
physPath := filepath.Join(vault, rec.Path)
|
||||
if _, err := os.Stat(physPath); os.IsNotExist(err) {
|
||||
t.Errorf("expected physical file at %s", physPath)
|
||||
}
|
||||
|
||||
// Verify notes record exists
|
||||
var format string
|
||||
err = app.db.QueryRow("SELECT format FROM notes WHERE node_id=?", overview.ID).Scan(&format)
|
||||
if err != nil {
|
||||
t.Errorf("expected notes record: %v", err)
|
||||
}
|
||||
if format != "markdown" {
|
||||
t.Errorf("expected format 'markdown', got %q", format)
|
||||
}
|
||||
|
||||
// Verify sync ops were recorded for child
|
||||
ops, err := app.sync.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatalf("get ops: %v", err)
|
||||
}
|
||||
foundNoteOp := false
|
||||
for _, op := range ops {
|
||||
if op.EntityID == overview.ID && op.OpType == syncsvc.OpCreate {
|
||||
foundNoteOp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundNoteOp {
|
||||
t.Error("expected sync OpCreate for Overview note child")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_DeleteNodeWithMissingFileDoesNotCorruptDB(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
// Create a folder with a child note
|
||||
parent, _ := app.CreateNodeFromTemplate("", "DeleteTest", "folder.default")
|
||||
noteNode, fileRec, err := app.notes.Create(parent.ID, "TestNote", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
// Delete the physical file to simulate a missing file
|
||||
physPath := filepath.Join(app.vault, fileRec.Path)
|
||||
os.Remove(physPath)
|
||||
|
||||
// Delete the parent (should handle missing file gracefully)
|
||||
if err := app.DeleteNode(parent.ID); err != nil {
|
||||
t.Fatalf("delete parent with missing file: %v", err)
|
||||
}
|
||||
|
||||
// Verify all nodes are soft-deleted
|
||||
_, err = app.nodes.GetActive(parent.ID)
|
||||
if err == nil {
|
||||
t.Error("expected parent to be soft-deleted")
|
||||
}
|
||||
_, err = app.nodes.GetActive(noteNode.ID)
|
||||
if err == nil {
|
||||
t.Error("expected note to be soft-deleted")
|
||||
}
|
||||
|
||||
// VaultCheck should be healthy (no orphan references)
|
||||
result, err := app.VaultCheck()
|
||||
if err != nil {
|
||||
t.Fatalf("vault check: %v", err)
|
||||
}
|
||||
if !result.Healthy {
|
||||
t.Logf("vault check errors (may be acceptable): %v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_TemplateChildrenSyncRoundtrip(t *testing.T) {
|
||||
app1, vault1 := setupTestApp(t)
|
||||
defer app1.db.Close()
|
||||
defer os.RemoveAll(vault1)
|
||||
|
||||
// Create a project from template on app1 (creates parent + default children)
|
||||
proj, err := app1.CreateNodeFromTemplate("", "RoundtripProj", "project.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
// Collect all sync ops from app1
|
||||
ops1, err := app1.sync.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatalf("get ops: %v", err)
|
||||
}
|
||||
if len(ops1) < 4 {
|
||||
t.Fatalf("expected at least 4 sync ops (1 parent + 3 folders + 1 note), got %d", len(ops1))
|
||||
}
|
||||
|
||||
// Create app2 (simulating another device)
|
||||
app2, vault2 := setupTestApp(t)
|
||||
defer app2.db.Close()
|
||||
defer os.RemoveAll(vault2)
|
||||
|
||||
// Apply all ops on app2
|
||||
for _, op := range ops1 {
|
||||
if err := app2.applyRemoteOp(op); err != nil {
|
||||
// Skip notes that reference files not in blob store
|
||||
if op.EntityType == "note" && op.OpType == "create" {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("apply remote op %s/%s: %v", op.EntityType, op.OpType, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create physical files for notes on app2 (since blobs aren't shared locally)
|
||||
createOps := 0
|
||||
for _, op := range ops1 {
|
||||
if op.EntityType == syncsvc.EntityNote && op.OpType == syncsvc.OpCreate {
|
||||
var payload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
dest := filepath.Join(vault2, payload.Path)
|
||||
os.MkdirAll(filepath.Dir(dest), 0o750)
|
||||
os.WriteFile(dest, []byte(payload.Content), 0o640)
|
||||
createOps++
|
||||
}
|
||||
}
|
||||
|
||||
// Verify app2 has the project node
|
||||
proj2, err := app2.nodes.GetActive(proj.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("app2: expected project node: %v", err)
|
||||
}
|
||||
if proj2.Title != "RoundtripProj" {
|
||||
t.Errorf("app2: expected title %q, got %q", "RoundtripProj", proj2.Title)
|
||||
}
|
||||
|
||||
// Verify app2 has all child nodes
|
||||
children2, err := app2.nodes.ListChildren(proj.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("app2: list children: %v", err)
|
||||
}
|
||||
|
||||
expectedChildren := map[string]string{
|
||||
"Documents": "folder",
|
||||
"Notes": "folder",
|
||||
"Files": "folder",
|
||||
"Overview": "note",
|
||||
}
|
||||
found := make(map[string]bool)
|
||||
for _, child := range children2 {
|
||||
expectedType, ok := expectedChildren[child.Title]
|
||||
if !ok {
|
||||
t.Errorf("app2: unexpected child %q", child.Title)
|
||||
continue
|
||||
}
|
||||
if child.Type != expectedType {
|
||||
t.Errorf("app2: child %q expected type %q, got %q", child.Title, expectedType, child.Type)
|
||||
}
|
||||
found[child.Title] = true
|
||||
}
|
||||
for title := range expectedChildren {
|
||||
if !found[title] {
|
||||
t.Errorf("app2: missing child %q", title)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify app2 has the Overview file record and note
|
||||
for _, child := range children2 {
|
||||
if child.Title == "Overview" {
|
||||
records, err := app2.files.ListByNode(child.ID)
|
||||
if err != nil {
|
||||
t.Errorf("app2: list file records for Overview: %v", err)
|
||||
continue
|
||||
}
|
||||
if len(records) == 0 {
|
||||
t.Error("app2: expected file record for Overview")
|
||||
}
|
||||
// Verify notes record exists
|
||||
var format string
|
||||
err = app2.db.QueryRow("SELECT format FROM notes WHERE node_id=?", child.ID).Scan(&format)
|
||||
if err != nil {
|
||||
t.Errorf("app2: expected notes record: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_TemplateChildrenBackwardCompat(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// Simulate a remote node create with template_id but without separate child ops
|
||||
op := syncsvc.Op{
|
||||
EntityType: syncsvc.EntityNode,
|
||||
EntityID: "backward-compat-node-1",
|
||||
OpType: syncsvc.OpCreate,
|
||||
PayloadJSON: `{
|
||||
"id": "backward-compat-node-1",
|
||||
"parent_id": "",
|
||||
"type": "project",
|
||||
"title": "BackwardCompatProj",
|
||||
"slug": "backward-compat-proj",
|
||||
"template_id": "project.default",
|
||||
"fs_path": "BackwardCompatProj",
|
||||
"section": "",
|
||||
"sort_order": 0,
|
||||
"archived": false,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z"
|
||||
}`,
|
||||
}
|
||||
|
||||
if err := app.applyRemoteOp(op); err != nil {
|
||||
t.Fatalf("apply remote op: %v", err)
|
||||
}
|
||||
|
||||
// Verify the parent was created
|
||||
n, err := app.nodes.GetActive("backward-compat-node-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get node: %v", err)
|
||||
}
|
||||
if n.Title != "BackwardCompatProj" {
|
||||
t.Errorf("expected title 'BackwardCompatProj', got %q", n.Title)
|
||||
}
|
||||
|
||||
// Verify template children were created by ensureTemplateChildren
|
||||
children, err := app.nodes.ListChildren("backward-compat-node-1", false)
|
||||
if err != nil {
|
||||
t.Fatalf("list children: %v", err)
|
||||
}
|
||||
if len(children) < 3 {
|
||||
t.Errorf("expected at least 3 template children, got %d", len(children))
|
||||
}
|
||||
|
||||
expectedChildren := map[string]string{
|
||||
"Documents": "folder",
|
||||
"Notes": "folder",
|
||||
"Files": "folder",
|
||||
"Overview": "note",
|
||||
}
|
||||
for _, child := range children {
|
||||
expectedType, ok := expectedChildren[child.Title]
|
||||
if !ok {
|
||||
t.Errorf("unexpected child %q", child.Title)
|
||||
continue
|
||||
}
|
||||
if child.Type != expectedType {
|
||||
t.Errorf("child %q expected type %q, got %q", child.Title, expectedType, child.Type)
|
||||
}
|
||||
if child.Type == "folder" && child.FsPath == "" {
|
||||
t.Errorf("child %q has empty fs_path", child.Title)
|
||||
}
|
||||
// Verify physical folder/file exists
|
||||
if child.Type == "folder" {
|
||||
physPath := filepath.Join(vault, child.FsPath)
|
||||
if _, err := os.Stat(physPath); os.IsNotExist(err) {
|
||||
t.Errorf("expected physical folder at %s", physPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func listNames(entries []os.DirEntry) []string {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
| 11 | **Wails Desktop GUI** | ✅ выполнено (v2, full Svelte UI) |
|
||||
| 12 | **Files/Folders full workflow** | ✅ выполнено (copy/link/import/tree) |
|
||||
| 13 | **Drag-and-drop** | ✅ выполнено (internal + external drops) |
|
||||
| 14 | **MVP stabilization** | 🔄 в процессе — smoke-тесты, docs, go test |
|
||||
| 14 | **MVP stabilization** | ✅ выполнено — atomicity audit, template children as nodes, fs_path validation, descendant move protection, delete atomicity, sync_apply backward compat, sync roundtrip tests |
|
||||
| 15 | Sync Server + Client | 🔒 PAUSED — HTTP API key, push/pull, blob sync |
|
||||
| 16 | Activity Suggestions | 🔒 PAUSED — worklog suggestions from activity_events |
|
||||
| 17 | File Scanner/Watcher | 🔒 PAUSED — fsnotify, snapshot scanner, missing file detection |
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
| 22 | Integrity Check + Repair | 🔒 PAUSED — checksums, crash recovery |
|
||||
| 23 | New templates/integrations | 🔒 PAUSED — community plugins |
|
||||
|
||||
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization). Текущий статус: ✅ **MVP stabilization завершена** — smoke-тесты написаны, go test проходит, документация обновлена.
|
||||
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization). Текущий статус: ✅ **MVP stabilization завершена** — все операции атомарны (DB+FS), template файлы/папки создаются как полноценные ноды, fs_path валидируется, sync_apply создаёт template children, 24 integration tests проходят.
|
||||
|
||||
> **Wails v3 → v2 migration:** Wails v3 alpha.96 показал SIGSEGV на Linux desktop (GTK/X11). Wails v2 stable выбран как GUI base для MVP. Миграция в процессе (ветка `gui/migrate-wails-v2`).
|
||||
|
||||
|
|
@ -51,9 +51,9 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
|||
|
||||
---
|
||||
|
||||
## Текущий этап: MVP Stabilization
|
||||
## Текущий этап: MVP Stabilization ✅
|
||||
|
||||
**Цель:** стабилизация MVP — smoke-тесты, go test, документация.
|
||||
**Цель:** стабилизация MVP — атомарность операций, template ноды, fs_path инварианты, sync roundtrip тесты.
|
||||
|
||||
**Прогресс Wails v2 Desktop GUI:**
|
||||
- ✅ Wails v2 shell (window opens, no SIGSEGV)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@
|
|||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte-language-server": "^0.18.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-language-server": "^5.3.0",
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
},
|
||||
|
|
@ -27,6 +30,33 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emmetio/abbreviation": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz",
|
||||
"integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emmetio/scanner": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@emmetio/css-abbreviation": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz",
|
||||
"integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emmetio/scanner": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@emmetio/scanner": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz",
|
||||
"integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
|
|
@ -855,6 +885,35 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vscode/emmet-helper": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.8.4.tgz",
|
||||
"integrity": "sha512-lUki5QLS47bz/U8IlG9VQ+1lfxMtxMZENmU5nu4Z71eOD5j9FK0SmYGL5NiVJg9WBWeAU0VxRADMY2Qpq7BfVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emmet": "^2.3.0",
|
||||
"jsonc-parser": "^2.3.0",
|
||||
"vscode-languageserver-textdocument": "^1.0.1",
|
||||
"vscode-languageserver-types": "^3.15.1",
|
||||
"vscode-nls": "^5.0.0",
|
||||
"vscode-uri": "^2.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/emmet-helper/node_modules/vscode-uri": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz",
|
||||
"integrity": "sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vscode/l10n": {
|
||||
"version": "0.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
|
||||
"integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
|
|
@ -888,6 +947,22 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/code-red": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
|
||||
|
|
@ -934,6 +1009,13 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/dedent-js": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz",
|
||||
"integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
|
|
@ -944,6 +1026,23 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emmet": {
|
||||
"version": "2.4.11",
|
||||
"resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
|
||||
"integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/scanner",
|
||||
"./packages/abbreviation",
|
||||
"./packages/css-abbreviation",
|
||||
"./"
|
||||
],
|
||||
"dependencies": {
|
||||
"@emmetio/abbreviation": "^2.3.3",
|
||||
"@emmetio/css-abbreviation": "^2.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
|
|
@ -993,6 +1092,24 @@
|
|||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -1008,6 +1125,13 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/globrex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
|
|
@ -1018,6 +1142,13 @@
|
|||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz",
|
||||
"integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
|
|
@ -1035,6 +1166,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -1126,6 +1264,47 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
|
||||
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-svelte": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.2.tgz",
|
||||
"integrity": "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"prettier": "^3.0.0",
|
||||
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||
|
|
@ -1178,6 +1357,26 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
@ -1227,6 +1426,101 @@
|
|||
"svelte": "^3.19.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-language-server": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte-language-server/-/svelte-language-server-0.18.1.tgz",
|
||||
"integrity": "sha512-rQ1uQxxiul2zOUaDUsY6B115Xh8Yjb2Tmby/hjGoq8VKCmd7jD1qaVNqMdxW0nvoocRnJdJ3uXOOaGmXPTxOcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"@vscode/emmet-helper": "2.8.4",
|
||||
"chokidar": "^4.0.1",
|
||||
"estree-walker": "^2.0.1",
|
||||
"fdir": "^6.2.0",
|
||||
"globrex": "^0.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"prettier": "~3.3.3",
|
||||
"prettier-plugin-svelte": "^3.5.0",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte2tsx": "~0.7.56",
|
||||
"typescript-auto-import-cache": "^0.3.6",
|
||||
"vscode-css-languageservice": "~6.3.5",
|
||||
"vscode-html-languageservice": "~5.4.0",
|
||||
"vscode-languageserver": "9.0.1",
|
||||
"vscode-languageserver-protocol": "3.17.5",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"vscode-uri": "~3.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"svelteserver": "bin/server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.2 || ^6.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-language-server/node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/svelte2tsx": {
|
||||
"version": "0.7.56",
|
||||
"resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.56.tgz",
|
||||
"integrity": "sha512-NTvqqL+goYlW8gWNajk81L07+uu7jw5V2m1Az5MZbYm3GEydcHXh+uTrLHM9SuGuaqCtF90vlMXkOVBotfH94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dedent-js": "^1.0.1",
|
||||
"scule": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0",
|
||||
"typescript": "^4.9.4 || ^5.0.0 || ^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-auto-import-cache": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz",
|
||||
"integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.8"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-language-server": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-language-server/-/typescript-language-server-5.3.0.tgz",
|
||||
"integrity": "sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"typescript-language-server": "lib/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
|
@ -1301,6 +1595,94 @@
|
|||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-css-languageservice": {
|
||||
"version": "6.3.10",
|
||||
"resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz",
|
||||
"integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vscode/l10n": "^0.0.18",
|
||||
"vscode-languageserver-textdocument": "^1.0.12",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"vscode-uri": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-html-languageservice": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.4.0.tgz",
|
||||
"integrity": "sha512-9/cbc90BSYCghmHI7/VbWettHZdC7WYpz2g5gBK6UDUI1MkZbM773Q12uAYJx9jzAiNHPpyo6KzcwmcnugncAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vscode/l10n": "^0.0.18",
|
||||
"vscode-languageserver-textdocument": "^1.0.12",
|
||||
"vscode-languageserver-types": "^3.17.5",
|
||||
"vscode-uri": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
|
||||
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-languageserver-protocol": "3.17.5"
|
||||
},
|
||||
"bin": {
|
||||
"installServerIntoExtension": "bin/installServerIntoExtension"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-protocol": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-jsonrpc": "8.2.0",
|
||||
"vscode-languageserver-types": "3.17.5"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-textdocument": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
|
||||
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-languageserver-types": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-nls": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz",
|
||||
"integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@
|
|||
"build": "vite build --mode production",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte-language-server": "^0.18.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-language-server": "^5.3.0",
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -450,29 +451,86 @@ func (s *Service) PreviewImport(sourcePath string) (*ImportSummary, error) {
|
|||
}
|
||||
|
||||
// DeleteNodeAndChildren soft-deletes a node and all descendants,
|
||||
// moving vault files to trash.
|
||||
// moving vault files to trash. All DB updates happen inside a transaction
|
||||
// so that partial failure does not leave an inconsistent DB state.
|
||||
func (s *Service) DeleteNodeAndChildren(nodeID string) error {
|
||||
children, _ := s.nodes.ListChildren(nodeID, false)
|
||||
for i := range children {
|
||||
if err := s.DeleteNodeAndChildren(children[i].ID); err != nil {
|
||||
return err
|
||||
// Collect all nodes to delete bottom-up (children before parent).
|
||||
var toDelete []*nodes.Node
|
||||
var collect func(id string)
|
||||
collect = func(id string) {
|
||||
children, err := s.nodes.ListChildren(id, false)
|
||||
if err != nil {
|
||||
children = nil
|
||||
}
|
||||
for i := range children {
|
||||
collect(children[i].ID)
|
||||
}
|
||||
n, err := s.nodes.GetActive(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
toDelete = append(toDelete, n)
|
||||
}
|
||||
collect(nodeID)
|
||||
|
||||
// Phase 1: FS trash moves (best-effort, collect errors).
|
||||
var trashErrors []string
|
||||
var movedTrash []struct{ src, dst string }
|
||||
for _, n := range toDelete {
|
||||
_ = s.deleteFileRecords(n.ID)
|
||||
if n.FsPath == "" {
|
||||
continue
|
||||
}
|
||||
src, err := s.vaultPath(n.FsPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info, statErr := os.Stat(src); statErr != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title))
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
trashErrors = append(trashErrors, fmt.Sprintf("node %s: %v", n.ID, err))
|
||||
} else {
|
||||
movedTrash = append(movedTrash, struct{ src, dst string }{src, dst})
|
||||
}
|
||||
}
|
||||
_ = s.deleteFileRecords(nodeID)
|
||||
n, err := s.nodes.GetActive(nodeID)
|
||||
if err == nil && n.FsPath != "" {
|
||||
src, vaultErr := s.vaultPath(n.FsPath)
|
||||
if vaultErr != nil {
|
||||
src = filepath.Join(s.vaultRoot, n.FsPath)
|
||||
}
|
||||
if info, statErr := os.Stat(src); statErr == 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)
|
||||
|
||||
// Phase 2: DB soft-deletes in a single transaction.
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, n := range toDelete {
|
||||
t := now()
|
||||
_, err := tx.Exec(
|
||||
`UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
|
||||
t, t, n.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("soft-delete %s: %w", n.ID, err)
|
||||
}
|
||||
}
|
||||
return s.nodes.SoftDelete(nodeID)
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
// Rollback trash moves (best-effort).
|
||||
for _, mt := range movedTrash {
|
||||
if rerr := os.Rename(mt.dst, mt.src); rerr != nil {
|
||||
log.Printf("rollback trash move failed: %v", rerr)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
|
||||
if len(trashErrors) > 0 {
|
||||
log.Printf("warn: trash errors during delete (DB was updated): %v", trashErrors)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) deleteFileRecords(nodeID string) error {
|
||||
|
|
@ -480,8 +538,14 @@ func (s *Service) deleteFileRecords(nodeID string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errs []string
|
||||
for _, r := range records {
|
||||
_ = s.DeleteToTrash(r.ID)
|
||||
if err := s.DeleteToTrash(r.ID); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("file %s: %v", r.ID, err))
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("trash errors: %v", errs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -698,6 +762,10 @@ func openWithSystem(path string) error {
|
|||
return cmd.Start()
|
||||
}
|
||||
|
||||
func now() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// --- scanning helpers ---
|
||||
|
||||
type scanFace interface {
|
||||
|
|
@ -706,11 +774,11 @@ type scanFace interface {
|
|||
|
||||
func scanRecord(s scanFace) (*Record, error) {
|
||||
var r Record
|
||||
var lastSeen sql.NullString
|
||||
var lastSeen, sha256, mime sql.NullString
|
||||
var createdStr, updatedStr string
|
||||
err := s.Scan(
|
||||
&r.ID, &r.NodeID, &r.Filename, &r.Path, &r.StorageMode,
|
||||
&r.Size, &r.SHA256, &r.MIME,
|
||||
&r.Size, &sha256, &mime,
|
||||
&createdStr, &updatedStr, &lastSeen, &r.Missing)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("file not found")
|
||||
|
|
@ -718,6 +786,12 @@ func scanRecord(s scanFace) (*Record, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sha256.Valid {
|
||||
r.SHA256 = sha256.String
|
||||
}
|
||||
if mime.Valid {
|
||||
r.MIME = mime.String
|
||||
}
|
||||
r.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||
r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||
if lastSeen.Valid {
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ func TestMVPSmoke(t *testing.T) {
|
|||
notePath := ""
|
||||
if len(noteFileRecs) > 0 {
|
||||
notePath = noteFileRecs[0].Path
|
||||
if _, err := os.Stat(notePath); os.IsNotExist(err) {
|
||||
if _, err := os.Stat(filepath.Join(vaultDir, notePath)); os.IsNotExist(err) {
|
||||
t.Errorf("note file not on disk: %s", notePath)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue