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:
parent
2cbb2986c1
commit
fec35f55b8
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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": ["*"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue