Notes: sync templates, fix Create layout, repair direct children, ListItems+ListNotes merge

=== Breaking ===
- CreateNote for unsupported parents (file/note/action/secret/worklog/link) -> error
- EnsureNotesFolder validates parent supports notes before creating Notes/

=== Templates (system_templates.json) ===
- folder.default: +notes module, +Notes folder in default_folders
- document.default: +notes module, +Notes folder, +Overview.md default file
- recipe.default: +Notes folder in default_folders
All container types now consistently declare notes support.

=== CreateNodeFromTemplate layout fix ===
- DefaultFolders created BEFORE DefaultFiles (so Notes/ exists)
- DefaultFile nodes now parented inside Notes folder, not the container
- File path, file record, notes record all canonical: Notes/Overview.md
- No root-level Overview.md created

=== ListItems (Files tab) ===
- bindings_files.go: ListItems now includes TypeNote (not just TypeFolder+TypeFile)
- Notes folder visible in Files tab tree
- Overview.md inside Notes shown with type='note', Mime='text/markdown'

=== ListNotes merge ===
- Collects from both Notes folder (canonical) and direct TypeNote children (compat)
- Duplicates excluded via seen set
- Canonical layout takes priority

=== RepairNotesLayout ===
- Moves direct TypeNote children into Notes folder via nodes.Move
- Updates files.path/files.filename for moved notes
- Skips non-container parents (file/note/etc)

=== Tests ===
- note_repair_test.go: 37 tests (24 old + 13 new)
  - SupportsNotes for containers/non-containers
  - EnsureNotesFolder rejects for unsupported parents
  - Create for file/note parent -> error, no state leak
  - Repair: skips non-containers, creates Notes folder, moves notes
  - Files tab: Notes folder visible, Overview content preserved after repair
- vault_layout_notes_files_test.go: 3 new ListItems/repair tests
  - ListItems shows Notes folder
  - ListItems inside Notes shows Overview with FileID
  - Repair moves direct children, ListItems reflects new layout
- Updated: suggest_test.go, trash_test.go, vault_layout_test.go expectations

=== Misc ===
- nodes/repository.go: ListByType helper for test use
- bindings_files.go: TypeNote in ListItems, Mime=text/markdown
This commit is contained in:
2026-06-15 17:24:56 +08:00
parent 2cbb2986c1
commit fec35f55b8
11 changed files with 2218 additions and 71 deletions
+22
View File
@@ -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 {
+321 -2
View File
@@ -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 <vaultRoot>/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:
//
// <case>/
// 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
}
File diff suppressed because it is too large Load Diff
@@ -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": ["*"]
}