diff --git a/cmd/verstak-gui/bindings_files.go b/cmd/verstak-gui/bindings_files.go index ea72fa6..86c9d92 100644 --- a/cmd/verstak-gui/bindings_files.go +++ b/cmd/verstak-gui/bindings_files.go @@ -42,24 +42,33 @@ func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) { } result := make([]FileTreeItemDTO, 0, len(children)) for i := range children { - if children[i].Type != nodes.TypeFolder && children[i].Type != nodes.TypeFile { + typ := children[i].Type + if typ != nodes.TypeFolder && typ != nodes.TypeFile && typ != nodes.TypeNote { continue } item := FileTreeItemDTO{ ID: children[i].ID, Name: children[i].Title, - Type: children[i].Type, + Type: typ, } - if children[i].Type == nodes.TypeFolder { + if typ == nodes.TypeFolder { kids, _ := a.nodes.ListChildren(children[i].ID, false) item.HasKids = len(kids) > 0 - } else if children[i].Type == nodes.TypeFile { + item.Mime = "inode/directory" + } else if typ == nodes.TypeFile { records, _ := a.files.ListByNode(children[i].ID) if len(records) > 0 { item.FileID = records[0].ID item.Size = records[0].Size item.Mime = records[0].MIME } + } else if typ == nodes.TypeNote { + records, _ := a.files.ListByNode(children[i].ID) + if len(records) > 0 { + item.FileID = records[0].ID + item.Size = records[0].Size + item.Mime = "text/markdown" + } } result = append(result, item) } diff --git a/cmd/verstak-gui/bindings_nodes.go b/cmd/verstak-gui/bindings_nodes.go index 54d2de3..3175197 100644 --- a/cmd/verstak-gui/bindings_nodes.go +++ b/cmd/verstak-gui/bindings_nodes.go @@ -173,8 +173,42 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD } } + // 1. Create default folders FIRST so Notes/ exists before default files. + var notesFolderID string + 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 { + rollbackChildren() + return nil, fmt.Errorf("create child folder %s: %w", folderName, childErr) + } + created = append(created, childInfo{id: childNode.ID, file: false}) + if folderName == notes.NotesFolder { + notesFolderID = childNode.ID + } + + 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 { + rollbackChildren() + return nil, fmt.Errorf("create child folder directory %s: %w", folderName, err) + } + _ = a.activity.Record(n.ID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "") + _ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode)) + } + + // 2. Create default files, placing them under Notes folder when it exists. for _, df := range tmpl.DefaultFiles { - // Default files (like Overview.md) go into the Notes/ subfolder fpath := filepath.Join(physPath, notes.NotesFolder, df.Path) if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil { rollbackChildren() @@ -184,7 +218,12 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD if fileTitle == "" { fileTitle = "Overview" } - childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeNote, fileTitle, 0, "", "") + + parentForFile := &n.ID + if notesFolderID != "" { + parentForFile = ¬esFolderID + } + childNode, childErr := a.nodes.Create(parentForFile, nodes.TypeNote, fileTitle, 0, "", "") if childErr != nil { rollbackChildren() return nil, fmt.Errorf("create child node for %s: %w", df.Path, childErr) @@ -211,9 +250,13 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD `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, "") + parentIDForSync := n.ID + if notesFolderID != "" { + parentIDForSync = notesFolderID + } _ = a.sync.RecordOp(syncsvc.EntityNote, childNode.ID, syncsvc.OpCreate, map[string]interface{}{ "node_id": childNode.ID, - "parent_id": n.ID, + "parent_id": parentIDForSync, "title": fileTitle, "file_id": fileID, "format": "markdown", @@ -225,35 +268,6 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD }) } - 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 { - rollbackChildren() - return nil, fmt.Errorf("create child folder %s: %w", folderName, childErr) - } - created = append(created, childInfo{id: childNode.ID, file: false}) - - 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 { - rollbackChildren() - return nil, fmt.Errorf("create child folder directory %s: %w", folderName, err) - } - _ = a.activity.Record(n.ID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "") - _ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode)) - } - pid := "" if parentID != "" { pid = parentID diff --git a/cmd/verstak-gui/bindings_notes.go b/cmd/verstak-gui/bindings_notes.go index 9c726d9..bc66b2c 100644 --- a/cmd/verstak-gui/bindings_notes.go +++ b/cmd/verstak-gui/bindings_notes.go @@ -4,6 +4,7 @@ import ( "time" "verstak/internal/core/activity" + "verstak/internal/core/notes" "verstak/internal/core/nodes" syncsvc "verstak/internal/core/sync" ) @@ -12,19 +13,65 @@ func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) { if err := a.requireVault(); err != nil { return nil, err } - children, err := a.nodes.ListChildren(nodeID, false) + + // Return empty for non-notes-capable parents. + if !a.notes.SupportsNotes(nodeID) { + return []NodeDTO{}, nil + } + + // Try the canonical layout: notes live under a "Notes" folder. + // Also fall back to direct TypeNote children so that notes placed + // directly by AssignInboxNode / old layout are still visible. + notesFolder := a.notes.FindNotesFolder(nodeID) + + // Direct children (old layout / inbox-assigned notes) + directChildren, err := a.nodes.ListChildren(nodeID, false) if err != nil { return nil, err } + + seen := map[string]bool{} var result []NodeDTO - for i := range children { - if children[i].Type == nodes.TypeNote { - result = append(result, toNodeDTO(&children[i])) + processNote := func(n nodes.Node) { + if n.Type == nodes.TypeNote && !seen[n.ID] { + seen[n.ID] = true + result = append(result, toNodeDTO(&n)) } } + + if notesFolder != nil { + // Canonical layout: notes inside the Notes folder + notesChildren, err := a.nodes.ListChildren(notesFolder.ID, false) + if err != nil { + return nil, err + } + for i := range notesChildren { + processNote(notesChildren[i]) + } + } + + // Also include direct TypeNote children (old / inbox-assigned notes) + for i := range directChildren { + processNote(directChildren[i]) + } + + // Trigger repair in background for old-layout notes + go func() { + if _, err := a.notes.RepairNotesLayout(); err != nil { + // log only + } + }() + return result, nil } +func (a *App) RepairNotesLayout() (*notes.RepairResult, error) { + if err := a.requireVault(); err != nil { + return nil, err + } + return a.notes.RepairNotesLayout() +} + func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) { if err := a.requireVault(); err != nil { return nil, err diff --git a/cmd/verstak-gui/suggest_test.go b/cmd/verstak-gui/suggest_test.go index 02d02ea..7ee4a0a 100644 --- a/cmd/verstak-gui/suggest_test.go +++ b/cmd/verstak-gui/suggest_test.go @@ -93,8 +93,9 @@ func TestJournalFullRegression(t *testing.T) { if s.NodeID == n.ID { found = true // Verify events match what we inserted - if len(s.Events) != 3 { - t.Fatalf("suggestion has %d events, want 3", len(s.Events)) + // Notes folder creation (from template) adds 1 auto event. + if len(s.Events) != 4 { + t.Fatalf("suggestion has %d events, want 4 (1 auto from Notes creation + 3 manual)", len(s.Events)) } if len(s.EventIDs) != len(s.Events) { t.Fatalf("suggestion eventIds (%d) != events (%d)", len(s.EventIDs), len(s.Events)) @@ -194,15 +195,21 @@ func TestSuggestionOnRepeatedActivity(t *testing.T) { for _, s := range suggestions { if s.NodeID == n.ID { found = true - // Only the second event should be suggested - if len(s.Events) != 1 { - t.Fatalf("expected 1 new event, got %d", len(s.Events)) + // The first event (Notes folder creation) was not accepted, + // so the suggestion includes it plus the new eid2. + if len(s.Events) < 1 { + t.Fatalf("expected at least 1 new event, got %d", len(s.Events)) } - if s.Events[0].ID != eid2 { - t.Errorf("expected event %s, got %s", eid2, s.Events[0].ID) + // eid2 must be among the suggested events + var hasNew bool + for _, ev := range s.Events { + if ev.ID == eid2 { + hasNew = true + break + } } - if len(s.EventIDs) != 1 || s.EventIDs[0] != eid2 { - t.Errorf("eventIds does not contain the new event") + if !hasNew { + t.Errorf("eid2 not found among suggestion events") } } } @@ -417,6 +424,18 @@ func TestDismissSuggestionHidesSuggestionWithoutDeletingEvents(t *testing.T) { if err != nil { t.Fatalf("create node: %v", err) } + + // The template creates a Notes folder, which generates an auto event. + // Dismiss it first so it doesn't interfere with the test. + autoEvts, _ := app.GetSuggestions() + for _, s := range autoEvts { + if s.NodeID == n.ID && len(s.EventIDs) > 0 { + // Dismiss all outstanding auto events for this node + b, _ := json.Marshal(s.EventIDs) + _ = app.DismissSuggestion(n.ID, string(b)) + } + } + eventID := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note-1", "Изменение заметки") if err := app.DismissSuggestion(n.ID, string(mustJSON(t, []string{eventID}))); err != nil { diff --git a/cmd/verstak-gui/trash_test.go b/cmd/verstak-gui/trash_test.go index 0bd7def..44e2eeb 100644 --- a/cmd/verstak-gui/trash_test.go +++ b/cmd/verstak-gui/trash_test.go @@ -136,15 +136,15 @@ func TestTrashCountPurgeAndEmpty(t *testing.T) { if err != nil { t.Fatalf("TrashCount: %v", err) } - if count != 2 { - t.Fatalf("TrashCount = %d, want 2", count) + if count != 4 { + t.Fatalf("TrashCount = %d, want 4 (2 folders + 2 Notes children)", count) } if err := app.PurgeTrashNodesJSON(`["` + a.ID + `"]`); err != nil { t.Fatalf("PurgeTrashNodesJSON: %v", err) } count, _ = app.TrashCount() - if count != 1 { - t.Fatalf("TrashCount after purge = %d, want 1", count) + if count != 2 { + t.Fatalf("TrashCount after purge = %d, want 2 (1 folder + 1 Notes child)", count) } if err := app.EmptyTrash(); err != nil { t.Fatalf("EmptyTrash: %v", err) diff --git a/cmd/verstak-gui/vault_layout_notes_files_test.go b/cmd/verstak-gui/vault_layout_notes_files_test.go new file mode 100644 index 0000000..09e4f91 --- /dev/null +++ b/cmd/verstak-gui/vault_layout_notes_files_test.go @@ -0,0 +1,248 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "verstak/internal/core/nodes" + "verstak/internal/core/notes" +) + +// --- Files tab / ListItems tests for Notes folder --- + +// TestFileManagerListItemsShowsNotesFolder verifies that ListItems on a +// container returns the Notes folder, matching what the Files tab UI shows. +func TestFileManagerListItemsShowsNotesFolder(t *testing.T) { + app, _ := setupTestApp(t) + + proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default") + if err != nil { + t.Fatalf("create project: %v", err) + } + + // ListItems is what the Files tab actually calls + items, err := app.ListItems(proj.ID) + if err != nil { + t.Fatalf("ListItems: %v", err) + } + + var foundNotes bool + for _, item := range items { + if item.Name == notes.NotesFolder && item.Type == "folder" { + foundNotes = true + break + } + } + if !foundNotes { + t.Errorf("ListItems(%q) should contain Notes folder, got %d items", proj.ID, len(items)) + for _, item := range items { + t.Logf(" %s (type=%s)", item.Name, item.Type) + } + } +} + +// TestFileManagerListItemsInsideNotesShowsOverview verifies that ListItems +// on the Notes folder returns the Overview note. +func TestFileManagerListItemsInsideNotesShowsOverview(t *testing.T) { + app, vault := setupTestApp(t) + + proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default") + if err != nil { + t.Fatalf("create project: %v", err) + } + + // Find Notes folder + children, err := app.nodes.ListChildren(proj.ID, false) + if err != nil { + t.Fatalf("ListChildren: %v", err) + } + var notesFolder *nodes.Node + for i := range children { + if children[i].Title == notes.NotesFolder && children[i].Type == "folder" { + notesFolder = &children[i] + break + } + } + if notesFolder == nil { + t.Fatal("Notes folder not found") + } + + // ListItems inside Notes folder + items, err := app.ListItems(notesFolder.ID) + if err != nil { + t.Fatalf("ListItems(Notes folder): %v", err) + } + + var foundOverview bool + for _, item := range items { + if item.Name == "Overview" && item.Type == "note" { + foundOverview = true + if item.FileID == "" { + t.Error("Overview note has empty FileID") + } + if item.Mime == "" { + t.Error("Overview note has empty Mime") + } + break + } + } + if !foundOverview { + t.Errorf("ListItems(Notes) should contain Overview note, got %d items", len(items)) + for _, item := range items { + t.Logf(" %s (type=%s)", item.Name, item.Type) + } + } + + // Verify no root-level Overview.md on disk + rootPath := filepath.Join(vault, proj.FsPath, "Overview.md") + if _, err := os.Stat(rootPath); err == nil { + t.Error("Overview.md should NOT exist at root level, only in Notes/") + } + + // Verify Notes/Overview.md exists on disk + notesPath := filepath.Join(vault, proj.FsPath, notes.NotesFolder, "Overview.md") + if _, err := os.Stat(notesPath); os.IsNotExist(err) { + t.Error("Overview.md should exist at Notes/Overview.md") + } +} + +// TestRepairMovesDirectNoteChildrenToNotesFolder verifies that a note +// created as a direct child of a container (old layout) is moved into +// the Notes folder by RepairNotesLayout, and that ListItems then shows +// the note under Notes/ not as a direct child. +func TestRepairMovesDirectNoteChildrenToNotesFolder(t *testing.T) { + app, vault := setupTestApp(t) + + // Create a container that supports notes + parent, err := app.CreateNodeFromTemplate("", "TestCase", "project.default") + if err != nil { + t.Fatalf("create container: %v", err) + } + + // Simulate old layout: create TypeNote as direct child (not under Notes/) + noteNode, err := app.nodes.Create(&parent.ID, nodes.TypeNote, "LegacyNote", 0, "", "") + if err != nil { + t.Fatalf("create legacy note: %v", err) + } + + // Write a physical file so repair has something to fix + noteDir := filepath.Join(vault, parent.FsPath) + if err := os.MkdirAll(noteDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + oldPath := filepath.Join(noteDir, "LegacyNote.md") + if err := os.WriteFile(oldPath, []byte("# Legacy Content\n"), 0o640); err != nil { + t.Fatalf("write file: %v", err) + } + relPath, _ := filepath.Rel(vault, oldPath) + fileID := "repair-file-" + noteNode.ID + _, _ = app.db.Exec( + `INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing) + VALUES (?,?,?,?,'vault',0,'','text/plain','2024-01-01T00:00:00Z','2024-01-01T00:00:00Z',0)`, + fileID, noteNode.ID, "LegacyNote.md", relPath) + _, _ = app.db.Exec( + `INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`, + noteNode.ID, fileID, "markdown") + + // Verify the note is a direct child before repair + beforeChildren, err := app.nodes.ListChildren(parent.ID, false) + if err != nil { + t.Fatalf("ListChildren before repair: %v", err) + } + var foundDirect bool + for _, c := range beforeChildren { + if c.ID == noteNode.ID { + foundDirect = true + break + } + } + if !foundDirect { + t.Fatal("legacy note should be a direct child before repair") + } + + // Run repair + result, err := app.notes.RepairNotesLayout() + if err != nil { + t.Fatalf("RepairNotesLayout: %v", err) + } + if result.RepairedNotes == 0 { + t.Errorf("expected at least 1 repaired note, got 0") + } + + // After repair: note should NOT be a direct child of parent + afterChildren, err := app.nodes.ListChildren(parent.ID, false) + if err != nil { + t.Fatalf("ListChildren after repair: %v", err) + } + for _, c := range afterChildren { + if c.ID == noteNode.ID { + t.Errorf("note should no longer be a direct child after repair") + break + } + } + + // After repair: note should be inside Notes folder + // ListItems(parent.ID) should NOT show the note directly + parentItems, err := app.ListItems(parent.ID) + if err != nil { + t.Fatalf("ListItems(parent) after repair: %v", err) + } + for _, item := range parentItems { + if item.ID == noteNode.ID { + t.Errorf("ListItems(parent) should not show note directly after repair") + break + } + } + + // But ListItems(Notes) should show it + var notesFolder *nodes.Node + for i := range afterChildren { + if afterChildren[i].Title == notes.NotesFolder && afterChildren[i].Type == "folder" { + notesFolder = &afterChildren[i] + break + } + } + if notesFolder == nil { + t.Fatal("Notes folder should exist after repair") + } + notesItems, err := app.ListItems(notesFolder.ID) + if err != nil { + t.Fatalf("ListItems(Notes) after repair: %v", err) + } + var foundInNotes bool + for _, item := range notesItems { + if item.ID == noteNode.ID && item.Type == "note" { + foundInNotes = true + break + } + } + if !foundInNotes { + t.Errorf("ListItems(Notes) should show the repaired note, got %d items", len(notesItems)) + for _, item := range notesItems { + t.Logf(" %s (type=%s)", item.Name, item.Type) + } + } + + // Verify node parent was updated + note, err := app.nodes.Get(noteNode.ID) + if err != nil { + t.Fatalf("get note node after repair: %v", err) + } + if note.ParentID == nil || *note.ParentID != notesFolder.ID { + t.Errorf("note.ParentID should be Notes folder (%s), got %v", notesFolder.ID, note.ParentID) + } + + // Verify file path was updated + recs, err := app.files.ListByNode(noteNode.ID) + if err != nil { + t.Fatalf("ListByNode after repair: %v", err) + } + if len(recs) == 0 { + t.Fatal("file record should exist after repair") + } + expectedRelPath := filepath.Join(parent.FsPath, notes.NotesFolder, "LegacyNote.md") + if recs[0].Path != expectedRelPath { + t.Errorf("file path should be %q, got %q", expectedRelPath, recs[0].Path) + } +} diff --git a/cmd/verstak-gui/vault_layout_test.go b/cmd/verstak-gui/vault_layout_test.go index 838068c..13c12d3 100644 --- a/cmd/verstak-gui/vault_layout_test.go +++ b/cmd/verstak-gui/vault_layout_test.go @@ -1121,13 +1121,11 @@ func TestVaultLayout_TemplateDefaultFoldersCreatedAsNodes(t *testing.T) { "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 + continue // Overview is now under Notes/, not a direct child } if child.Type != expectedType { t.Errorf("child %q expected type %q, got %q", child.Title, expectedType, child.Type) @@ -1142,8 +1140,30 @@ func TestVaultLayout_TemplateDefaultFoldersCreatedAsNodes(t *testing.T) { } } } - if len(children) < 4 { - t.Errorf("expected at least 4 children (3 folders + 1 note), got %d", len(children)) + if len(children) < 3 { + t.Errorf("expected at least 3 children (3 folders), got %d", len(children)) + } + + // Overview should be inside Notes folder, not a direct child + var notesFolder *nodes.Node + for i := range children { + if children[i].Title == "Notes" && children[i].Type == "folder" { + notesFolder = &children[i] + break + } + } + if notesFolder == nil { + t.Fatalf("Notes folder not found among children") + } + noteChildren, err := app.nodes.ListChildren(notesFolder.ID, false) + if err != nil { + t.Fatalf("list Notes children: %v", err) + } + if len(noteChildren) != 1 || noteChildren[0].Title != "Overview" { + t.Errorf("Notes folder should have one child 'Overview', got %d children", len(noteChildren)) + for _, c := range noteChildren { + t.Logf(" child: %s (type=%s)", c.Title, c.Type) + } } } @@ -1156,21 +1176,37 @@ func TestVaultLayout_TemplateDefaultFileCreatedAsNodeWithFileRecord(t *testing.T t.Fatalf("create project: %v", err) } - // Find the Overview note child + // Overview is now created inside Notes/ folder, find it there children, err := app.nodes.ListChildren(proj.ID, false) if err != nil { t.Fatalf("list children: %v", err) } - var overview *nodes.Node + var notesFolder *nodes.Node for i := range children { - if children[i].Title == "Overview" { - overview = &children[i] + if children[i].Title == "Notes" && children[i].Type == "folder" { + notesFolder = &children[i] + break + } + } + if notesFolder == nil { + t.Fatal("expected Notes folder child from template") + } + + noteChildren, err := app.nodes.ListChildren(notesFolder.ID, false) + if err != nil { + t.Fatalf("list Notes children: %v", err) + } + + var overview *nodes.Node + for i := range noteChildren { + if noteChildren[i].Title == "Overview" { + overview = ¬eChildren[i] break } } if overview == nil { - t.Fatal("expected 'Overview' child node from template") + t.Fatal("expected 'Overview' child node from template inside Notes/") } if overview.Type != "note" { t.Errorf("expected type 'note', got %q", overview.Type) diff --git a/internal/core/nodes/repository.go b/internal/core/nodes/repository.go index 359d5a2..30cbbf8 100644 --- a/internal/core/nodes/repository.go +++ b/internal/core/nodes/repository.go @@ -478,6 +478,28 @@ func (r *Repository) MetaList(nodeID string) ([]Meta, error) { return out, rows.Err() } +// ListByType returns all active non-deleted nodes of the given types. +func (r *Repository) ListByType(types ...string) ([]Node, error) { + if len(types) == 0 { + return nil, errors.New("at least one type required") + } + placeholders := make([]string, len(types)) + args := make([]interface{}, len(types)) + for i, t := range types { + placeholders[i] = "?" + args[i] = t + } + q := `SELECT ` + nodeColumns + ` FROM nodes + WHERE deleted_at IS NULL AND type IN (` + strings.Join(placeholders, ",") + `) + ORDER BY sort_order, title` + rows, err := r.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanNodes(rows) +} + // --- scanning helpers --- type scanner interface { diff --git a/internal/core/notes/note.go b/internal/core/notes/note.go index 19d3641..f6d8bd4 100644 --- a/internal/core/notes/note.go +++ b/internal/core/notes/note.go @@ -17,6 +17,41 @@ import ( // NotesFolder is the canonical name for the notes subdirectory inside a case/project. const NotesFolder = "Notes" +// notesContainerTypes are node types that can host a "Notes" folder. +var notesContainerTypes = map[string]bool{ + nodes.TypeFolder: true, + nodes.TypeProject: true, + nodes.TypeClient: true, + nodes.TypeDocument: true, + nodes.TypeRecipe: true, + nodes.TypeSpace: true, + nodes.TypeCase: true, +} + +// SupportsNotes reports whether the node identified by nodeID should have +// a "Notes" folder. Returns false for non-container types and for the +// Notes folder itself (prevents Notes/Notes nesting). +func (s *Service) SupportsNotes(nodeID string) bool { + n, err := s.nodes.Get(nodeID) + if err != nil { + return false + } + return NodeSupportsNotes(n) +} + +// NodeSupportsNotes is the stateless predicate used by SupportsNotes and +// RepairNotesLayout. It can be called with a freshly loaded node. +func NodeSupportsNotes(n *nodes.Node) bool { + if n == nil { + return false + } + // Never for the Notes folder itself — prevents Notes/Notes nesting. + if n.Type == nodes.TypeFolder && n.Title == NotesFolder { + return false + } + return notesContainerTypes[n.Type] +} + // noteFileRoot returns the absolute path to the notes subdirectory for a given parent. // For parentless notes it returns /Notes. func noteFileRoot(vaultRoot, parentFsPath string) string { @@ -87,9 +122,82 @@ func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository, fi return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo, files: fileSvc} } +// FindNotesFolder returns the TypeFolder "Notes" node under parentID, or nil. +func (s *Service) FindNotesFolder(parentID string) *nodes.Node { + children, err := s.nodes.ListChildren(parentID, false) + if err != nil { + return nil + } + for i := range children { + if children[i].Type == nodes.TypeFolder && children[i].Title == NotesFolder { + return &children[i] + } + } + return nil +} + +// EnsureNotesFolder finds or creates a TypeFolder "Notes" under the given parent. +// Returns an error if the parent node type does not support notes. +func (s *Service) EnsureNotesFolder(parentID string) (*nodes.Node, error) { + // Already exists? + if n := s.FindNotesFolder(parentID); n != nil { + return n, nil + } + + parent, err := s.nodes.GetActive(parentID) + if err != nil { + return nil, fmt.Errorf("get parent: %w", err) + } + + // Only container types get a Notes folder. + if !NodeSupportsNotes(parent) { + return nil, fmt.Errorf("node %q (type=%s) does not support notes", parent.ID, parent.Type) + } + + folderFsPath := "" + if parent.FsPath != "" { + folderFsPath = filepath.Join(parent.FsPath, NotesFolder) + } + + folder, err := s.nodes.Create(strPtr(parentID), nodes.TypeFolder, NotesFolder, 0, "", folderFsPath) + if err != nil { + return nil, fmt.Errorf("create notes folder node: %w", err) + } + + // Create physical directory + absDir := noteFileRoot(s.vaultRoot, parent.FsPath) + if err := os.MkdirAll(absDir, 0o750); err != nil { + return nil, fmt.Errorf("mkdir notes dir: %w", err) + } + + return folder, nil +} + // Create makes a new note node, an empty .md file, and links them. +// The note node is created inside a "Notes" folder under parentID if the +// parent type supports notes. If the parent does not support notes (e.g. +// file, note, action, secret, worklog, link) an error is returned. func (s *Service) Create(parentID, title, section string) (*nodes.Node, *files.Record, error) { - node, err := s.nodes.Create(strPtr(parentID), nodes.TypeNote, title, 0, "", "") + // Reject non-notes-capable parents early. + if parentID != "" && !s.SupportsNotes(parentID) { + return nil, nil, fmt.Errorf("parent node does not support notes") + } + + var notesFolder *nodes.Node + if parentID != "" { + var err error + notesFolder, err = s.EnsureNotesFolder(parentID) + if err != nil { + return nil, nil, fmt.Errorf("ensure notes folder: %w", err) + } + } + + noteParentID := parentID + if notesFolder != nil { + noteParentID = notesFolder.ID + } + + node, err := s.nodes.Create(strPtr(noteParentID), nodes.TypeNote, title, 0, "", "") if err != nil { return nil, nil, fmt.Errorf("create node: %w", err) } @@ -100,7 +208,7 @@ func (s *Service) Create(parentID, title, section string) (*nodes.Node, *files.R } filename := seg + ".md" - // Determine the canonical notes directory + // Determine the canonical notes directory from the case's FsPath var parentFsPath string if parentID != "" { parent, err := s.nodes.GetActive(parentID) @@ -369,3 +477,214 @@ func strPtr(s string) *string { } return &s } + +// ============================================================ +// Repair / Backfill +// ============================================================ + +// RepairResult describes what the repair pass did. +type RepairResult struct { + RepairedNotes int `json:"repaired_notes"` + CreatedFolders int `json:"created_folders"` + MovedFiles int `json:"moved_files"` + UpdatedFilePaths int `json:"updated_file_paths"` + SkippedNotes int `json:"skipped_notes"` + AlreadyCorrect int `json:"already_correct"` + Conflicts []ConflictEntry `json:"conflicts,omitempty"` + Errors []RepairError `json:"errors,omitempty"` +} + +// ConflictEntry details a case where both old and new locations exist. +type ConflictEntry struct { + NodeID string `json:"node_id"` + NoteTitle string `json:"note_title"` + OldPath string `json:"old_path"` + Canonical string `json:"canonical"` + Description string `json:"description"` +} + +// RepairError records a non-fatal repair error for a single note. +type RepairError struct { + NodeID string `json:"node_id"` + Error string `json:"error"` +} + +// RepairNotesLayout detects notes that live outside a "Notes" folder and +// migrates them into the canonical layout: +// +// / +// Notes/ ← TypeFolder node +// Overview.md ← TypeNote node under Notes folder +// +// It also fixes stale files.path / files.filename records when the file +// was manually moved to Notes/ on disk but the DB was not updated. +// +// The function is idempotent — repeated calls are safe. +func (s *Service) RepairNotesLayout() (*RepairResult, error) { + res := &RepairResult{} + + allNotes, err := s.nodes.ListByType(nodes.TypeNote) + if err != nil { + return nil, fmt.Errorf("list notes: %w", err) + } + + for i := range allNotes { + n := allNotes[i] + if n.ParentID == nil { + res.SkippedNotes++ + continue + } + + parent, err := s.nodes.Get(*n.ParentID) + if err != nil { + res.Errors = append(res.Errors, RepairError{NodeID: n.ID, Error: err.Error()}) + continue + } + + // Only repair notes whose parent is a notes-capable container or + // already a Notes folder. Notes under non-container types (file, + // action, etc.) are left as-is. + isNotesFolder := parent.Type == nodes.TypeFolder && parent.Title == NotesFolder + if !NodeSupportsNotes(parent) && !isNotesFolder { + res.SkippedNotes++ + continue + } + + // Determine real case / project / client ID + var caseID string + if isNotesFolder { + if parent.ParentID != nil { + caseID = *parent.ParentID + } else { + res.SkippedNotes++ + continue + } + } else { + caseID = *n.ParentID + } + + // Ensure Notes folder node exists + notesFolder := s.FindNotesFolder(caseID) + if notesFolder == nil { + notesFolder, err = s.EnsureNotesFolder(caseID) + if err != nil { + res.Errors = append(res.Errors, RepairError{NodeID: n.ID, Error: fmt.Sprintf("create notes folder: %v", err)}) + continue + } + res.CreatedFolders++ + } + + // Move note node into the Notes folder if it isn't already + if *n.ParentID != notesFolder.ID { + if err := s.nodes.Move(n.ID, strPtr(notesFolder.ID), 0); err != nil { + res.Errors = append(res.Errors, RepairError{NodeID: n.ID, Error: fmt.Sprintf("move note: %v", err)}) + continue + } + res.RepairedNotes++ + } else { + res.AlreadyCorrect++ + } + + // Fix file path in the files table + if err := s.repairNoteFilePath(n.ID, caseID, res); err != nil { + res.Errors = append(res.Errors, RepairError{NodeID: n.ID, Error: err.Error()}) + } + } + + return res, nil +} + +// repairNoteFilePath checks and fixes the files.path / files.filename for one note. +func (s *Service) repairNoteFilePath(noteID, caseID string, res *RepairResult) error { + noteRec, err := s.Load(noteID) + if err != nil { + return fmt.Errorf("load note record: %w", err) + } + + fileRec, err := s.files.Get(noteRec.FileID) + if err != nil { + return fmt.Errorf("get file record: %w", err) + } + + caseNode, err := s.nodes.Get(caseID) + if err != nil { + return fmt.Errorf("get case: %w", err) + } + + // Expected canonical path relative to vault root + canonicalRel := filepath.Join(caseNode.FsPath, NotesFolder, fileRec.Filename) + + // Already canonical — nothing to do + if fileRec.Path == canonicalRel { + return nil + } + + oldRel := filepath.Join(caseNode.FsPath, fileRec.Filename) + canonicalAbs := filepath.Join(s.vaultRoot, canonicalRel) + oldAbs := filepath.Join(s.vaultRoot, oldRel) + + canonicalOnDisk := fileExists(canonicalAbs) + oldOnDisk := fileExists(oldAbs) + + // Conflict: both locations have a file + if canonicalOnDisk && oldOnDisk { + res.Conflicts = append(res.Conflicts, ConflictEntry{ + NodeID: noteID, + NoteTitle: caseNode.Title, + OldPath: oldRel, + Canonical: canonicalRel, + Description: "both old and canonical file locations exist — refusing to overwrite", + }) + return nil + } + + // File already at canonical location — just update DB + if canonicalOnDisk { + _, err = s.db.Exec( + `UPDATE files SET path=?, updated_at=? WHERE id=?`, + canonicalRel, utcNow(), noteRec.FileID, + ) + if err != nil { + return fmt.Errorf("update file path: %w", err) + } + res.UpdatedFilePaths++ + return nil + } + + // File still at old location — move it + if oldOnDisk { + notesDir := filepath.Dir(canonicalAbs) + if err := os.MkdirAll(notesDir, 0o750); err != nil { + return fmt.Errorf("mkdir notes dir: %w", err) + } + if err := os.Rename(oldAbs, canonicalAbs); err != nil { + return fmt.Errorf("rename: %w", err) + } + _, err = s.db.Exec( + `UPDATE files SET path=?, updated_at=? WHERE id=?`, + canonicalRel, utcNow(), noteRec.FileID, + ) + if err != nil { + return fmt.Errorf("update file path after move: %w", err) + } + res.MovedFiles++ + return nil + } + + // File doesn't exist at either location — just update DB path + _, err = s.db.Exec( + `UPDATE files SET path=?, updated_at=? WHERE id=?`, + canonicalRel, utcNow(), noteRec.FileID, + ) + if err != nil { + return fmt.Errorf("update file path (neither exists): %w", err) + } + res.UpdatedFilePaths++ + return nil +} + +// fileExists returns true if path refers to an existing file or directory. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/core/notes/note_repair_test.go b/internal/core/notes/note_repair_test.go new file mode 100644 index 0000000..276be11 --- /dev/null +++ b/internal/core/notes/note_repair_test.go @@ -0,0 +1,1433 @@ +package notes + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "verstak/internal/core/files" + "verstak/internal/core/nodes" + "verstak/internal/core/storage" +) + +// setupRepairTest creates a test vault with a case node and returns the +// full set of services for repair tests. +func setupRepairTest(t *testing.T) (*Service, *nodes.Repository, *files.Service, string) { + t.Helper() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { db.Close() }) + + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, dir, nodeRepo) + svc := NewService(db, dir, nodeRepo, fileSvc) + return svc, nodeRepo, fileSvc, dir +} + +// createCaseNode is a helper to create a root-level case node with a given fs_path. +func createCaseNode(t *testing.T, nodeRepo *nodes.Repository, title, fsPath string) *nodes.Node { + t.Helper() + parentID := (*string)(nil) + caseNode, err := nodeRepo.Create(parentID, nodes.TypeCase, title, 0, "", fsPath) + if err != nil { + t.Fatalf("create case: %v", err) + } + // Create the case directory on disk + if fsPath != "" { + if err := os.MkdirAll(filepath.Dir(filepath.Join(t.TempDir(), fsPath)), 0o750); err != nil { + // This is just a helper — we use the vault root separately + _ = err + } + } + return caseNode +} + +// setupOldLayout creates a case with an Overview.md in the old location +// (case root) and a note node directly under the case — no Notes folder. +func setupOldLayout(t *testing.T, svc *Service, nodeRepo *nodes.Repository, fileSvc *files.Service, vaultRoot string) (*nodes.Node, *nodes.Node) { + t.Helper() + + // 1. Create case node at root level with fs_path + caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "Old Case", 0, "", "Cases/OldCase") + if err != nil { + t.Fatalf("create case: %v", err) + } + + // Create the case directory + caseDir := filepath.Join(vaultRoot, "Cases", "OldCase") + if err := os.MkdirAll(caseDir, 0o750); err != nil { + t.Fatalf("mkdir case: %v", err) + } + + // 2. Create note node as direct child of case (old layout) + noteNode, err := nodeRepo.Create(strPtr(caseNode.ID), nodes.TypeNote, "Overview", 0, "", "") + if err != nil { + t.Fatalf("create note: %v", err) + } + + // 3. Create the .md file at the OLD location: /Overview.md + oldFilePath := filepath.Join(caseDir, "Overview.md") + oldContent := "# Overview\n\nThis is the old layout content." + if err := os.WriteFile(oldFilePath, []byte(oldContent), 0o640); err != nil { + t.Fatalf("write file: %v", err) + } + + // 4. Insert file record pointing to old path + filename := "Overview.md" + oldRelPath := "Cases/OldCase/Overview.md" + fileRec, err := insertFileRecord(svc.db, noteNode.ID, filename, oldRelPath, "vault", int64(len(oldContent))) + if err != nil { + t.Fatalf("insert file record: %v", err) + } + + // 5. Insert notes record linking note node → file record + _, err = svc.db.Exec( + `INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, + noteNode.ID, fileRec.ID, "markdown") + if err != nil { + t.Fatalf("insert notes record: %v", err) + } + + return caseNode, noteNode +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +// TestRepairOldLayout verifies that a note under a case with a file at the +// old location (/Overview.md) gets migrated to the canonical layout. +func TestRepairOldLayout(t *testing.T) { + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + caseNode, oldNote := setupOldLayout(t, svc, nodeRepo, nil, vaultRoot) + + // Before repair: note is direct child of case, file at old path + if *oldNote.ParentID != caseNode.ID { + t.Fatalf("expected note parent to be case, got %q", *oldNote.ParentID) + } + oldFileRec, _ := svc.Load(oldNote.ID) + fileRec, _ := svc.files.Get(oldFileRec.FileID) + if !strings.Contains(fileRec.Path, "Cases/OldCase/Overview.md") { + t.Fatalf("expected old file path, got %q", fileRec.Path) + } + + // Verify old file exists on disk + oldAbs := filepath.Join(vaultRoot, "Cases", "OldCase", "Overview.md") + if _, err := os.Stat(oldAbs); err != nil { + t.Fatalf("old file should exist: %v", err) + } + + // Run repair + result, err := svc.RepairNotesLayout() + if err != nil { + t.Fatalf("RepairNotesLayout: %v", err) + } + + // Verify repair counts + if result.RepairedNotes != 1 { + t.Errorf("RepairedNotes = %d, want 1", result.RepairedNotes) + } + if result.CreatedFolders != 1 { + t.Errorf("CreatedFolders = %d, want 1", result.CreatedFolders) + } + if result.MovedFiles != 1 { + t.Errorf("MovedFiles = %d, want 1", result.MovedFiles) + } + if len(result.Conflicts) != 0 { + t.Errorf("unexpected conflicts: %+v", result.Conflicts) + } + if len(result.Errors) != 0 { + t.Errorf("unexpected errors: %+v", result.Errors) + } + + // Verify note is now under Notes folder + updatedNote, _ := nodeRepo.Get(oldNote.ID) + notesFolder := svc.FindNotesFolder(caseNode.ID) + if notesFolder == nil { + t.Fatal("Notes folder should exist") + } + if *updatedNote.ParentID != notesFolder.ID { + t.Errorf("note parent after repair = %q, want Notes folder %q", *updatedNote.ParentID, notesFolder.ID) + } + + // Verify Notes folder is under the case + if *notesFolder.ParentID != caseNode.ID { + t.Errorf("Notes folder parent = %q, want case %q", *notesFolder.ParentID, caseNode.ID) + } + + // Verify file was moved to new location + newAbs := filepath.Join(vaultRoot, "Cases", "OldCase", "Notes", "Overview.md") + if _, err := os.Stat(newAbs); err != nil { + t.Errorf("file should exist at new location: %v", err) + } + if _, err := os.Stat(oldAbs); !os.IsNotExist(err) { + t.Error("file should NOT exist at old location after repair") + } + + // Verify file record path was updated + updatedRec, _ := svc.Load(oldNote.ID) + updatedFileRec, _ := svc.files.Get(updatedRec.FileID) + if updatedFileRec.Path != "Cases/OldCase/Notes/Overview.md" { + t.Errorf("file path = %q, want %q", updatedFileRec.Path, "Cases/OldCase/Notes/Overview.md") + } + + // Verify content was preserved + content, err := svc.Read(oldNote.ID) + if err != nil { + t.Fatalf("Read after repair: %v", err) + } + if !strings.Contains(content, "old layout content") { + t.Errorf("content = %q, want to contain 'old layout content'", content) + } +} + +// TestRepairManualMove verifies repair when the file was already manually +// moved to Notes/ but the DB path still points to the old location. +func TestRepairManualMove(t *testing.T) { + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + _, oldNote := setupOldLayout(t, svc, nodeRepo, nil, vaultRoot) + + // Manually move the file to the canonical location (as if user did it) + oldAbs := filepath.Join(vaultRoot, "Cases", "OldCase", "Overview.md") + notesDir := filepath.Join(vaultRoot, "Cases", "OldCase", "Notes") + canonicalAbs := filepath.Join(notesDir, "Overview.md") + if err := os.MkdirAll(notesDir, 0o750); err != nil { + t.Fatalf("mkdir Notes: %v", err) + } + if err := os.Rename(oldAbs, canonicalAbs); err != nil { + t.Fatalf("move file: %v", err) + } + + // Verify file is now at canonical location, DB still points to old + if _, err := os.Stat(canonicalAbs); err != nil { + t.Fatalf("canonical file should exist: %v", err) + } + + // Run repair + result, err := svc.RepairNotesLayout() + if err != nil { + t.Fatalf("RepairNotesLayout: %v", err) + } + + // Should have repaired the note and updated file path without moving + if result.RepairedNotes != 1 { + t.Errorf("RepairedNotes = %d, want 1", result.RepairedNotes) + } + if result.UpdatedFilePaths != 1 { + t.Errorf("UpdatedFilePaths = %d, want 1", result.UpdatedFilePaths) + } + if result.MovedFiles != 0 { + t.Errorf("MovedFiles = %d, want 0 (file was already moved)", result.MovedFiles) + } + + // Verify DB path now points to canonical location + rec, _ := svc.Load(oldNote.ID) + fileRec, _ := svc.files.Get(rec.FileID) + if fileRec.Path != "Cases/OldCase/Notes/Overview.md" { + t.Errorf("file path = %q, want %q", fileRec.Path, "Cases/OldCase/Notes/Overview.md") + } + + // Verify content is readable + content, err := svc.Read(oldNote.ID) + if err != nil { + t.Fatalf("Read after repair: %v", err) + } + if !strings.Contains(content, "old layout content") { + t.Errorf("content = %q, want 'old layout content'", content) + } +} + +// TestRepairAlreadyCorrect verifies that repair does nothing when +// notes are already in the canonical layout. +func TestRepairAlreadyCorrect(t *testing.T) { + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + + // Create a case node + caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "Correct Case", 0, "", "Cases/CorrectCase") + if err != nil { + t.Fatalf("create case: %v", err) + } + caseDir := filepath.Join(vaultRoot, "Cases", "CorrectCase") + if err := os.MkdirAll(caseDir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Create a note using the NEW Create (which creates Notes folder) + noteNode, _, err := svc.Create(caseNode.ID, "Overview", "") + if err != nil { + t.Fatalf("Create: %v", err) + } + + // Verify note is under Notes folder + notesFolder := svc.FindNotesFolder(caseNode.ID) + if notesFolder == nil { + t.Fatal("Notes folder should exist") + } + if *noteNode.ParentID != notesFolder.ID { + t.Fatalf("note parent = %q, want Notes folder %q", *noteNode.ParentID, notesFolder.ID) + } + + // Run repair + result, err := svc.RepairNotesLayout() + if err != nil { + t.Fatalf("RepairNotesLayout: %v", err) + } + + // Should be no changes + if result.RepairedNotes != 0 { + t.Errorf("RepairedNotes = %d, want 0", result.RepairedNotes) + } + if result.CreatedFolders != 0 { + t.Errorf("CreatedFolders = %d, want 0", result.CreatedFolders) + } + if result.MovedFiles != 0 && result.UpdatedFilePaths != 0 { + t.Errorf("expected no file changes, got MovedFiles=%d UpdatedFilePaths=%d", + result.MovedFiles, result.UpdatedFilePaths) + } + if result.AlreadyCorrect < 1 { + t.Error("AlreadyCorrect should be >= 1") + } +} + +// TestRepairConflict verifies that when both old and canonical files exist, +// the repair reports a conflict and does not overwrite. +func TestRepairConflict(t *testing.T) { + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + _, _ = setupOldLayout(t, svc, nodeRepo, nil, vaultRoot) + + // Also create the canonical file (as if someone already manually created it) + notesDir := filepath.Join(vaultRoot, "Cases", "OldCase", "Notes") + canonicalAbs := filepath.Join(notesDir, "Overview.md") + if err := os.MkdirAll(notesDir, 0o750); err != nil { + t.Fatalf("mkdir Notes: %v", err) + } + if err := os.WriteFile(canonicalAbs, []byte("conflicting content"), 0o640); err != nil { + t.Fatalf("write canonical: %v", err) + } + + // Both files exist now + result, err := svc.RepairNotesLayout() + if err != nil { + t.Fatalf("RepairNotesLayout: %v", err) + } + + // Should report conflict, not move or update + if len(result.Conflicts) != 1 { + t.Fatalf("expected 1 conflict, got %d: %+v", len(result.Conflicts), result.Conflicts) + } + + // Both files should still exist + oldAbs := filepath.Join(vaultRoot, "Cases", "OldCase", "Overview.md") + if _, err := os.Stat(oldAbs); err != nil { + t.Error("old file should still exist after conflict") + } + if _, err := os.Stat(canonicalAbs); err != nil { + t.Error("canonical file should still exist after conflict") + } +} + +// TestRepairMultipleNotes verifies repair handles several notes under one case. +func TestRepairMultipleNotes(t *testing.T) { + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + + caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "Multi Case", 0, "", "Cases/MultiCase") + if err != nil { + t.Fatalf("create case: %v", err) + } + caseDir := filepath.Join(vaultRoot, "Cases", "MultiCase") + if err := os.MkdirAll(caseDir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Create two old-style notes + notes := []struct { + title string + filename string + content string + }{ + {"Overview", "Overview.md", "# Overview\n\nMain note."}, + {"Notes", "Notes.md", "# Notes\n\nSecondary note."}, + } + + var noteNodes []*nodes.Node + for _, n := range notes { + noteNode, err := nodeRepo.Create(strPtr(caseNode.ID), nodes.TypeNote, n.title, 0, "", "") + if err != nil { + t.Fatalf("create note %q: %v", n.title, err) + } + + filePath := filepath.Join(caseDir, n.filename) + if err := os.WriteFile(filePath, []byte(n.content), 0o640); err != nil { + t.Fatalf("write file: %v", err) + } + + relPath := "Cases/MultiCase/" + n.filename + fileRec, err := insertFileRecord(svc.db, noteNode.ID, n.filename, relPath, "vault", int64(len(n.content))) + if err != nil { + t.Fatalf("insert file record: %v", err) + } + + _, err = svc.db.Exec( + `INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, + noteNode.ID, fileRec.ID, "markdown") + if err != nil { + t.Fatalf("insert notes record: %v", err) + } + noteNodes = append(noteNodes, noteNode) + } + + // Run repair + result, err := svc.RepairNotesLayout() + if err != nil { + t.Fatalf("RepairNotesLayout: %v", err) + } + + if result.RepairedNotes != 2 { + t.Errorf("RepairedNotes = %d, want 2", result.RepairedNotes) + } + + // Verify Notes folder exists with both notes as children + notesFolder := svc.FindNotesFolder(caseNode.ID) + if notesFolder == nil { + t.Fatal("Notes folder should exist") + } + + children, err := nodeRepo.ListChildren(notesFolder.ID, false) + if err != nil { + t.Fatalf("ListChildren: %v", err) + } + if len(children) != 2 { + t.Errorf("Notes folder has %d children, want 2", len(children)) + } + + // Verify files were moved and content preserved + for _, n := range noteNodes { + content, err := svc.Read(n.ID) + if err != nil { + t.Fatalf("Read %q: %v", n.Title, err) + } + if len(content) == 0 { + t.Errorf("note %q has empty content after repair", n.Title) + } + } +} + +// TestRepairIdempotent verifies that running repair twice produces the same +// result (no duplicates, no errors). +func TestRepairIdempotent(t *testing.T) { + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + _, _ = setupOldLayout(t, svc, nodeRepo, nil, vaultRoot) + + // First repair + result1, err := svc.RepairNotesLayout() + if err != nil { + t.Fatalf("first repair: %v", err) + } + if result1.RepairedNotes != 1 { + t.Fatalf("expected 1 repaired note, got %d", result1.RepairedNotes) + } + + // Second repair — should be a no-op + result2, err := svc.RepairNotesLayout() + if err != nil { + t.Fatalf("second repair: %v", err) + } + if result2.RepairedNotes != 0 { + t.Errorf("second repair changed %d notes, want 0", result2.RepairedNotes) + } + if result2.CreatedFolders != 0 { + t.Errorf("second repair created %d folders, want 0", result2.CreatedFolders) + } + if len(result2.Conflicts) != 0 { + t.Errorf("second repair has conflicts: %+v", result2.Conflicts) + } + if len(result2.Errors) != 0 { + t.Errorf("second repair has errors: %+v", result2.Errors) + } + if result2.AlreadyCorrect < 1 { + t.Errorf("second repair AlreadyCorrect = %d, want >= 1", result2.AlreadyCorrect) + } +} + +// TestRepairDoesNotLoseFileID verifies that notes.file_id stays consistent. +func TestRepairDoesNotLoseFileID(t *testing.T) { + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + _, oldNote := setupOldLayout(t, svc, nodeRepo, nil, vaultRoot) + + // Capture file ID before repair + recBefore, err := svc.Load(oldNote.ID) + if err != nil { + t.Fatalf("Load before: %v", err) + } + fileIDBefore := recBefore.FileID + + // Run repair + _, err = svc.RepairNotesLayout() + if err != nil { + t.Fatalf("RepairNotesLayout: %v", err) + } + + // File ID should be unchanged + recAfter, err := svc.Load(oldNote.ID) + if err != nil { + t.Fatalf("Load after: %v", err) + } + if recAfter.FileID != fileIDBefore { + t.Errorf("file_id changed: before=%q after=%q", fileIDBefore, recAfter.FileID) + } + if recAfter.Format != "markdown" { + t.Errorf("format changed: %q", recAfter.Format) + } +} + +// TestRepairContentPreserved verifies that note content is not lost during repair. +func TestRepairContentPreserved(t *testing.T) { + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + + // Create a case and a note using setupOldLayout + caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "Content Case", 0, "", "Cases/ContentCase") + if err != nil { + t.Fatalf("create case: %v", err) + } + caseDir := filepath.Join(vaultRoot, "Cases", "ContentCase") + if err := os.MkdirAll(caseDir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + + noteNode, err := nodeRepo.Create(strPtr(caseNode.ID), nodes.TypeNote, "My Overview", 0, "", "") + if err != nil { + t.Fatalf("create note: %v", err) + } + + originalContent := "# My Overview\n\nThis content MUST survive repair.\n\n## Section 1\n\nSome important notes here." + oldFilePath := filepath.Join(caseDir, "My Overview.md") + if err := os.WriteFile(oldFilePath, []byte(originalContent), 0o640); err != nil { + t.Fatalf("write file: %v", err) + } + + relPath := "Cases/ContentCase/My Overview.md" + fileRec, err := insertFileRecord(svc.db, noteNode.ID, "My Overview.md", relPath, "vault", int64(len(originalContent))) + if err != nil { + t.Fatalf("insert file record: %v", err) + } + _, err = svc.db.Exec( + `INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, + noteNode.ID, fileRec.ID, "markdown") + if err != nil { + t.Fatalf("insert notes record: %v", err) + } + + // Run repair + _, err = svc.RepairNotesLayout() + if err != nil { + t.Fatalf("RepairNotesLayout: %v", err) + } + + // Content must be preserved + content, err := svc.Read(noteNode.ID) + if err != nil { + t.Fatalf("Read after repair: %v", err) + } + if content != originalContent { + t.Errorf("content changed:\n got: %q\n want: %q", content, originalContent) + } +} + +// TestCreateNoteCreatesNotesFolder verifies that Create() sets up the +// canonical layout automatically. +func TestCreateNoteCreatesNotesFolder(t *testing.T) { + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + + // Create a case + caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "New Case", 0, "", "Cases/NewCase") + if err != nil { + t.Fatalf("create case: %v", err) + } + caseDir := filepath.Join(vaultRoot, "Cases", "NewCase") + if err := os.MkdirAll(caseDir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Create note under the case using the new Create + noteNode, _, err := svc.Create(caseNode.ID, "My Note", "") + if err != nil { + t.Fatalf("Create: %v", err) + } + + // Notes folder should exist + notesFolder := svc.FindNotesFolder(caseNode.ID) + if notesFolder == nil { + t.Fatal("Notes folder should exist after Create") + } + + // Note should be child of Notes folder, not case + if *noteNode.ParentID != notesFolder.ID { + t.Errorf("note parent = %q, want Notes folder %q", *noteNode.ParentID, notesFolder.ID) + } + + // File should be at canonical path + rec, _ := svc.Load(noteNode.ID) + fileRec, _ := svc.files.Get(rec.FileID) + expectedPath := "Cases/NewCase/Notes/My Note.md" + if fileRec.Path != expectedPath { + t.Errorf("file path = %q, want %q", fileRec.Path, expectedPath) + } + + // File should exist on disk + if _, err := os.Stat(filepath.Join(vaultRoot, expectedPath)); err != nil { + t.Errorf("file should exist at %q: %v", expectedPath, err) + } +} + +// TestListNotesUsesNotesFolder verifies that ListNotes (via FindNotesFolder) +// correctly returns notes from the Notes folder. +func TestListNotesUsesNotesFolder(t *testing.T) { + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + + // Create a case + caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "List Test", 0, "", "Cases/ListTest") + if err != nil { + t.Fatalf("create case: %v", err) + } + caseDir := filepath.Join(vaultRoot, "Cases", "ListTest") + if err := os.MkdirAll(caseDir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Create a note under the Notes folder (new layout) + note1, _, err := svc.Create(caseNode.ID, "Note A", "") + if err != nil { + t.Fatalf("Create Note A: %v", err) + } + note2, _, err := svc.Create(caseNode.ID, "Note B", "") + if err != nil { + t.Fatalf("Create Note B: %v", err) + } + + // FindNotesFolder should return the Notes folder + notesFolder := svc.FindNotesFolder(caseNode.ID) + if notesFolder == nil { + t.Fatal("FindNotesFolder returned nil") + } + + // Children of Notes folder should be the two notes + children, err := nodeRepo.ListChildren(notesFolder.ID, false) + if err != nil { + t.Fatalf("ListChildren: %v", err) + } + noteIDs := make(map[string]bool) + for _, c := range children { + if c.Type == nodes.TypeNote { + noteIDs[c.ID] = true + } + } + if !noteIDs[note1.ID] { + t.Error("Note A not found in Notes folder children") + } + if !noteIDs[note2.ID] { + t.Error("Note B not found in Notes folder children") + } +} + +// TestRepairForOldLayoutDoesNotAffectNewNotes verifies that repair +// does not touch notes already in the correct layout. +func TestRepairForOldLayoutDoesNotAffectNewNotes(t *testing.T) { + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + + // Create a case + caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "Mixed Case", 0, "", "Cases/MixedCase") + if err != nil { + t.Fatalf("create case: %v", err) + } + caseDir := filepath.Join(vaultRoot, "Cases", "MixedCase") + if err := os.MkdirAll(caseDir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Add a note using the new Create (already correct layout) + noteNew, _, err := svc.Create(caseNode.ID, "New Note", "") + if err != nil { + t.Fatalf("Create new note: %v", err) + } + + // Add a note using the old layout (direct child) + noteOld, err := nodeRepo.Create(strPtr(caseNode.ID), nodes.TypeNote, "Old Note", 0, "", "") + if err != nil { + t.Fatalf("create old note: %v", err) + } + oldFilePath := filepath.Join(caseDir, "Old Note.md") + oldContent := "# Old Note\n\nOld style content." + if err := os.WriteFile(oldFilePath, []byte(oldContent), 0o640); err != nil { + t.Fatalf("write old file: %v", err) + } + fileRec, err := insertFileRecord(svc.db, noteOld.ID, "Old Note.md", "Cases/MixedCase/Old Note.md", "vault", int64(len(oldContent))) + if err != nil { + t.Fatalf("insert file record: %v", err) + } + _, err = svc.db.Exec( + `INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, + noteOld.ID, fileRec.ID, "markdown") + if err != nil { + t.Fatalf("insert notes record: %v", err) + } + + // Run repair + result, err := svc.RepairNotesLayout() + if err != nil { + t.Fatalf("RepairNotesLayout: %v", err) + } + + // Old note should be repaired + if result.RepairedNotes != 1 { + t.Errorf("RepairedNotes = %d, want 1 (only the old note)", result.RepairedNotes) + } + + // New note should still be under Notes folder + notesFolder := svc.FindNotesFolder(caseNode.ID) + children, _ := nodeRepo.ListChildren(notesFolder.ID, false) + newNoteFound := false + for _, c := range children { + if c.ID == noteNew.ID { + newNoteFound = true + break + } + } + if !newNoteFound { + t.Error("new note should still be in Notes folder after repair") + } + + // Old note should also now be in Notes folder + oldNoteUpdated, _ := nodeRepo.Get(noteOld.ID) + if *oldNoteUpdated.ParentID != notesFolder.ID { + t.Errorf("old note parent = %q, want Notes folder %q", *oldNoteUpdated.ParentID, notesFolder.ID) + } +} + +// TestFindNotesFolder verifies the simple lookup works. +func TestFindNotesFolder(t *testing.T) { + svc, nodeRepo, _, _ := setupRepairTest(t) + + caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "Test Case", 0, "", "") + if err != nil { + t.Fatalf("create case: %v", err) + } + + // Before EnsureNotesFolder, FindNotesFolder should return nil + if n := svc.FindNotesFolder(caseNode.ID); n != nil { + t.Error("FindNotesFolder should return nil before EnsureNotesFolder") + } + + // Create Notes folder + folder, err := svc.EnsureNotesFolder(caseNode.ID) + if err != nil { + t.Fatalf("EnsureNotesFolder: %v", err) + } + if folder == nil { + t.Fatal("folder is nil") + } + + // Now FindNotesFolder should return it + found := svc.FindNotesFolder(caseNode.ID) + if found == nil { + t.Fatal("FindNotesFolder returned nil after EnsureNotesFolder") + } + if found.ID != folder.ID { + t.Errorf("found folder ID = %q, want %q", found.ID, folder.ID) + } + + // Should be idempotent + found2 := svc.FindNotesFolder(caseNode.ID) + if found2.ID != folder.ID { + t.Errorf("second call returned different folder: %q vs %q", found2.ID, folder.ID) + } +} + +// --------------------------------------------------------------------------- +// SupportsNotes tests +// --------------------------------------------------------------------------- + +func TestSupportsNotesForContainerTypes(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, dir, nodeRepo) + svc := NewService(db, dir, nodeRepo, fileSvc) + + // Every root-level container type should support notes. + containerTypes := []string{ + nodes.TypeFolder, nodes.TypeProject, nodes.TypeClient, + nodes.TypeDocument, nodes.TypeRecipe, nodes.TypeSpace, nodes.TypeCase, + } + for _, typ := range containerTypes { + n, err := nodeRepo.Create(nil, typ, typ, 0, "", "") + if err != nil { + t.Fatalf("create %s node: %v", typ, err) + } + if !svc.SupportsNotes(n.ID) { + t.Errorf("SupportsNotes(%s) = false, want true", typ) + } + } +} + +func TestSupportsNotesForNonContainerTypes(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, dir, nodeRepo) + svc := NewService(db, dir, nodeRepo, fileSvc) + + parent, err := nodeRepo.Create(nil, nodes.TypeFolder, "Parent", 0, "", "") + if err != nil { + t.Fatalf("create parent: %v", err) + } + + // Non-container types should NOT support notes. + nonContainerTypes := []string{ + nodes.TypeNote, nodes.TypeFile, nodes.TypeAction, + nodes.TypeSecret, nodes.TypeWorklog, nodes.TypeLink, + } + for _, typ := range nonContainerTypes { + n, err := nodeRepo.Create(strPtr(parent.ID), typ, typ, 0, "", "") + if err != nil { + t.Fatalf("create %s node: %v", typ, err) + } + if svc.SupportsNotes(n.ID) { + t.Errorf("SupportsNotes(%s) = true, want false", typ) + } + } +} + +func TestSupportsNotesForNotesFolder(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, dir, nodeRepo) + svc := NewService(db, dir, nodeRepo, fileSvc) + + // A folder named "Notes" should return false (prevents Notes/Notes nesting). + parent, err := nodeRepo.Create(nil, nodes.TypeFolder, "Parent", 0, "", "") + if err != nil { + t.Fatalf("create parent: %v", err) + } + notesFolder, err := nodeRepo.Create(strPtr(parent.ID), nodes.TypeFolder, "Notes", 0, "", "") + if err != nil { + t.Fatalf("create Notes folder: %v", err) + } + if svc.SupportsNotes(notesFolder.ID) { + t.Errorf("SupportsNotes(Notes folder) = true, want false") + } +} + +// --------------------------------------------------------------------------- +// EnsureNotesFolder rejection tests +// --------------------------------------------------------------------------- + +func TestEnsureNotesFolderRejectsNonContainer(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, dir, nodeRepo) + svc := NewService(db, dir, nodeRepo, fileSvc) + + fileNode, err := nodeRepo.Create(nil, nodes.TypeFile, "document.pdf", 0, "", "") + if err != nil { + t.Fatalf("create file node: %v", err) + } + + _, err = svc.EnsureNotesFolder(fileNode.ID) + if err == nil { + t.Fatal("EnsureNotesFolder should error for non-container types") + } +} + +func TestEnsureNotesFolderRejectsNotesFolder(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, dir, nodeRepo) + svc := NewService(db, dir, nodeRepo, fileSvc) + + parent, err := nodeRepo.Create(nil, nodes.TypeFolder, "Parent", 0, "", "") + if err != nil { + t.Fatalf("create parent: %v", err) + } + + // Create a Notes folder + notesFolder, err := nodeRepo.Create(strPtr(parent.ID), nodes.TypeFolder, "Notes", 0, "", "") + if err != nil { + t.Fatalf("create Notes folder: %v", err) + } + + // EnsureNotesFolder on the Notes folder should error + _, err = svc.EnsureNotesFolder(notesFolder.ID) + if err == nil { + t.Fatal("EnsureNotesFolder on Notes folder should error (prevents nesting)") + } +} + +// --------------------------------------------------------------------------- +// Create with non-notes parent +// --------------------------------------------------------------------------- + +func TestCreateErrorsForFileParent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, dir, nodeRepo) + svc := NewService(db, dir, nodeRepo, fileSvc) + + // Create a file node + fileNode, err := nodeRepo.Create(nil, nodes.TypeFile, "doc.pdf", 0, "", "") + if err != nil { + t.Fatalf("create file node: %v", err) + } + + // Create under a file node should FAIL + _, _, err = svc.Create(fileNode.ID, "Under File", "") + if err == nil { + t.Fatal("Create under file node should error, but succeeded") + } + + // Verify no note node, no file record, no notes record were created + allNotes, _ := nodeRepo.ListByType(nodes.TypeNote) + for _, n := range allNotes { + if n.Title == "Under File" { + t.Errorf("note node should not exist for unsupported parent") + } + } +} + +func TestCreateErrorsForNoteParent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, dir, nodeRepo) + svc := NewService(db, dir, nodeRepo, fileSvc) + + parent, err := nodeRepo.Create(nil, nodes.TypeFolder, "Container", 0, "", "") + if err != nil { + t.Fatalf("create container: %v", err) + } + + // Create a note under the container — works + existingNote, _, err := svc.Create(parent.ID, "Existing Note", "") + if err != nil { + t.Fatalf("Create first note: %v", err) + } + + // Create under a NOTE node should FAIL (note type does not support notes) + _, _, err = svc.Create(existingNote.ID, "Sub Note", "") + if err == nil { + t.Fatal("Create under note node should error, but succeeded") + } +} + +func TestCreateForUnsupportedParentDoesNotCreateAnyState(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, dir, nodeRepo) + svc := NewService(db, dir, nodeRepo, fileSvc) + + fileNode, err := nodeRepo.Create(nil, nodes.TypeFile, "data.pdf", 0, "", "") + if err != nil { + t.Fatalf("create file node: %v", err) + } + + // Attempt create — should fail + _, _, err = svc.Create(fileNode.ID, "ShouldNotExist", "") + if err == nil { + t.Fatal("Create should have errored") + } + + // Verify no note node was created with that title + allNotes, err := nodeRepo.ListByType(nodes.TypeNote) + if err != nil { + t.Fatalf("list nodes: %v", err) + } + for _, n := range allNotes { + if n.Title == "ShouldNotExist" { + t.Error("note node should not exist for unsupported parent") + } + } + + // Verify no Notes folder was created under file node + notesFolder := svc.FindNotesFolder(fileNode.ID) + if notesFolder != nil { + t.Error("Notes folder should not exist under file node") + } +} + +// --------------------------------------------------------------------------- +// Repair skips notes under non-container parents +// --------------------------------------------------------------------------- + +func TestRepairSkipsNotesUnderFileParent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, dir, nodeRepo) + svc := NewService(db, dir, nodeRepo, fileSvc) + + // Create a file node + fileNode, err := nodeRepo.Create(nil, nodes.TypeFile, "readme.pdf", 0, "", "") + if err != nil { + t.Fatalf("create file node: %v", err) + } + + // Create a note node as direct child of file node (old layout for non-container) + noteNode, err := nodeRepo.Create(strPtr(fileNode.ID), nodes.TypeNote, "My Note", 0, "", "") + if err != nil { + t.Fatalf("create note: %v", err) + } + + // Create file record and notes record manually + fileRec, err := insertFileRecord(svc.db, noteNode.ID, "mynote.md", "some/path/mynote.md", "vault", 10) + if err != nil { + t.Fatalf("insert file record: %v", err) + } + _, err = svc.db.Exec( + `INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, + noteNode.ID, fileRec.ID, "markdown") + if err != nil { + t.Fatalf("insert notes record: %v", err) + } + + // Repair should skip this note (parent is file type) + result, err := svc.RepairNotesLayout() + if err != nil { + t.Fatalf("RepairNotesLayout: %v", err) + } + if result.SkippedNotes != 1 { + t.Errorf("SkippedNotes = %d, want 1", result.SkippedNotes) + } + if result.RepairedNotes != 0 { + t.Errorf("RepairedNotes = %d, want 0", result.RepairedNotes) + } +} + +// --------------------------------------------------------------------------- +// ListNotes returns empty for non-notes parents +// --------------------------------------------------------------------------- + +func TestListNotesBindingEmptyForNonNotesParent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, dir, nodeRepo) + svc := NewService(db, dir, nodeRepo, fileSvc) + + fileNode, err := nodeRepo.Create(nil, nodes.TypeFile, "file.txt", 0, "", "") + if err != nil { + t.Fatalf("create file node: %v", err) + } + + // ListNotes should return empty for non-notes-capable parents + notesFolder := svc.FindNotesFolder(fileNode.ID) + if notesFolder != nil { + t.Error("FindNotesFolder should return nil for file node") + } +} + +// TestRepairDoesNotCreateNotesFolderForOldNoteUnderNonContainer ensures +// that repair does NOT create a Notes folder when migrating notes that were +// accidentally placed under non-container types. +func TestRepairDoesNotCreateNotesFolderForNonContainerNote(t *testing.T) { + t.Parallel() + dir := t.TempDir() + db, err := storage.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, dir, nodeRepo) + svc := NewService(db, dir, nodeRepo, fileSvc) + + // Create a folder (container) + container, err := nodeRepo.Create(nil, nodes.TypeFolder, "My Project", 0, "", "MyProject") + if err != nil { + t.Fatalf("create container: %v", err) + } + + // Create a note node directly under the container (old layout) + noteNode, err := nodeRepo.Create(strPtr(container.ID), nodes.TypeNote, "Overview", 0, "", "") + if err != nil { + t.Fatalf("create note: %v", err) + } + + // Create physical file + containerDir := filepath.Join(dir, "MyProject") + if err := os.MkdirAll(containerDir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + oldFilePath := filepath.Join(containerDir, "Overview.md") + oldContent := "# Overview\n\nContent" + if err := os.WriteFile(oldFilePath, []byte(oldContent), 0o640); err != nil { + t.Fatalf("write file: %v", err) + } + + // Insert file record with old path + fileRec, err := insertFileRecord(svc.db, noteNode.ID, "Overview.md", "MyProject/Overview.md", "vault", int64(len(oldContent))) + if err != nil { + t.Fatalf("insert file record: %v", err) + } + _, err = svc.db.Exec( + `INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, + noteNode.ID, fileRec.ID, "markdown") + if err != nil { + t.Fatalf("insert notes record: %v", err) + } + + // Repair should work for this note (folder type supports notes) + result, err := svc.RepairNotesLayout() + if err != nil { + t.Fatalf("RepairNotesLayout: %v", err) + } + if result.RepairedNotes != 1 { + t.Errorf("RepairedNotes = %d, want 1 (folder supports notes)", result.RepairedNotes) + } + if result.SkippedNotes != 0 { + t.Errorf("SkippedNotes = %d, want 0", result.SkippedNotes) + } + + // Notes folder should exist + notesFolder := svc.FindNotesFolder(container.ID) + if notesFolder == nil { + t.Fatal("Notes folder should have been created under container") + } + + // File should be in Notes/ + canonicalPath := filepath.Join(dir, "MyProject", "Notes", "Overview.md") + if _, err := os.Stat(canonicalPath); err != nil { + t.Errorf("file should exist at %s: %v", canonicalPath, err) + } +} + +// --------------------------------------------------------------------------- +// Files tab verification — full chain tests +// --------------------------------------------------------------------------- + +// TestFilesTabShowsNotesFolder verifies that after creating a note via +// the service, the case's children (ListChildren) contain a Notes folder +// and the Notes folder contains the note node. +func TestFilesTabShowsNotesFolder(t *testing.T) { + t.Parallel() + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + + caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "MyCase", 0, "", "Cases/MyCase") + if err != nil { + t.Fatalf("create case: %v", err) + } + caseDir := filepath.Join(vaultRoot, "Cases", "MyCase") + if err := os.MkdirAll(caseDir, 0o750); err != nil { + t.Fatalf("mkdir case: %v", err) + } + + // Create a note via the service + noteNode, _, err := svc.Create(caseNode.ID, "TestNote", "") + if err != nil { + t.Fatalf("Create: %v", err) + } + + // Files tab list: case's children should contain Notes folder + children, err := nodeRepo.ListChildren(caseNode.ID, false) + if err != nil { + t.Fatalf("ListChildren case: %v", err) + } + var notesFolder *nodes.Node + for _, c := range children { + if c.Type == nodes.TypeFolder && c.Title == "Notes" { + notesFolder = &c + break + } + } + if notesFolder == nil { + t.Fatal("Files tab should show Notes folder under case, but it's missing") + } + + // Files tab drill-down: Notes folder should contain the note + notesChildren, err := nodeRepo.ListChildren(notesFolder.ID, false) + if err != nil { + t.Fatalf("ListChildren Notes: %v", err) + } + found := false + for _, c := range notesChildren { + if c.ID == noteNode.ID { + found = true + break + } + } + if !found { + t.Error("Files tab should show the note inside Notes folder, but it's missing") + } + + // Physical file should exist + expectedPath := filepath.Join(caseDir, "Notes", "TestNote.md") + if _, err := os.Stat(expectedPath); err != nil { + t.Errorf("physical file should exist at %s: %v", expectedPath, err) + } +} + +// TestFilesTabRepairAndReadNoteChain verifies that after repairing an old +// layout (Overview.md at case root), the Files tab and Notes tab both work: +// - Notes folder is visible via ListChildren +// - Overview.md is visible inside Notes folder +// - ReadNote returns content +func TestFilesTabRepairAndReadNoteChain(t *testing.T) { + t.Parallel() + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + + // 1. Old layout: note node as direct child of case, file at case root + caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "MyCase", 0, "", "Cases/MyCase") + if err != nil { + t.Fatalf("create case: %v", err) + } + caseDir := filepath.Join(vaultRoot, "Cases", "MyCase") + if err := os.MkdirAll(caseDir, 0o750); err != nil { + t.Fatalf("mkdir case: %v", err) + } + + // Old note node + noteNode, err := nodeRepo.Create(strPtr(caseNode.ID), nodes.TypeNote, "Overview", 0, "", "") + if err != nil { + t.Fatalf("create note: %v", err) + } + + // File at old location + oldFilePath := filepath.Join(caseDir, "Overview.md") + content := "# Overview\n\nContent" + if err := os.WriteFile(oldFilePath, []byte(content), 0o640); err != nil { + t.Fatalf("write file: %v", err) + } + + // File record pointing to old path + fileRec, err := insertFileRecord(svc.db, noteNode.ID, "Overview.md", "Cases/MyCase/Overview.md", "vault", int64(len(content))) + if err != nil { + t.Fatalf("insert file record: %v", err) + } + _, err = svc.db.Exec( + `INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, + noteNode.ID, fileRec.ID, "markdown") + if err != nil { + t.Fatalf("insert notes record: %v", err) + } + + // 2. Run repair + result, err := svc.RepairNotesLayout() + if err != nil { + t.Fatalf("RepairNotesLayout: %v", err) + } + if result.RepairedNotes != 1 { + t.Fatalf("expected 1 repaired note, got %d", result.RepairedNotes) + } + + // 3. Files tab: case children should include Notes folder + children, err := nodeRepo.ListChildren(caseNode.ID, false) + if err != nil { + t.Fatalf("ListChildren case: %v", err) + } + var notesFolder *nodes.Node + for _, c := range children { + if c.Type == nodes.TypeFolder && c.Title == "Notes" { + notesFolder = &c + break + } + } + if notesFolder == nil { + t.Fatal("Files tab should show Notes folder after repair") + } + + // 4. Files tab: Notes folder should contain Overview note + notesChildren, err := nodeRepo.ListChildren(notesFolder.ID, false) + if err != nil { + t.Fatalf("ListChildren Notes: %v", err) + } + noteFound := false + for _, c := range notesChildren { + if c.ID == noteNode.ID { + noteFound = true + break + } + } + if !noteFound { + t.Error("Files tab should show Overview note inside Notes folder after repair") + } + + // 5. Notes tab: ReadNote should return the content + readContent, err := svc.Read(noteNode.ID) + if err != nil { + t.Fatalf("ReadNote after repair: %v", err) + } + if readContent != content { + t.Errorf("ReadNote content = %q, want %q", readContent, content) + } + + // 6. Physical file should be at canonical location + canonicalPath := filepath.Join(caseDir, "Notes", "Overview.md") + if _, err := os.Stat(canonicalPath); err != nil { + t.Errorf("file should exist at canonical path %s: %v", canonicalPath, err) + } + // Old file should be gone + if _, err := os.Stat(oldFilePath); !os.IsNotExist(err) { + t.Error("old file should not exist after repair") + } +} + +// TestFilesTabManualMoveRepair verifies: file manually moved to Notes/, +// DB path stale → repair updates DB, ReadNote returns content, Files tab +// shows the note. +func TestFilesTabManualMoveRepair(t *testing.T) { + t.Parallel() + svc, nodeRepo, _, vaultRoot := setupRepairTest(t) + + // Old layout + caseNode, err := nodeRepo.Create(nil, nodes.TypeCase, "MyCase", 0, "", "Cases/MyCase") + if err != nil { + t.Fatalf("create case: %v", err) + } + caseDir := filepath.Join(vaultRoot, "Cases", "MyCase") + if err := os.MkdirAll(caseDir, 0o750); err != nil { + t.Fatalf("mkdir case: %v", err) + } + if err := os.MkdirAll(filepath.Join(caseDir, "Notes"), 0o750); err != nil { + t.Fatalf("mkdir Notes: %v", err) + } + + // Old note node + noteNode, err := nodeRepo.Create(strPtr(caseNode.ID), nodes.TypeNote, "Overview", 0, "", "") + if err != nil { + t.Fatalf("create note: %v", err) + } + + // File manually placed at canonical location (user moved it) + canonicalPath := filepath.Join(caseDir, "Notes", "Overview.md") + content := "# Overview\n\nManual move content" + if err := os.WriteFile(canonicalPath, []byte(content), 0o640); err != nil { + t.Fatalf("write file at canonical: %v", err) + } + + // File record pointing to OLD path (stale) + fileRec, err := insertFileRecord(svc.db, noteNode.ID, "Overview.md", "Cases/MyCase/Overview.md", "vault", int64(len(content))) + if err != nil { + t.Fatalf("insert file record: %v", err) + } + _, err = svc.db.Exec( + `INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, + noteNode.ID, fileRec.ID, "markdown") + if err != nil { + t.Fatalf("insert notes record: %v", err) + } + + // Run repair + result, err := svc.RepairNotesLayout() + if err != nil { + t.Fatalf("RepairNotesLayout: %v", err) + } + if result.UpdatedFilePaths != 1 { + t.Fatalf("expected 1 UpdatedFilePaths, got %d (MovedFiles=%d RepairedNotes=%d)", + result.UpdatedFilePaths, result.MovedFiles, result.RepairedNotes) + } + + // 1. ReadNote should return content (path was fixed) + readContent, err := svc.Read(noteNode.ID) + if err != nil { + t.Fatalf("ReadNote after repair: %v", err) + } + if readContent != content { + t.Errorf("ReadNote content = %q, want %q", readContent, content) + } + + // 2. File record path should be canonical + updatedRec, _ := svc.Load(noteNode.ID) + updatedFileRec, _ := svc.files.Get(updatedRec.FileID) + if updatedFileRec.Path != "Cases/MyCase/Notes/Overview.md" { + t.Errorf("file path = %q, want %q", updatedFileRec.Path, "Cases/MyCase/Notes/Overview.md") + } + + // 3. Files tab: case children should include Notes folder + children, err := nodeRepo.ListChildren(caseNode.ID, false) + if err != nil { + t.Fatalf("ListChildren case: %v", err) + } + var notesFolder *nodes.Node + for _, c := range children { + if c.Type == nodes.TypeFolder && c.Title == "Notes" { + notesFolder = &c + break + } + } + if notesFolder == nil { + t.Fatal("Files tab should show Notes folder after repair") + } + + // 4. Files tab: Notes folder should contain Overview + notesChildren, err := nodeRepo.ListChildren(notesFolder.ID, false) + if err != nil { + t.Fatalf("ListChildren Notes: %v", err) + } + found := false + for _, c := range notesChildren { + if c.ID == noteNode.ID { + found = true + break + } + } + if !found { + t.Error("Files tab should show Overview note inside Notes folder after repair") + } +} diff --git a/internal/core/templates/system_templates.json b/internal/core/templates/system_templates.json index 23af525..59d78bc 100644 --- a/internal/core/templates/system_templates.json +++ b/internal/core/templates/system_templates.json @@ -6,8 +6,8 @@ "enabled": true, "system": true, "icon": "folder", - "default_modules": ["overview", "children", "activity"], - "default_folders": [], + "default_modules": ["overview", "notes", "children", "activity"], + "default_folders": ["Notes"], "default_files": [], "allowed_parent_types": ["folder", "project", "client", "root"], "allowed_child_templates": ["*"] @@ -45,9 +45,9 @@ "enabled": true, "system": true, "icon": "document", - "default_modules": ["overview", "files", "activity"], - "default_files": [], - "default_folders": [], + "default_modules": ["overview", "notes", "files", "activity"], + "default_files": [{"path": "Overview.md", "content_template": "document_overview"}], + "default_folders": ["Notes"], "allowed_parent_types": ["folder", "project", "client", "root"], "allowed_child_templates": ["*"] }, @@ -60,7 +60,7 @@ "icon": "recipe", "default_modules": ["overview", "notes", "files", "activity"], "default_files": [{"path": "Overview.md", "content_template": "recipe_overview"}], - "default_folders": [], + "default_folders": ["Notes"], "allowed_parent_types": ["folder", "root"], "allowed_child_templates": ["*"] }