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
64 changes: 60 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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 list
park assist
park reclassify 1767786622-idea.md -c projects
```
Expand Down Expand Up @@ -124,6 +125,7 @@ The JSON output includes the canonical category enum, field kinds, the date form
| `init` | create the category folders (idempotent) |
| `check` | verify category folders exist (useful for automation) |
| `new [title]` | park a new note (alias `add`) |
| `list [--all] [-c <cat>] [--json]` | list parked notes grouped by category (alias `ls`) |
| `assist` | open the tabbed TUI browser |
| `show <file>` | glamour-render a note, no TUI |
| `reclassify <file> -c <cat>` | reclassify a note (alias `recat`) |
Expand All @@ -141,6 +143,57 @@ The JSON output includes the canonical category enum, field kinds, the date form
| `-f, --from-file` | ingest an existing markdown file |
| `--filename` | explicit file slug |

### `park list` options

| option | purpose |
|--------|---------|
| `--all` | include categories excluded by config (for example archive) |
| `-c, --category` | only list these categories (repeatable; overrides exclusion) |
| `--json` | emit a versioned, machine-readable envelope |

`park list` renders one block per category: an upper-cased banner followed by each note's filename and frontmatter fields. Notes within a category are ordered oldest-modified first, and categories marked `excluded` in the config are hidden until `--all` is passed or the category is named explicitly with `-c`.

```
INBOX
-----

filename: ttl-spike.md
category: inbox
created: 2026-07-16
source: terminal
synopsis: current TTL feels wrong, worth a spike

PROJECTS
--------

filename: caching.md
category: projects
created: 2026-07-18
source: my-cli-tool
synopsis: dashboard caching rework
```

`park list --json` flattens every group into a single `items` array, each item carrying its `category`, `path`, and RFC 3339 `modified` timestamp, wrapped in a versioned envelope:

```json
{
"schema_version": 1,
"items": [
{
"filename": "ttl-spike.md",
"path": "/home/you/.config/park/_inbox/ttl-spike.md",
"category": "inbox",
"created": "2026-07-16",
"source": "terminal",
"synopsis": "current TTL feels wrong, worth a spike",
"modified": "2026-07-16T19:03:11Z"
}
]
}
```

`schema_version` tracks the frontmatter contract exposed by `park schema`, so consumers can detect drift.

### Ingestion

`park new` accepts content through two non-interactive paths:
Expand Down Expand Up @@ -244,29 +297,32 @@ Config lives at `<park-root>/config` as TOML. Run `park config` to print the def
```toml
default_category = "inbox"

[[categories]]
[[category]]
name = "inbox"
path = "~/.config/park/_inbox"
key = "i"

[[categories]]
[[category]]
name = "projects"
path = "~/.config/park/_projects"
key = "p"

[[categories]]
[[category]]
name = "areas"
path = "~/.config/park/_areas"
key = "a"

[[categories]]
[[category]]
name = "archive"
path = "~/.config/park/_archive"
key = "x"
excluded = true
```

Categories are fully configurable: name, storage path, and TUI hotkey. Add or remove categories to match your workflow. `default_category` is where `park new` lands notes when `-c` is omitted.

Set `excluded = true` on a category to hide it from `park list` by default; it reappears with `park list --all` or when named explicitly (`park list -c archive`). The built-in config ships with `archive` excluded.

## Tech stack

| component | library |
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.4.1
v0.5.0
42 changes: 42 additions & 0 deletions cmd/park/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ func newCommand() *cli.Command {
reclassifyCategory string
newCategory string
schemaJSON bool
listJSON bool
listAll bool
)

defaultRoot := os.Getenv("PARK_ROOT")
Expand Down Expand Up @@ -153,6 +155,46 @@ func newCommand() *cli.Command {
return nil
},
},
{
Name: "list",
Aliases: []string{"ls"},
Usage: "list parked notes grouped by category",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "json",
Destination: &listJSON,
Usage: "output machine-readable JSON",
},
&cli.BoolFlag{
Name: "all",
Destination: &listAll,
Usage: "include categories excluded by config",
},
&cli.StringSliceFlag{
Name: "category",
Aliases: []string{"c"},
Usage: "only list these categories (repeatable; overrides exclusion)",
},
},
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, nil
},
Action: func(ctx context.Context, cmd *cli.Command) error {
opts := store.ListOptions{
IncludeExcluded: listAll,
Categories: cmd.StringSlice("category"),
}
if err := listPark(cfg, opts, listJSON, cmd.Root().Writer); err != nil {
return styledExit(err, 1)
}
return nil
},
},
{
Name: "new",
Aliases: []string{"add"},
Expand Down
164 changes: 164 additions & 0 deletions cmd/park/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package cmd

import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"

"github.com/polymorcodeus/park/schema"
"github.com/urfave/cli/v3"
)

Expand Down Expand Up @@ -152,3 +154,165 @@ func TestStyledExit(t *testing.T) {
t.Errorf("error = %q, want boom message", err.Error())
}
}

// writeNote writes a minimal valid note into the given category folder.
func writeNote(t *testing.T, dir, name, category, synopsis string) {
t.Helper()
content := "---\ncategory: " + category + "\ncreated: 2026-01-01\nsource: test\nsynopsis: " + synopsis + "\n---\n\nbody\n"
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatalf("write note %q: %v", name, err)
}
}

func TestListCommandDefaultExcludesArchive(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"), "inbox-note.md", "inbox", "an inbox item")
writeNote(t, filepath.Join(root, "_archive"), "archive-note.md", "archive", "an archived item")

out, _, err := runPark(t, root, "list")
if err != nil {
t.Fatalf("list error = %v", err)
}
if !strings.Contains(out, "inbox-note.md") {
t.Errorf("list output missing inbox note:\n%s", out)
}
if strings.Contains(out, "archive-note.md") {
t.Errorf("list output included excluded archive:\n%s", out)
}
}

func TestListAllIncludesArchive(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, "_archive"), "archive-note.md", "archive", "an archived item")

out, _, err := runPark(t, root, "list", "--all")
if err != nil {
t.Fatalf("list --all error = %v", err)
}
if !strings.Contains(out, "archive-note.md") {
t.Errorf("list --all output missing archive note:\n%s", out)
}
}

func TestListCategoryOverridesExclusion(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, "_archive"), "archive-note.md", "archive", "an archived item")

out, _, err := runPark(t, root, "list", "--category", "archive")
if err != nil {
t.Fatalf("list --category error = %v", err)
}
if !strings.Contains(out, "archive-note.md") {
t.Errorf("list --category archive output missing archive note:\n%s", out)
}
}

func TestListCategoryUnknown(t *testing.T) {
_, _, err := runPark(t, t.TempDir(), "list", "--category", "bogus")
if err == nil {
t.Fatal("expected error for unknown category")
}
if got := exitCode(t, err); got != 2 {
t.Errorf("exit code = %d, want 2", got)
}
}

func TestListJSON(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"), "json-note.md", "inbox", "a json item")

out, _, err := runPark(t, root, "list", "--json")
if err != nil {
t.Fatalf("list --json error = %v", err)
}

var env struct {
SchemaVersion int `json:"schema_version"`
Items []struct {
Filename string `json:"filename"`
Category string `json:"category"`
Synopsis string `json:"synopsis"`
Modified string `json:"modified"`
} `json:"items"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("parse list json = %v\n%s", err, out)
}
if env.SchemaVersion != schema.SchemaVersion {
t.Errorf("schema_version = %d, want %d", env.SchemaVersion, schema.SchemaVersion)
}
if len(env.Items) != 1 {
t.Fatalf("items = %d, want 1", len(env.Items))
}
if env.Items[0].Filename != "json-note.md" || env.Items[0].Category != "inbox" {
t.Errorf("item = %+v, want json-note.md in inbox", env.Items[0])
}
if env.Items[0].Synopsis != "a json item" {
t.Errorf("synopsis = %q, want %q", env.Items[0].Synopsis, "a json item")
}
if env.Items[0].Modified == "" {
t.Error("modified is empty, want a timestamp")
}
}

func TestListJSONCategoryFilter(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"), "inbox-note.md", "inbox", "an inbox item")
writeNote(t, filepath.Join(root, "_archive"), "archive-note.md", "archive", "an archived item")

out, _, err := runPark(t, root, "list", "--json", "--category", "archive")
if err != nil {
t.Fatalf("list --json --category error = %v", err)
}

var env struct {
SchemaVersion int `json:"schema_version"`
Items []struct {
Filename string `json:"filename"`
Category string `json:"category"`
} `json:"items"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("parse list json = %v\n%s", err, out)
}
if env.SchemaVersion != schema.SchemaVersion {
t.Errorf("schema_version = %d, want %d", env.SchemaVersion, schema.SchemaVersion)
}
if len(env.Items) != 1 {
t.Fatalf("items = %d, want 1", len(env.Items))
}
if env.Items[0].Filename != "archive-note.md" || env.Items[0].Category != "archive" {
t.Errorf("item = %+v, want archive-note.md in archive", env.Items[0])
}
}

func TestListAlias(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"), "alias-note.md", "inbox", "an item")

out, _, err := runPark(t, root, "ls")
if err != nil {
t.Fatalf("ls error = %v", err)
}
if !strings.Contains(out, "alias-note.md") {
t.Errorf("ls output missing note:\n%s", out)
}
}
14 changes: 14 additions & 0 deletions cmd/park/park.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/polymorcodeus/park/internal/model"
"github.com/polymorcodeus/park/internal/note"
"github.com/polymorcodeus/park/internal/render"
"github.com/polymorcodeus/park/internal/store"
)

// isTerminal reports whether the given file descriptor is connected to an
Expand Down Expand Up @@ -151,6 +152,19 @@ func schemaPark(asJSON bool, w io.Writer) error {
return nil
}

// listPark lists parked notes grouped by category, either as plain text or as
// the versioned JSON envelope.
func listPark(cfg *config.Config, opts store.ListOptions, asJSON bool, w io.Writer) error {
groups, err := store.List(cfg, opts)
if err != nil {
return err
}
if asJSON {
return store.WriteListJSON(w, groups)
}
return store.FormatList(w, groups)
}

func assistPark(cfg *config.Config, w io.Writer) error {
m, err := model.NewAssistModel(cfg)
if err != nil {
Expand Down
Loading
Loading