diff --git a/README.md b/README.md index 356fb4e..20637fc 100644 --- a/README.md +++ b/README.md @@ -234,13 +234,18 @@ zn comment add "Project plan.md" "Can we simplify this?" zn comment list "Project plan.md" --all --json zn base create "Reading list" zn base rows "Reading list" --json +zn base pages "Reading list" ``` Use the stable ID returned by `task list` with `task toggle `. Comment replies and resolve/reopen operations take a thread ID. `base add` and `base set` accept repeated `--set Field=Value` flags for existing fields; `base add --body` or `--page` also creates a record page. `base convert` -turns a loose CSV into a `.base` folder. +turns a loose CSV into a `.base` folder, and `base pages` gives every +record that has no page its record page, so a database converted from a +CSV can be linked right away. A page file already in the database folder +under a record's name is linked instead of copied, and `--dry-run` lists +what it would do. ### Watching task status diff --git a/internal/cli/args.go b/internal/cli/args.go index d0ce40c..f8a6635 100644 --- a/internal/cli/args.go +++ b/internal/cli/args.go @@ -51,6 +51,7 @@ var valuelessFlags = map[string]bool{ "no-default": true, "no-start": true, "check": true, + "dry-run": true, "rollback": true, "no-edit": true, "page": true, diff --git a/internal/cli/base_pages_test.go b/internal/cli/base_pages_test.go new file mode 100644 index 0000000..b391a27 --- /dev/null +++ b/internal/cli/base_pages_test.go @@ -0,0 +1,181 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" +) + +// `zn base pages` gives every record of a database converted from a CSV its +// record page, so each one can be linked; the desktop's "Create pages for +// all records". +func basePagesVault(t *testing.T) (root, base string) { + t.Helper() + isolatedCLI(t) + root = t.TempDir() + base = filepath.Join(root, "inbox", "Books.base") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + csv := "id,Title,Author\n1,Dune,Herbert\n2,Kindred,Butler\n3,Emma,Austen\n4,,Nobody\n" + if err := os.WriteFile(filepath.Join(base, "data.csv"), []byte(csv), 0o644); err != nil { + t.Fatal(err) + } + return root, base +} + +type basePagesResult struct { + OK bool `json:"ok"` + DryRun bool `json:"dryRun"` + Pages []struct { + ID string `json:"id"` + Title string `json:"title"` + Page string `json:"page"` + } `json:"pages"` + Existing int `json:"existing"` + Untitled int `json:"untitled"` +} + +func runBasePages(t *testing.T, root string, flags ...string) basePagesResult { + t.Helper() + args := append([]string{"base", "pages", "Books", "--json", "--vault", root}, flags...) + out := captureOutput(t, func() { + if code := Main(args); code != 0 { + t.Errorf("zn %v exited %d", args, code) + } + }) + var result basePagesResult + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("zn %v: %v\n%s", args, err, out) + } + return result +} + +func mdFiles(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + out := []string{} + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".md") { + out = append(out, e.Name()) + } + } + sort.Strings(out) + return out +} + +func TestBasePagesCreatesAPageForEveryTitledRecord(t *testing.T) { + root, base := basePagesVault(t) + result := runBasePages(t, root) + titles := []string{} + for _, p := range result.Pages { + titles = append(titles, p.Title) + } + if !result.OK || result.DryRun || !reflect.DeepEqual(titles, []string{"Dune", "Kindred", "Emma"}) || result.Untitled != 1 || result.Existing != 0 { + t.Fatalf("result: %+v", result) + } + if got := mdFiles(t, base); !reflect.DeepEqual(got, []string{"Dune.md", "Emma.md", "Kindred.md"}) { + t.Fatalf("pages on disk: %v", got) + } + // The page the desktop's own "open" makes: properties as frontmatter, the title as its heading. + page, err := os.ReadFile(filepath.Join(base, "Kindred.md")) + if err != nil || string(page) != "---\nAuthor: Butler\n---\n# Kindred\n\n" { + t.Fatalf("Kindred.md: %q %v", page, err) + } + // schema.json maps each row to its page, relative to the folder as the app keeps it. + raw, err := os.ReadFile(filepath.Join(base, "schema.json")) + if err != nil { + t.Fatal(err) + } + var schema struct { + Pages map[string]string `json:"pages"` + } + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatal(err) + } + if len(schema.Pages) != 3 || schema.Pages[result.Pages[1].ID] != "Kindred.md" { + t.Fatalf("schema pages: %v", schema.Pages) + } + + // A second run finds every page and writes nothing. + again := runBasePages(t, root) + if len(again.Pages) != 0 || again.Existing != 3 || again.Untitled != 1 { + t.Fatalf("second run: %+v", again) + } + if got := mdFiles(t, base); len(got) != 3 { + t.Fatalf("second run wrote pages: %v", got) + } +} + +func TestBasePagesDryRunWritesNothing(t *testing.T) { + root, base := basePagesVault(t) + result := runBasePages(t, root, "--dry-run") + if !result.DryRun || len(result.Pages) != 3 || result.Pages[0].Page != "" { + t.Fatalf("dry run: %+v", result) + } + if got := mdFiles(t, base); len(got) != 0 { + t.Fatalf("dry run wrote pages: %v", got) + } +} + +func TestBasePagesRefusesALooseCSV(t *testing.T) { + isolatedCLI(t) + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "inbox", "Books.csv"), []byte("id,Title\n1,Dune\n"), 0o644); err != nil { + t.Fatal(err) + } + var code int + captureOutput(t, func() { code = Main([]string{"base", "pages", "Books", "--vault", root}) }) + if code == 0 { + t.Fatal("a loose .csv has no record pages; zn base pages should refuse it") + } + if entries, _ := os.ReadDir(filepath.Join(root, "inbox")); len(entries) != 1 { + t.Fatalf("refusal wrote files: %v", entries) + } +} + +// A workaround script wrote a page file per record but no schema.json entry: +// those files are linked as they are, never shadowed by a `Title 2.md`. +func TestBasePagesLinksPageFilesAScriptWrote(t *testing.T) { + root, base := basePagesVault(t) + notes := "---\nAuthor: Herbert\n---\n# Dune\n\nMy notes on Dune.\n" + if err := os.WriteFile(filepath.Join(base, "Dune.md"), []byte(notes), 0o644); err != nil { + t.Fatal(err) + } + args := []string{"base", "pages", "Books", "--json", "--vault", root} + out := captureOutput(t, func() { + if code := Main(args); code != 0 { + t.Errorf("zn %v exited %d", args, code) + } + }) + var result struct { + Pages []struct{ Title, Page string } `json:"pages"` + Linked []struct{ Title, Page string } `json:"linked"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("%v\n%s", err, out) + } + if len(result.Linked) != 1 || result.Linked[0].Title != "Dune" || len(result.Pages) != 2 { + t.Fatalf("result: %+v", result) + } + if got := mdFiles(t, base); !reflect.DeepEqual(got, []string{"Dune.md", "Emma.md", "Kindred.md"}) { + t.Fatalf("pages on disk: %v", got) + } + if kept, _ := os.ReadFile(filepath.Join(base, "Dune.md")); string(kept) != notes { + t.Fatalf("the script's page changed: %q", kept) + } + raw, _ := os.ReadFile(filepath.Join(base, "schema.json")) + if !strings.Contains(string(raw), `"Dune.md"`) { + t.Fatalf("schema does not map Dune.md: %s", raw) + } +} diff --git a/internal/cli/commands_base.go b/internal/cli/commands_base.go index dafa86b..3e91208 100644 --- a/internal/cli/commands_base.go +++ b/internal/cli/commands_base.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "github.com/ZenNotes/tui/internal/backend" @@ -463,3 +464,122 @@ func cmdBaseConvert(_ context.Context, b backend.Backend, args Args) error { emitOK(fmt.Sprintf("Converted %s to %s", summary.Path, database.FormDirFromCSVPath(next))) return nil } + +// cmdBasePages gives every record without a page its record page: the +// desktop's "Create pages for all records". A record is a note links can +// reach only once it has a page, and pages were made one record at a time, +// so a database converted from a CSV had none. --dry-run lists what it +// would create and writes nothing. +func cmdBasePages(_ context.Context, b backend.Backend, args Args) error { + ops := b.DatabaseOps() + summary, err := resolveDatabase(ops, args.Positional(0)) + if err != nil { + return err + } + if !database.HasRecordPages(summary.Path) { + return fmt.Errorf("%s is a loose .csv, which has no record pages. Run `zn base convert %q` first.", summary.Path, summary.Title) + } + doc, err := ops.OpenDatabase(summary.Path) + if err != nil { + return err + } + plan, err := doc.PlanRecordPages(ops.RecordPageExists) + if err != nil { + return err + } + type pageView struct { + ID string `json:"id"` + Title string `json:"title"` + Page string `json:"page,omitempty"` + } + dryRun := args.Bool("dry-run") + pages := []pageView{} + linked := []pageView{} + for _, adopted := range plan.Adopt { + linked = append(linked, pageView{ID: adopted.Row.ID, Title: doc.RecordTitle(adopted.Row), Page: adopted.Page}) + if dryRun { + continue + } + if doc.Pages == nil { + doc.Pages = map[string]string{} + } + doc.Pages[adopted.Row.ID] = adopted.Page + } + var failure error + for _, row := range plan.Missing { + title := doc.RecordTitle(row) + if dryRun { + pages = append(pages, pageView{ID: row.ID, Title: title}) + continue + } + page, err := ops.CreateRecordPage(summary.Path, title, doc.ComposePageBody(row, "# "+title+"\n\n")) + if err != nil { + failure = err + break + } + if doc.Pages == nil { + doc.Pages = map[string]string{} + } + doc.Pages[row.ID] = page + pages = append(pages, pageView{ID: row.ID, Title: title, Page: page}) + } + // One schema write for the run, kept when a page fails partway so the + // pages already written stay linked to their rows. + if !dryRun && len(pages)+len(linked) > 0 { + if _, err := ops.WriteSchema(summary.Path, doc); err != nil { + return err + } + } + if failure != nil { + return fmt.Errorf("Created %d of %d record pages in %s, then: %w", len(pages), len(plan.Missing), summary.Title, failure) + } + if args.Bool("json") { + emitJSON(map[string]any{ + "ok": true, + "path": summary.Path, + "dryRun": dryRun, + "pages": pages, + "linked": linked, + "existing": plan.Existing, + "untitled": plan.Untitled, + }) + return nil + } + noun := func(n int, one, many string) string { + if n == 1 { + return "1 " + one + } + return strconv.Itoa(n) + " " + many + } + switch { + case len(pages) == 0 && len(linked) == 0: + emitOK("Every titled record in " + summary.Title + " already has a page") + case dryRun: + if len(pages) > 0 { + emitLine("Would create " + noun(len(pages), "record page", "record pages") + " in " + summary.Title + ":") + for _, p := range pages { + emitLine(" " + p.Title) + } + } + if len(linked) > 0 { + emitLine("Would link " + noun(len(linked), "record to the page file already there", "records to the page files already there") + ":") + for _, p := range linked { + emitLine(" " + p.Title + " -> " + p.Page) + } + } + default: + if len(pages) > 0 { + emitOK("Created " + noun(len(pages), "record page", "record pages") + " in " + summary.Title) + } + if len(linked) > 0 { + emitOK("Linked " + noun(len(linked), "record to the page file already there", "records to the page files already there")) + } + } + if plan.Existing > 0 { + emitLine(noun(plan.Existing, "record already had its page", "records already had their pages")) + } + if plan.Untitled > 0 { + emitLine(noun(plan.Untitled, "record has no title and was left out", "records have no title and were left out")) + } + return nil +} diff --git a/internal/cli/help.go b/internal/cli/help.go index 9ae9fa9..7817f3c 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -94,6 +94,7 @@ var helpSections = []helpSection{ {"base add ", "Add a row; --body (or --body - for stdin) also creates its record page", "--set Field=Value … --body --page --json"}, {"base set ", "Set fields; select options mint, the page re-mirrors", "--set Field=Value … --json"}, {"base convert ", "Turn a loose .csv into a .base folder so rows can have record pages", "--json"}, + {"base pages ", "Give every record without one its record page, so each can be linked", "--dry-run --json"}, }}, {"CAPTURE", []helpRow{ {"capture \"...\"", "Quick add. Pipes stdin if no positional", "--folder --tag --title --json"}, diff --git a/internal/cli/main.go b/internal/cli/main.go index abf2ebe..e435b44 100644 --- a/internal/cli/main.go +++ b/internal/cli/main.go @@ -58,6 +58,7 @@ func dispatchTable() map[string]Handler { "base add": cmdBaseAdd, "base set": cmdBaseSet, "base convert": cmdBaseConvert, + "base pages": cmdBasePages, "capture": cmdCapture, } } diff --git a/internal/database/doc.go b/internal/database/doc.go index bceabdf..b7a46a7 100644 --- a/internal/database/doc.go +++ b/internal/database/doc.go @@ -1,6 +1,7 @@ package database import ( + "regexp" "strings" "github.com/ZenNotes/tui/internal/vault" @@ -268,6 +269,150 @@ func (d *Doc) ComposePageBody(row Row, body string) string { return strings.Join(lines, "\n") + "\n" + strings.TrimLeft(body, "\n") } +var ( + pageNameBad = regexp.MustCompile(`[\\/:\x00-\x1f*?"<>|]`) + spaceRun = regexp.MustCompile(`\s+`) +) + +// RecordPageCandidates lists where a record's page may already be without +// being mapped to its row: a note named after the record in the database +// folder, or in its `pages/` subfolder, under each name a page gets. The +// app names a local page by its note-title rules, the server path and zn +// only replace slashes, and a script someone wrote to make the pages likely +// used the title as it is. Mirrors recordPageCandidates in the app's +// shared-domain database-records.ts, name for name. +func (d *Doc) RecordPageCandidates(title string) []string { + formDir := FormDirFromCSVPath(toPosix(d.Path)) + raw := strings.TrimSpace(title) + if formDir == "" || raw == "" { + return nil + } + names := []string{} + add := func(name string) { + if name == "" { + return + } + for _, existing := range names { + if existing == name { + return + } + } + names = append(names, name) + } + app := strings.TrimSpace(spaceRun.ReplaceAllString(pageNameBad.ReplaceAllString(raw, "-"), " ")) + if runes := []rune(app); len(runes) > 200 { + app = string(runes[:200]) + } + add(app) + add(strings.NewReplacer("\\", "-", "/", "-").Replace(raw)) + if !strings.ContainsAny(raw, `\/`) { + add(raw) + } + out := []string{} + for _, dir := range []string{formDir, formDir + "/pages"} { + for _, name := range names { + out = append(out, dir+"/"+name+".md") + } + } + return out +} + +// RecordPageToAdopt is the unmapped page file a record should be linked to, +// or "": the first of its candidates that exists and no row maps. Before +// this, opening a record whose page file was there but unmapped wrote a +// `Title 2.md` beside it. A record with an empty title has no name to match. +func (d *Doc) RecordPageToAdopt(row Row, exists func(notePath string) (bool, error), taken map[string]bool) (string, error) { + titleID := d.TitleFieldID() + if titleID == "" { + return "", nil + } + if taken == nil { + taken = map[string]bool{} + for _, page := range d.Pages { + taken[page] = true + } + } + for _, candidate := range d.RecordPageCandidates(row.Cells[titleID]) { + if taken[candidate] { + continue + } + ok, err := exists(candidate) + if err != nil { + return "", err + } + if ok { + return candidate, nil + } + } + return "", nil +} + +// AdoptedPage is a record linked to a page file that was already there. +type AdoptedPage struct { + Row Row + Page string +} + +// RecordPagePlan is what giving every record its page would do. +type RecordPagePlan struct { + // Missing are the records with no page, or whose page is gone, in row order. + Missing []Row + // Adopt are the records whose page file is already there, unmapped: + // linked, not written. + Adopt []AdoptedPage + // Existing counts the records whose page is mapped and still there. + Existing int + // Untitled counts the records with an empty title field, left without a page. + Untitled int +} + +// PlanRecordPages plans `zn base pages` with the desktop app's rules for +// "Create pages for all records" (planRecordPages in the app's +// shared-domain database-records.ts). A record is a note links can reach +// only once it has a page. One whose page is mapped and still exists keeps +// it; one whose page was deleted gets a new one, as opening it would; one +// with an empty title is left out, since its page would only be "Untitled" +// and nothing could link to it by name. A record whose page file is already +// in the folder but not mapped (written by a script, or left by an older +// map) is linked to that file instead of getting a `Title 2.md` beside it; +// a file another row maps, or an earlier record took, is never taken twice. +func (d *Doc) PlanRecordPages(exists func(notePath string) (bool, error)) (RecordPagePlan, error) { + plan := RecordPagePlan{} + titleID := d.TitleFieldID() + // Every mapped page belongs to its row, even one that is gone for now. + taken := map[string]bool{} + for _, page := range d.Pages { + taken[page] = true + } + for _, row := range d.Rows { + if page := d.Pages[row.ID]; page != "" { + ok, err := exists(page) + if err != nil { + return plan, err + } + if ok { + plan.Existing++ + continue + } + } + if titleID == "" || strings.TrimSpace(row.Cells[titleID]) == "" { + plan.Untitled++ + continue + } + found, err := d.RecordPageToAdopt(row, exists, taken) + if err != nil { + return plan, err + } + if found != "" { + taken[found] = true + plan.Adopt = append(plan.Adopt, AdoptedPage{Row: row, Page: found}) + continue + } + plan.Missing = append(plan.Missing, row) + } + return plan, nil +} + // SetCell writes one cell. func (d *Doc) SetCell(rowID, fieldID, value string) { for i := range d.Rows { diff --git a/internal/database/ops.go b/internal/database/ops.go index 09bd33b..5645c9d 100644 --- a/internal/database/ops.go +++ b/internal/database/ops.go @@ -670,6 +670,17 @@ func (o *Ops) CreateRecordPage(csvPath, title, body string) (string, error) { return noteRel, nil } +// RecordPageExists reports whether a record page note is there. A read error +// comes back as an error, never as a missing page, so a failing read cannot +// mint a second page for a record that has one. +func (o *Ops) RecordPageExists(notePath string) (bool, error) { + text, err := o.io.ReadFileTextOrNull(toPosix(notePath)) + if err != nil { + return false, err + } + return text != nil, nil +} + // RenameDatabase renames the `.base` folder and returns the new csv path. func (o *Ops) RenameDatabase(csvPath, newTitle string) (string, error) { oldFormDir := FormDirFromCSVPath(toPosix(csvPath)) diff --git a/internal/database/record_pages_test.go b/internal/database/record_pages_test.go new file mode 100644 index 0000000..662d9c6 --- /dev/null +++ b/internal/database/record_pages_test.go @@ -0,0 +1,94 @@ +package database + +import ( + "errors" + "reflect" + "testing" +) + +// The same rules as the desktop's "Create pages for all records". +func TestPlanRecordPages(t *testing.T) { + d := testDoc(t) + d.Rows = []Row{ + {ID: "r1", Cells: map[string]string{"f0": "r1", "f1": "Dune"}}, + {ID: "r2", Cells: map[string]string{"f0": "r2", "f1": "Emma"}}, + {ID: "r3", Cells: map[string]string{"f0": "r3", "f1": "Kindred"}}, + {ID: "r4", Cells: map[string]string{"f0": "r4", "f1": " "}}, + } + d.Pages = map[string]string{"r1": "Books.base/Dune.md", "r2": "Books.base/Emma.md"} + plan, err := d.PlanRecordPages(func(p string) (bool, error) { return p == "Books.base/Dune.md", nil }) + if err != nil { + t.Fatal(err) + } + ids := []string{} + for _, row := range plan.Missing { + ids = append(ids, row.ID) + } + // Emma's page was deleted, so she gets a new one; the blank title is left out. + if !reflect.DeepEqual(ids, []string{"r2", "r3"}) || plan.Existing != 1 || plan.Untitled != 1 { + t.Fatalf("plan: missing %v, existing %d, untitled %d", ids, plan.Existing, plan.Untitled) + } +} + +// A read that fails is not a missing page: planning stops instead of +// minting a second page for a record that has one. +func TestPlanRecordPagesStopsOnAReadError(t *testing.T) { + d := testDoc(t) + d.Pages = map[string]string{"r1": "Books.base/Dune.md"} + boom := errors.New("server unreachable") + if _, err := d.PlanRecordPages(func(string) (bool, error) { return false, boom }); !errors.Is(err, boom) { + t.Fatalf("want the read error, got %v", err) + } +} + +// The same names the app's recordPageCandidates gives, so the two link the +// same files. +func TestRecordPageCandidatesMatchTheApp(t *testing.T) { + d := testDoc(t) + want := []string{ + "Books.base/What- a -guide--.md", + `Books.base/What: a "guide"?.md`, + "Books.base/pages/What- a -guide--.md", + `Books.base/pages/What: a "guide"?.md`, + } + if got := d.RecordPageCandidates(`What: a "guide"?`); !reflect.DeepEqual(got, want) { + t.Fatalf("candidates:\n got %q\nwant %q", got, want) + } + // A slash in a title never makes a path of it. + if got := d.RecordPageCandidates("A/B"); !reflect.DeepEqual(got, []string{"Books.base/A-B.md", "Books.base/pages/A-B.md"}) { + t.Fatalf("slash: %q", got) + } +} + +// A page file already in the folder, unmapped, is linked rather than copied; +// one another row maps is never taken, and twins share no file. +func TestPlanRecordPagesLinksPageFilesAlreadyThere(t *testing.T) { + d := testDoc(t) + d.Rows = []Row{ + {ID: "r1", Cells: map[string]string{"f0": "r1", "f1": "Dune"}}, + {ID: "r2", Cells: map[string]string{"f0": "r2", "f1": "Kindred"}}, + {ID: "r3", Cells: map[string]string{"f0": "r3", "f1": "Emma"}}, + {ID: "r4", Cells: map[string]string{"f0": "r4", "f1": "Dune"}}, + } + d.Pages = map[string]string{"r9": "Books.base/Emma.md"} + files := map[string]bool{"Books.base/Dune.md": true, "Books.base/pages/Kindred.md": true, "Books.base/Emma.md": true} + plan, err := d.PlanRecordPages(func(p string) (bool, error) { return files[p], nil }) + if err != nil { + t.Fatal(err) + } + adopted := [][2]string{} + for _, a := range plan.Adopt { + adopted = append(adopted, [2]string{a.Row.ID, a.Page}) + } + if !reflect.DeepEqual(adopted, [][2]string{{"r1", "Books.base/Dune.md"}, {"r2", "Books.base/pages/Kindred.md"}}) { + t.Fatalf("adopted: %v", adopted) + } + missing := []string{} + for _, row := range plan.Missing { + missing = append(missing, row.ID) + } + // Emma's file belongs to another row; the second Dune finds its file taken. + if !reflect.DeepEqual(missing, []string{"r3", "r4"}) { + t.Fatalf("missing: %v", missing) + } +} diff --git a/internal/tui/database_test.go b/internal/tui/database_test.go index ef6ba17..012d801 100644 --- a/internal/tui/database_test.go +++ b/internal/tui/database_test.go @@ -288,3 +288,38 @@ func TestParseDateInput(t *testing.T) { t.Fatal("nonsense is rejected") } } + +// Opening a record whose page file is already in the folder but not mapped +// (a workaround script wrote it) links that file instead of writing a +// `Dune 2.md` beside it. +func TestOpenRecordPageLinksAPageFileAlreadyThere(t *testing.T) { + a, root := newDatabaseTestApp(t) + v := openTestDatabase(t, a) + next, err := a.backend.DatabaseOps().ConvertToFolder(v.csvPath) + if err != nil { + t.Fatal(err) + } + notes := "# Dune\n\nMy notes on Dune.\n" + if err := os.WriteFile(filepath.Join(root, "Books.base", "Dune.md"), []byte(notes), 0o644); err != nil { + t.Fatal(err) + } + v.csvPath = next + v.refresh(a) + var dune string + for _, row := range v.doc.Rows { + if v.doc.RecordTitle(row) == "Dune" { + dune = row.ID + } + } + v.focusRow(dune) + v.openRecordPage(a) + if _, err := os.Stat(filepath.Join(root, "Books.base", "Dune 2.md")); !os.IsNotExist(err) { + t.Fatal("opening wrote a second page beside the one already there") + } + if got := v.doc.Pages[dune]; got != "Books.base/Dune.md" { + t.Fatalf("Dune maps to %q", got) + } + if kept, _ := os.ReadFile(filepath.Join(root, "Books.base", "Dune.md")); string(kept) != notes { + t.Fatalf("the page changed: %q", kept) + } +} diff --git a/internal/tui/views_database.go b/internal/tui/views_database.go index 4e97d19..2d662a3 100644 --- a/internal/tui/views_database.go +++ b/internal/tui/views_database.go @@ -1026,6 +1026,21 @@ func (v *databaseView) openRecordPage(a *App) { return } ops := a.backend.DatabaseOps() + // A page file already there under the record's name but not mapped (a + // script wrote it, say) is linked, not shadowed by a `Title 2.md`. + if page, err := v.doc.RecordPageToAdopt(*r, ops.RecordPageExists, nil); err == nil && page != "" { + if v.doc.Pages == nil { + v.doc.Pages = map[string]string{} + } + v.doc.Pages[r.ID] = page + if _, err := ops.WriteSchema(v.csvPath, v.doc); err != nil { + a.notifyError(err.Error()) + } + a.refreshIndex() + a.notify("Linked record page " + page) + a.openNote(page, true) + return + } title := v.doc.RecordTitle(*r) body := v.doc.ComposePageBody(*r, "") path, err := ops.CreateRecordPage(v.csvPath, title, body)