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
134 changes: 134 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Contributing

## Prerequisites

- Go 1.26.4+
- `golangci-lint` for `make lint` (install with `make deps`)

## Getting started

```bash
git clone https://github.com/polymorcodeus/park.git
cd park
make build
```

## Before opening a PR

```bash
make check # fmt, vet, lint, test
```

- Keep PRs focused: one behavior change per PR.
- Update `README.md` and `CONTRIBUTING.md` when behavior, commands, or flags change; docs are part of done.
- Follow the conventions in this file (package boundaries, error wrapping, message ownership).

## Package boundaries

| Package | Does | Imports |
|---------|------|---------|
| `schema` | public frontmatter contract; canonical categories, version, and write template | stdlib only |
| `internal/config` | configuration schema, loading, validation | `internal/fs`, `schema` |
| `internal/note` | note content, frontmatter parsing/writing, creation, ingestion (`Note`, `Parse`, `Write`, `Add`, `Create`) | `internal/config`, `internal/fs`, `schema` |
| `internal/store` | on-disk item management, scanning, reclassification, list formatting | `internal/config`, `internal/note`, `schema` |
| `internal/render` | glamour-based rendering | `internal/note` |
| `internal/theme` | color constants | stdlib only |
| `internal/fs` | filesystem helpers (ExpandPath) | stdlib only |
| `internal/model` | Bubble Tea TUI screens | `internal/config`, `internal/note`, `internal/store`, `internal/theme` |
| `cmd/park` | CLI tree + wiring | everything |

## Conventions

These rules keep the package boundaries above meaningful as the codebase grows.

### File I/O and frontmatter

- `internal/fs` is the only package that expands `~` and `$HOME`. Ingestion
paths (`Draft.FromFile`) are normalized exactly once, in `note.IngestFile`,
so parsing, form preview, and source-file removal all see the same path.
- `internal/note` owns all frontmatter parsing and writing. Code outside this
package should not parse `---` blocks by hand.
- `internal/store` owns category-folder operations, resolving filenames to
full paths, and `park list` grouping/formatting (plain and JSON). Filename
resolution is unified in `ResolvePath`: a bare basename is searched across
every configured category folder, while a value containing a path separator
is treated as a literal path and never joined onto a category folder.
`park show` and `park reclassify` both resolve their `<file>` argument this
way, so a path can never double-join.
- `internal/model` may call `note.Create`, `store.Scan`, and `store.Reclassify`,
but should not read files directly from disk except through those packages.
- `cmd/park` parses CLI flags and delegates all file/content work to
`internal/note` or `internal/store`.

### Data models

- `schema.Frontmatter` is the canonical metadata block: `Category`,
`Created`, `Source`, `Synopsis`. It lives in the public `schema` package so
downstream tooling can import the contract instead of re-deriving it.
- `note.Metadata` is an alias to `schema.Frontmatter`. It is not validated in
isolation because its completeness depends on context.
- `note.Draft` is the creation-time model: `Filename`, `Body`, `FromFile`,
plus `Metadata`. `Created` may be empty; it is populated when the draft is
converted to a note. `Draft.ReadyToCreate()` checks `Filename`,
`Metadata.Category`, `Metadata.Source`, and `Metadata.Synopsis`.
- `note.Note` is the persisted model: `Path`, `Body`, plus complete
`Metadata` (`Created` always set). Completeness checks go through
`schema.Frontmatter.IsComplete()`; there is no `Note`-level completeness
wrapper.
- `store.Item` is the read/scanned model. It embeds `note.Metadata` plus
`Path`, `Filename`, and `ModTime`.
- `store.Group` is a category name plus its `[]Item`; `store.List` returns
groups in config order and `store.FormatList`/`store.WriteListJSON` render
them for `park list`.
- `Body` has the same meaning in `Draft` and `Note` (markdown content below
the frontmatter). During `Draft` → `Note` conversion, any embedded frontmatter
in `Body` is stripped and merged into `Metadata`, so `Note.Body` is always
clean.

### TUI

- Key bindings are matched in `Update` from the same `key.Binding` values that
render help (see `categoryBinding` in `internal/model/assist.go`, which pairs
each category name with its binding), so help text and behavior cannot drift.

### Errors and output

- Errors are wrapped with `fmt.Errorf("...: %w", err)` when crossing package
boundaries.
- User-facing message formatting belongs in the package that owns the data.
`cmd/park` wires output to the terminal but does not format store results.
- Category-validation errors are constructed once, by
`Config.UnknownCategoryError`; CLI `Before` hooks and domain packages call it
instead of hand-writing the "unknown category" message.
- Avoid `init()` for logic that can be explicit in `main()` or a constructor.
- Exported helpers with no callers get deleted, not kept "just in case": the
`unused` linter cannot see exported identifiers, so dead API only surfaces
in review.

## Styling

The TUI uses the Charm design system tokens:

- Page background: `#14121a`
- Raised surface: `#1c1a24`
- Primary text: `#f5f1fa`
- Muted text: `#a79fc0`
- Faint text: `#6f6785`
- Accent purple: `#7d56f4`
- Accent pink: `#FF4081`

Colors are defined as exported constants in `internal/theme/theme.go` so both
the TUI (`internal/model`) and the CLI's styled error output (`cmd/park`)
share the same palette.

## Implementation notes

- Frontmatter is flat `key: value` parsed line-by-line (no YAML dependency).
- `Reclassify` rewrites frontmatter *before* moving the file, so a failed move never
leaves a file in an inconsistent state.
- `schema` is a public, stdlib-only package. Other tools can import
`github.com/polymorcodeus/park/schema` to pin the frontmatter contract.
- `internal/config` has no dependency on `main.go`; importing the focused
packages into another CLI is just wiring commands to the exported functions.
- `Config` is loaded once in the CLI `Before` hook and passed as `*config.Config`
to all command helpers.
35 changes: 22 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

# park

[![Go Version](https://img.shields.io/github/go-mod/go-version/polymorcodeus/park)](https://go.dev/) [![Build Status](https://img.shields.io/github/actions/workflow/status/polymorcodeus/park/ci.yml?branch=main)](https://github.com/polymorcodeus/park/actions)
[![Go Version](https://img.shields.io/github/go-mod/go-version/polymorcodeus/park)](https://go.dev/) [![Build Status](https://img.shields.io/github/actions/workflow/status/polymorcodeus/park/ci.yml?branch=main)](https://github.com/polymorcodeus/park/actions) [![License](https://img.shields.io/github/license/polymorcodeus/park)](./LICENSE) [![Go Reference](https://pkg.go.dev/badge/github.com/polymorcodeus/park/schema.svg)](https://pkg.go.dev/github.com/polymorcodeus/park/schema)

**A parking lot for markdown notes, organized as IPAA (Inbox / Projects / Areas / Archive).**

Expand All @@ -20,7 +20,7 @@ Park surfaces notes mid-coding-session and keeps the Inbox skim-able through fro
park init
park new "revisit dashboard caching approach" \
-s "current TTL feels wrong, worth a spike" -src my-cli-tool
park new "keep an eye on API rate limits" -c area
park new "keep an eye on API rate limits" -c areas
park list
park assist
park reclassify 1767786622-idea.md -c projects
Expand All @@ -34,6 +34,8 @@ Quick install (downloads the latest release to `/usr/local/bin`):
curl -sSL https://raw.githubusercontent.com/polymorcodeus/park/main/install.sh | bash
```

The `go install` and source builds require Go 1.26.4+.

Or install via Go:

```bash
Expand All @@ -48,8 +50,6 @@ cd park
make build
```

Requires Go 1.26.4+.

## Setup

```bash
Expand Down Expand Up @@ -81,9 +81,11 @@ Notes are plain markdown files with frontmatter, one folder per category:
└── _archive/ # inactive, retained for reference
```

Layout shown for the Linux default root; on macOS the root is `~/Library/Application Support/park`.

### Frontmatter

```yaml
```text
---
category: inbox
created: 2026-07-16
Expand All @@ -92,14 +94,14 @@ synopsis: current TTL feels wrong, worth a spike
---
```

Frontmatter is parsed line-by-line — no YAML dependency. Four fields:
Frontmatter is parsed line-by-line -- no YAML dependency. Four fields:

| field | purpose |
|-------|---------|
| `category` | redundant with folder, kept so ad-hoc files still self-describe |
| `created` | ISO 8601 date, set at park time |
| `source` | where the note came from (repo, chat, stray idea) |
| `synopsis` | one-line summary — the entire mechanism that makes triage fast |
| `synopsis` | one-line summary -- the entire mechanism that makes triage fast |

`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.

Expand All @@ -118,6 +120,8 @@ The JSON output includes the canonical category enum, field kinds, the date form

`park reclassify <file> -c <category>` rewrites frontmatter *before* moving the file. A failed move never leaves a note in a half-updated state. Same-category moves are rejected.

`<file>` follows the same resolution rules as `park show`: a bare basename is searched across every configured category folder (archive included), or a path (absolute, or relative to the working directory) is used as-is. A value containing a path separator is treated as a literal path and is never joined onto a category folder, so passing a path cannot double-join into `category/path/to/file.md`.

## Commands

| command | purpose |
Expand All @@ -129,7 +133,7 @@ The JSON output includes the canonical category enum, field kinds, the date form
| `assist` | open the tabbed TUI browser |
| `show <file> [--plain]` | render a note to the terminal (plain when piped, or with `--plain`) |
| `reclassify <file> -c <cat>` | reclassify a note (alias `recat`) |
| `config` | print the default TOML config |
| `config` | print the loaded TOML config |
| `schema` | print the frontmatter schema contract |
| `schema --json` | print the contract as machine-readable JSON |

Expand Down Expand Up @@ -213,7 +217,7 @@ park new -f ~/Downloads/meeting-notes.md \
-s "Q3 planning recap" -src "Slack export"
```

The file is read, wrapped with frontmatter, and moved into the park. The original is left untouched.
The file is read, wrapped with frontmatter, and moved into the park; the original file is removed.

**From stdin** (pipe):

Expand All @@ -233,8 +237,7 @@ When stdin is not a terminal, `park new` reads the entire input as the note body

## TUI

`park assist` opens a CLI tabbed browser across all configured categories and allows for interactively moving documents
between categories.
`park assist` opens a tabbed TUI browser across all configured categories, with interactive moves between categories.

| key | action |
|-----|--------|
Expand Down Expand Up @@ -277,7 +280,7 @@ park new -f /tmp/claude-output.md \
-s "database schema redesign proposal" -src "Claude"
```

The file is read, frontmatter is injected, and it is moved into the category folder. The original file stays in place.
The file is moved into the category folder and the original is removed, same as any `--from-file` ingest; see [Ingestion](#ingestion).

### Verify setup from an agent

Expand Down Expand Up @@ -308,7 +311,7 @@ park show inbox-note.md | grep -i "deadline"

## Configuration

Config lives at `<park-root>/config` as TOML. Run `park config` to print the default:
Config lives at `<park-root>/config` as TOML. Run `park config` to print the loaded config (the built-in default when no config file exists):

```toml
default_category = "inbox"
Expand Down Expand Up @@ -357,3 +360,9 @@ git clone https://github.com/polymorcodeus/park.git
cd park
make check # fmt, vet, lint, test
```

See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full setup.

## License

[MIT](./LICENSE)
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.5.1
v0.5.2
8 changes: 3 additions & 5 deletions cmd/park/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"context"
"fmt"
"os"
"strings"

"charm.land/lipgloss/v2"
"github.com/polymorcodeus/park/internal/config"
Expand Down Expand Up @@ -180,7 +179,7 @@ func newCommand() *cli.Command {
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
for _, name := range cmd.StringSlice("category") {
if !cfg.HasCategory(name) {
return ctx, styledExit(fmt.Errorf("unknown category %q; valid: %s", name, strings.Join(cfg.CategoryNames(), ", ")), 2)
return ctx, styledExit(cfg.UnknownCategoryError(name), 2)
}
}
return ctx, nil
Expand Down Expand Up @@ -231,8 +230,7 @@ func newCommand() *cli.Command {
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
if newCategory != "" {
if !cfg.HasCategory(newCategory) {
err := fmt.Errorf("--category must be one of %s (got %q)", strings.Join(cfg.CategoryNames(), ", "), newCategory)
return ctx, styledExit(err, 1)
return ctx, styledExit(cfg.UnknownCategoryError(newCategory), 1)
}
}
return ctx, nil
Expand Down Expand Up @@ -263,7 +261,7 @@ func newCommand() *cli.Command {
return ctx, styledExit(fmt.Errorf("usage: park reclassify <file> --category <category>"), 2)
}
if !cfg.HasCategory(reclassifyCategory) {
return ctx, styledExit(fmt.Errorf("unknown category %q; valid: %s", reclassifyCategory, strings.Join(cfg.CategoryNames(), ", ")), 2)
return ctx, styledExit(cfg.UnknownCategoryError(reclassifyCategory), 2)
}
return ctx, nil
},
Expand Down
35 changes: 35 additions & 0 deletions cmd/park/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,41 @@ func TestReclassifySameCategory(t *testing.T) {
}
}

func TestReclassifyAcceptsLiteralPath(t *testing.T) {
root := t.TempDir()
if _, _, err := runPark(t, root, "init"); err != nil {
t.Fatalf("init error = %v", err)
}
notePath := filepath.Join(root, "_inbox", "path-note.md")
writeNote(t, filepath.Join(root, "_inbox"), "path-note.md", "inbox", "a path note")

if _, _, err := runPark(t, root, "reclassify", notePath, "--category", "projects"); err != nil {
t.Fatalf("reclassify by literal path error = %v", err)
}
if _, err := os.Stat(filepath.Join(root, "_projects", "path-note.md")); err != nil {
t.Errorf("file missing in projects: %v", err)
}
if _, err := os.Stat(notePath); !os.IsNotExist(err) {
t.Errorf("file still exists in inbox: %v", err)
}
}

func TestReclassifyAcceptsRelativePath(t *testing.T) {
root := t.TempDir()
if _, _, err := runPark(t, root, "init"); err != nil {
t.Fatalf("init error = %v", err)
}
writeNote(t, filepath.Join(root, "_inbox"), "rel-note.md", "inbox", "a relative note")

t.Chdir(root)
if _, _, err := runPark(t, root, "reclassify", filepath.Join("_inbox", "rel-note.md"), "--category", "areas"); err != nil {
t.Fatalf("reclassify by relative path error = %v", err)
}
if _, err := os.Stat(filepath.Join(root, "_areas", "rel-note.md")); err != nil {
t.Errorf("file missing in areas: %v", err)
}
}

func TestShowMissingArg(t *testing.T) {
_, _, err := runPark(t, t.TempDir(), "show")
if err == nil {
Expand Down
8 changes: 3 additions & 5 deletions cmd/park/park.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,9 @@ func runNoteForm(cfg *config.Config, w io.Writer, seed *note.Draft) error {
// 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 {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(schema.Describe()); err != nil {
return fmt.Errorf("write schema output: %w", err)
}
return nil
Expand Down
Loading
Loading