Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`. 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

Expand Down
1 change: 1 addition & 0 deletions internal/cli/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
181 changes: 181 additions & 0 deletions internal/cli/base_pages_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
120 changes: 120 additions & 0 deletions internal/cli/commands_base.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strconv"
"strings"

"github.com/ZenNotes/tui/internal/backend"
Expand Down Expand Up @@ -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
}
1 change: 1 addition & 0 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ var helpSections = []helpSection{
{"base add <base>", "Add a row; --body (or --body - for stdin) also creates its record page", "--set Field=Value … --body <text|-> --page --json"},
{"base set <base> <row>", "Set fields; select options mint, the page re-mirrors", "--set Field=Value … --json"},
{"base convert <base>", "Turn a loose .csv into a .base folder so rows can have record pages", "--json"},
{"base pages <base>", "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 <f> --tag <t> --title <t> --json"},
Expand Down
1 change: 1 addition & 0 deletions internal/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func dispatchTable() map[string]Handler {
"base add": cmdBaseAdd,
"base set": cmdBaseSet,
"base convert": cmdBaseConvert,
"base pages": cmdBasePages,
"capture": cmdCapture,
}
}
Expand Down
Loading
Loading