diff --git a/README.md b/README.md index f91f2df..89dab49 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,17 @@ Frontmatter is parsed line-by-line — no YAML dependency. Four fields: `synopsis` is the key design decision: read it, decide whether to open the file, move on. `source` lets future-you reconstruct *why* a note exists without re-reading it. +### Schema contract + +The frontmatter contract is published as a public, stdlib-only Go package at `github.com/polymorcodeus/park/schema` and exposed through the CLI: + +```bash +park schema # human-readable contract +park schema --json # machine-readable contract for downstream tooling +``` + +The JSON output includes the canonical category enum, field kinds, the date format, and the exact write template. Import the package directly when building tools that write or validate park notes. + ### Reclassification `park reclassify -c ` rewrites frontmatter *before* moving the file. A failed move never leaves a note in a half-updated state. Same-category moves are rejected. @@ -109,6 +120,8 @@ Frontmatter is parsed line-by-line — no YAML dependency. Four fields: | `show ` | glamour-render a note, no TUI | | `reclassify -c ` | reclassify a note (alias `recat`) | | `config` | print the default TOML config | +| `schema` | print the frontmatter schema contract | +| `schema --json` | print the contract as machine-readable JSON | ### `park new` options diff --git a/VERSION b/VERSION index d4dfa56..fb7a04c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.3.0 \ No newline at end of file +v0.4.0 diff --git a/cmd/park/main.go b/cmd/park/main.go index e076d0c..364a6af 100644 --- a/cmd/park/main.go +++ b/cmd/park/main.go @@ -45,6 +45,7 @@ func Main() { parkRoot, parkConfig string reclassifyCategory string newCategory string + schemaJSON bool ) defaultRoot := os.Getenv("PARK_ROOT") @@ -221,6 +222,23 @@ func Main() { return nil }, }, + { + Name: "schema", + Usage: "print the frontmatter schema contract", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Destination: &schemaJSON, + Usage: "output machine-readable JSON", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + if err := schemaPark(schemaJSON, cmd.Root().Writer); err != nil { + return styledExit(err, 1) + } + return nil + }, + }, { Name: "show", Usage: "render a note to the terminal", diff --git a/cmd/park/park.go b/cmd/park/park.go index edfac08..cb36287 100644 --- a/cmd/park/park.go +++ b/cmd/park/park.go @@ -1,9 +1,13 @@ package cmd import ( + "encoding/json" "fmt" "io" "os" + "strings" + + "github.com/polymorcodeus/park/schema" tea "charm.land/bubbletea/v2" "github.com/urfave/cli/v3" @@ -104,6 +108,49 @@ func runNoteForm(cfg *config.Config, w io.Writer, seed *note.Draft) error { return nil } +// schemaPark prints the canonical frontmatter schema, either as JSON or as +// a short human-readable summary. +func schemaPark(asJSON bool, w io.Writer) error { + if asJSON { + data, err := json.MarshalIndent(schema.Describe(), "", " ") + if err != nil { + return fmt.Errorf("marshal schema: %w", err) + } + if _, err := fmt.Fprintln(w, string(data)); err != nil { + return fmt.Errorf("write schema output: %w", err) + } + return nil + } + + s := schema.Describe() + var b strings.Builder + fmt.Fprintf(&b, "park frontmatter schema (version %d)\n\n", s.SchemaVersion) + fmt.Fprintln(&b, "Fields:") + for _, f := range s.Fields { + extra := "" + switch f.Kind { + case "enum": + extra = "values: " + strings.Join(f.Values, ", ") + case "date": + extra = "format: " + f.Format + if f.Auto { + extra += " (auto-stamped)" + } + } + if extra != "" { + fmt.Fprintf(&b, " %-8s required %-5s %s\n", f.Name, f.Kind, extra) + } else { + fmt.Fprintf(&b, " %-8s required %-5s\n", f.Name, f.Kind) + } + } + fmt.Fprintf(&b, "\nDate format: %s\n", s.DateFormat) + fmt.Fprintf(&b, "Write template:\n%s", s.WriteTemplate) + if _, err := fmt.Fprint(w, b.String()); err != nil { + return fmt.Errorf("write schema output: %w", err) + } + return nil +} + func assistPark(cfg *config.Config, w io.Writer) error { m, err := model.NewAssistModel(cfg) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index c2c45e4..d9df4ec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ import ( "github.com/BurntSushi/toml" "github.com/polymorcodeus/park/internal/fs" + "github.com/polymorcodeus/park/schema" ) // Config is the top-level configuration for park. @@ -189,12 +190,12 @@ func DefaultConfig(root string) *Config { root = DefaultRootPath() } return &Config{ - DefaultCategory: "inbox", + DefaultCategory: string(schema.CategoryInbox), Categories: []Category{ - {Name: "inbox", Path: filepath.Join(root, "_inbox"), Key: "i"}, - {Name: "projects", Path: filepath.Join(root, "_projects"), Key: "p"}, - {Name: "areas", Path: filepath.Join(root, "_areas"), Key: "a"}, - {Name: "archive", Path: filepath.Join(root, "_archive"), Key: "x"}, + {Name: string(schema.CategoryInbox), Path: filepath.Join(root, "_inbox"), Key: "i"}, + {Name: string(schema.CategoryProjects), Path: filepath.Join(root, "_projects"), Key: "p"}, + {Name: string(schema.CategoryAreas), Path: filepath.Join(root, "_areas"), Key: "a"}, + {Name: string(schema.CategoryArchive), Path: filepath.Join(root, "_archive"), Key: "x"}, }, } } diff --git a/internal/model/assist.go b/internal/model/assist.go index 7cba034..e7fdf83 100644 --- a/internal/model/assist.go +++ b/internal/model/assist.go @@ -11,6 +11,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/polymorcodeus/park/internal/config" "github.com/polymorcodeus/park/internal/store" + "github.com/polymorcodeus/park/schema" ) // listItem adapts an Item to bubbles/list's list.Item interface. @@ -19,7 +20,7 @@ type listItem struct { } func (i listItem) Title() string { - created, _ := time.Parse("2006-01-02", i.item.Created) + created, _ := time.Parse(schema.DateFormat, i.item.Created) if created.IsZero() { created = i.item.ModTime } diff --git a/internal/note/note.go b/internal/note/note.go index 95d8b2c..6278f8a 100644 --- a/internal/note/note.go +++ b/internal/note/note.go @@ -12,38 +12,12 @@ import ( "github.com/polymorcodeus/park/internal/config" "github.com/polymorcodeus/park/internal/fs" + "github.com/polymorcodeus/park/schema" ) -// Metadata is the set of fields persisted as frontmatter in every note. -type Metadata struct { - Category string - Created string - Source string - Synopsis string -} - -// IsComplete reports whether all metadata fields are populated. -func (m Metadata) IsComplete() bool { - return fieldSet(m.Category) && fieldSet(m.Created) && fieldSet(m.Source) && fieldSet(m.Synopsis) -} - -// MissingFields returns the metadata fields that are empty. -func (m Metadata) MissingFields() []string { - var missing []string - if !fieldSet(m.Category) { - missing = append(missing, "category") - } - if !fieldSet(m.Created) { - missing = append(missing, "created") - } - if !fieldSet(m.Source) { - missing = append(missing, "source") - } - if !fieldSet(m.Synopsis) { - missing = append(missing, "synopsis") - } - return missing -} +// Metadata is the shared metadata block for parked notes. It is an alias +// so the canonical contract lives in one place: the public schema package. +type Metadata = schema.Frontmatter // Note is the persisted representation of a parked note. Path is empty when // the note is parsed from a string rather than read from a file. @@ -242,8 +216,7 @@ func Write(path string, n Note) (err error) { } }() - if _, err = fmt.Fprintf(f, "---\ncategory: %s\ncreated: %s\nsource: %s\nsynopsis: %s\n---\n\n%s\n", - n.Category, n.Created, n.Source, n.Synopsis, n.Body); err != nil { + if _, err = fmt.Fprint(f, schema.Render(n.Metadata, n.Body)); err != nil { return fmt.Errorf("write temp %q: %w", tmpPath, err) } @@ -255,7 +228,7 @@ func Write(path string, n Note) (err error) { // Today returns the current date in the frontmatter's date format. func Today() string { - return time.Now().Format("2006-01-02") + return time.Now().Format(schema.DateFormat) } // Result is the outcome of attempting to add a note headlessly. @@ -393,7 +366,7 @@ func Create(cfg *config.Config, d Draft) (string, error) { n := Note{ Path: path, Body: d.Body, - Metadata: Metadata{ + Metadata: schema.Frontmatter{ Category: d.Category, Created: Today(), Source: d.Source, diff --git a/schema/schema.go b/schema/schema.go new file mode 100644 index 0000000..b168d46 --- /dev/null +++ b/schema/schema.go @@ -0,0 +1,121 @@ +// Package schema describes the canonical frontmatter contract for parked +// notes. It is a pure leaf package: no internal dependencies, stdlib only, so +// downstream tooling can import the contract instead of re-deriving it. +package schema + +import ( + "strings" +) + +// SchemaVersion is the current version of the frontmatter contract. +// It increments on any breaking change to the contract. +const SchemaVersion = 1 + +// DateFormat is the canonical date layout used for the `created` field. +const DateFormat = "2006-01-02" + +// Category is a canonical parked-note category. The set below is the +// contract's enum and is independent of any loaded TOML config. +type Category string + +// Canonical categories. +const ( + CategoryInbox Category = "inbox" + CategoryProjects Category = "projects" + CategoryAreas Category = "areas" + CategoryArchive Category = "archive" +) + +// Categories returns the canonical category values in order. +func Categories() []string { + return []string{ + string(CategoryInbox), + string(CategoryProjects), + string(CategoryAreas), + string(CategoryArchive), + } +} + +// Frontmatter is the set of fields persisted as frontmatter in every note. +type Frontmatter struct { + Category string + Created string + Source string + Synopsis string +} + +// IsComplete reports whether all frontmatter fields are populated. +func (f Frontmatter) IsComplete() bool { + return fieldSet(f.Category) && fieldSet(f.Created) && fieldSet(f.Source) && fieldSet(f.Synopsis) +} + +// MissingFields returns the frontmatter fields that are empty. +func (f Frontmatter) MissingFields() []string { + var missing []string + if !fieldSet(f.Category) { + missing = append(missing, "category") + } + if !fieldSet(f.Created) { + missing = append(missing, "created") + } + if !fieldSet(f.Source) { + missing = append(missing, "source") + } + if !fieldSet(f.Synopsis) { + missing = append(missing, "synopsis") + } + return missing +} + +// WriteTemplate is the canonical frontmatter+body layout. Placeholders are +// {category}, {created}, {source}, {synopsis}, and {body}. +const WriteTemplate = "---\ncategory: {category}\ncreated: {created}\nsource: {source}\nsynopsis: {synopsis}\n---\n\n{body}\n" + +// Render returns the frontmatter block and body with the given values +// substituted into WriteTemplate. +func Render(f Frontmatter, body string) string { + return strings.NewReplacer( + "{category}", f.Category, + "{created}", f.Created, + "{source}", f.Source, + "{synopsis}", f.Synopsis, + "{body}", body, + ).Replace(WriteTemplate) +} + +func fieldSet(s string) bool { + return strings.TrimSpace(s) != "" +} + +// Field describes a single frontmatter field in the JSON schema export. +type Field struct { + Name string `json:"name"` + Required bool `json:"required"` + Kind string `json:"kind"` + Values []string `json:"values,omitempty"` + Format string `json:"format,omitempty"` + Auto bool `json:"auto,omitempty"` +} + +// Spec is the JSON-serializable schema description. +type Spec struct { + SchemaVersion int `json:"schema_version"` + Fields []Field `json:"fields"` + DateFormat string `json:"date_format"` + WriteTemplate string `json:"write_template"` +} + +// Describe returns the current schema contract as a serializable value. +func Describe() Spec { + return Spec{ + SchemaVersion: SchemaVersion, + Fields: []Field{ + {Name: "category", Required: true, Kind: "enum", Values: Categories()}, + {Name: "created", Required: true, Kind: "date", Format: DateFormat, Auto: true}, + {Name: "source", Required: true, Kind: "string"}, + {Name: "synopsis", Required: true, Kind: "string"}, + }, + DateFormat: DateFormat, + WriteTemplate: WriteTemplate, + } +} diff --git a/schema/schema_test.go b/schema/schema_test.go new file mode 100644 index 0000000..0b3a2db --- /dev/null +++ b/schema/schema_test.go @@ -0,0 +1,102 @@ +package schema + +import ( + "encoding/json" + "slices" + "testing" +) + +func TestSchemaVersion(t *testing.T) { + if SchemaVersion != 1 { + t.Errorf("SchemaVersion = %d, want 1", SchemaVersion) + } +} + +func TestCategories(t *testing.T) { + want := []string{"inbox", "projects", "areas", "archive"} + got := Categories() + if !slices.Equal(got, want) { + t.Errorf("Categories() = %v, want %v", got, want) + } +} + +func TestFrontmatterIsComplete(t *testing.T) { + complete := Frontmatter{Category: "inbox", Created: "2026-08-31", Source: "test", Synopsis: "ok"} + if !complete.IsComplete() { + t.Error("complete frontmatter reported incomplete") + } + + incomplete := Frontmatter{Category: "inbox", Source: "test", Synopsis: "ok"} + if incomplete.IsComplete() { + t.Error("incomplete frontmatter reported complete") + } +} + +func TestFrontmatterMissingFields(t *testing.T) { + f := Frontmatter{Category: "inbox", Source: "test"} + got := f.MissingFields() + want := []string{"created", "synopsis"} + if !slices.Equal(got, want) { + t.Errorf("MissingFields() = %v, want %v", got, want) + } +} + +func TestRender(t *testing.T) { + f := Frontmatter{ + Category: "projects", + Created: "2026-08-31", + Source: "terminal", + Synopsis: "a note", + } + got := Render(f, "# body\n") + want := "---\ncategory: projects\ncreated: 2026-08-31\nsource: terminal\nsynopsis: a note\n---\n\n# body\n\n" + if got != want { + t.Errorf("Render() = %q, want %q", got, want) + } +} + +func TestDescribe(t *testing.T) { + s := Describe() + + if s.SchemaVersion != SchemaVersion { + t.Errorf("Spec.SchemaVersion = %d, want %d", s.SchemaVersion, SchemaVersion) + } + + if s.DateFormat != DateFormat { + t.Errorf("Spec.DateFormat = %q, want %q", s.DateFormat, DateFormat) + } + + if s.WriteTemplate != WriteTemplate { + t.Errorf("Spec.WriteTemplate = %q, want %q", s.WriteTemplate, WriteTemplate) + } + + if len(s.Fields) != 4 { + t.Fatalf("len(Spec.Fields) = %d, want 4", len(s.Fields)) + } + + category := s.Fields[0] + if category.Name != "category" || category.Kind != "enum" || !category.Required { + t.Errorf("category field = %+v, want required enum", category) + } + if !slices.Equal(category.Values, Categories()) { + t.Errorf("category values = %v, want %v", category.Values, Categories()) + } + + created := s.Fields[1] + if created.Name != "created" || created.Kind != "date" || created.Format != DateFormat || !created.Auto { + t.Errorf("created field = %+v, want date with format and auto", created) + } + + data, err := json.Marshal(s) + if err != nil { + t.Fatalf("json.Marshal(Spec) error = %v", err) + } + + var round Spec + if err := json.Unmarshal(data, &round); err != nil { + t.Fatalf("json.Unmarshal(Spec) error = %v", err) + } + if round.SchemaVersion != SchemaVersion { + t.Errorf("round-trip SchemaVersion = %d, want %d", round.SchemaVersion, SchemaVersion) + } +}