From afd8a35e237a65c5c514eda4967cb5ab7bd3fded Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 22 Oct 2026 11:43:10 -0500 Subject: [PATCH 1/2] Feat(base): zn base pages gives every record its record page A database record is a note links can reach only once it has a page, and `zn base add --page` made one only for a new row; an existing record got its page when someone opened it, one at a time. A database converted from a CSV had none, so none of its records could be linked. `zn base pages ` gives every record that has no page its record page, the desktop's "Create pages for all records" (ZenNotes 2.74.0): the row's properties as frontmatter and its title as the heading, as `zn base add --page` writes them. Doc.PlanRecordPages follows the app's rules (planRecordPages in its shared-domain database-records.ts): a record whose page is mapped and still there keeps it, one whose page was deleted gets a new one, and one with an empty title is left out. Ops.RecordPageExists returns a read error as an error rather than as a missing page, so a failing server read stops the run instead of minting a second page for a record that has one. The schema is written once, and kept when a page fails partway, so the pages already written stay linked to their rows. `--dry-run` lists what it would create, `--json` reports the pages, and a loose .csv is refused with a pointer to `zn base convert`. The help row also registers the command and its flags for parsing and completion. On a vault with Books.base holding only its data.csv: --dry-run listed Dune, Kindred and Emma and wrote nothing; the run created the three pages and mapped them in schema.json, relative to the folder as the app keeps them; a second run found all three. The desktop app opened that vault with links to the records live and counted every page as there. Tests: the planner, a read error stopping it, the command's pages and schema, a second run writing nothing, --dry-run, and a loose .csv. go vet and go test ./... clean. --- README.md | 5 +- internal/cli/args.go | 1 + internal/cli/base_pages_test.go | 145 +++++++++++++++++++++++++ internal/cli/commands_base.go | 95 ++++++++++++++++ internal/cli/help.go | 1 + internal/cli/main.go | 1 + internal/database/doc.go | 40 +++++++ internal/database/ops.go | 11 ++ internal/database/record_pages_test.go | 42 +++++++ 9 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 internal/cli/base_pages_test.go create mode 100644 internal/database/record_pages_test.go diff --git a/README.md b/README.md index 356fb4e..1b6a36c 100644 --- a/README.md +++ b/README.md @@ -234,13 +234,16 @@ 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 (`--dry-run` lists what it would create). ### 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..b52b567 --- /dev/null +++ b/internal/cli/base_pages_test.go @@ -0,0 +1,145 @@ +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) + } +} diff --git a/internal/cli/commands_base.go b/internal/cli/commands_base.go index dafa86b..173bd9e 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,97 @@ 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{} + 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) > 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, + "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: + emitOK("Every titled record in " + summary.Title + " already has a page") + case dryRun: + emitLine("Would create " + noun(len(pages), "record page", "record pages") + " in " + summary.Title + ":") + for _, p := range pages { + emitLine(" " + p.Title) + } + default: + emitOK("Created " + noun(len(pages), "record page", "record pages") + " in " + summary.Title) + } + 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..d8aac26 100644 --- a/internal/database/doc.go +++ b/internal/database/doc.go @@ -268,6 +268,46 @@ func (d *Doc) ComposePageBody(row Row, body string) string { return strings.Join(lines, "\n") + "\n" + strings.TrimLeft(body, "\n") } +// 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 + // 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. +func (d *Doc) PlanRecordPages(exists func(notePath string) (bool, error)) (RecordPagePlan, error) { + plan := RecordPagePlan{} + titleID := d.TitleFieldID() + 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 + } + 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..5b18432 --- /dev/null +++ b/internal/database/record_pages_test.go @@ -0,0 +1,42 @@ +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) + } +} From ced7926715549e85349e545f780a9e4bdca1585f Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 22 Oct 2026 11:53:20 -0500 Subject: [PATCH 2/2] Fix(base): a record's page file already in the folder gets linked, not copied A Discord user's workaround script wrote a page file per record into the database folder without mapping it in schema.json. `zn base pages` took those records for pageless and wrote `Dune 2.md` beside the file, and opening a record in the TUI did the same, as it always had for an unmapped file. A record with no mapped page is now linked to a page file already there under its name, in the database folder or its `pages/` subfolder, under each name a page gets: the app's note-title rules, the slash-only rule zn and the server path use, and the title as it is. Doc.RecordPageCandidates gives the same names as the app's recordPageCandidates (a test pins them to the app's case), and Doc.RecordPageToAdopt picks the first that exists and no row maps. PlanRecordPages lists those records under Adopt, `zn base pages` maps them without writing and reports them as `linked` (and under --dry-run), and the TUI's open links the file before it would create a page. A file another row maps, or an earlier record with the same title took, is never taken twice. Tests: the candidate names, files in the folder and in pages/ linked, one mapped by another row left alone, twins sharing no file, the command linking a script's page as it is, and the TUI open linking instead of writing `Dune 2.md` (which fails without this change). go vet and go test ./... clean. --- README.md | 4 +- internal/cli/base_pages_test.go | 36 +++++++++ internal/cli/commands_base.go | 37 +++++++-- internal/database/doc.go | 107 ++++++++++++++++++++++++- internal/database/record_pages_test.go | 52 ++++++++++++ internal/tui/database_test.go | 35 ++++++++ internal/tui/views_database.go | 15 ++++ 7 files changed, 278 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1b6a36c..20637fc 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,9 @@ replies and resolve/reopen operations take a thread ID. `base add` and `base add --body` or `--page` also creates a record page. `base convert` 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 (`--dry-run` lists what it would create). +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/base_pages_test.go b/internal/cli/base_pages_test.go index b52b567..b391a27 100644 --- a/internal/cli/base_pages_test.go +++ b/internal/cli/base_pages_test.go @@ -143,3 +143,39 @@ func TestBasePagesRefusesALooseCSV(t *testing.T) { 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 173bd9e..3e91208 100644 --- a/internal/cli/commands_base.go +++ b/internal/cli/commands_base.go @@ -494,6 +494,17 @@ func cmdBasePages(_ context.Context, b backend.Backend, args Args) error { } 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) @@ -514,7 +525,7 @@ func cmdBasePages(_ context.Context, b backend.Backend, args Args) error { } // 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) > 0 { + if !dryRun && len(pages)+len(linked) > 0 { if _, err := ops.WriteSchema(summary.Path, doc); err != nil { return err } @@ -528,6 +539,7 @@ func cmdBasePages(_ context.Context, b backend.Backend, args Args) error { "path": summary.Path, "dryRun": dryRun, "pages": pages, + "linked": linked, "existing": plan.Existing, "untitled": plan.Untitled, }) @@ -540,15 +552,28 @@ func cmdBasePages(_ context.Context, b backend.Backend, args Args) error { return strconv.Itoa(n) + " " + many } switch { - case len(pages) == 0: + case len(pages) == 0 && len(linked) == 0: emitOK("Every titled record in " + summary.Title + " already has a page") case dryRun: - emitLine("Would create " + noun(len(pages), "record page", "record pages") + " in " + summary.Title + ":") - for _, p := range pages { - emitLine(" " + p.Title) + 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: - emitOK("Created " + noun(len(pages), "record page", "record pages") + " in " + summary.Title) + 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")) diff --git a/internal/database/doc.go b/internal/database/doc.go index d8aac26..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,10 +269,97 @@ 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. @@ -284,10 +372,18 @@ type RecordPagePlan struct { // 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. +// 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) @@ -303,6 +399,15 @@ func (d *Doc) PlanRecordPages(exists func(notePath string) (bool, error)) (Recor 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 diff --git a/internal/database/record_pages_test.go b/internal/database/record_pages_test.go index 5b18432..662d9c6 100644 --- a/internal/database/record_pages_test.go +++ b/internal/database/record_pages_test.go @@ -40,3 +40,55 @@ func TestPlanRecordPagesStopsOnAReadError(t *testing.T) { 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)