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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ The JSON output includes the canonical category enum, field kinds, the date form
| `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 |
| `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 |
| `schema` | print the frontmatter schema contract |
Expand Down Expand Up @@ -194,6 +194,14 @@ synopsis: dashboard caching rework

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

### `park show` options

| option | purpose |
|--------|---------|
| `--plain` | force plain text output (no ANSI); auto-selected when stdout is not a terminal |

`park show` renders a note's frontmatter summary and body through glamour. When stdout is not a terminal (piped to a file, a pager, or another process) it automatically emits plain text with no ANSI escape codes, so captured output stays readable; `--plain` forces that mode even on a terminal. `park assist` applies the same rule to the note it opens after the TUI exits.

### Ingestion

`park new` accepts content through two non-interactive paths:
Expand Down Expand Up @@ -290,6 +298,14 @@ Automation does not have to stop at ingestion. A scheduled job or agent can recl
find "$PARK_ROOT/_inbox" -name "*.md" -mtime +30 -exec park reclassify {} -c archive \;
```

### Read a note from an agent

`park show` writes to stdout and drops styling when output is redirected, so captured text is clean:

```bash
park show inbox-note.md | grep -i "deadline"
```

## Configuration

Config lives at `<park-root>/config` as TOML. Run `park config` to print the default:
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.5.0
v0.5.1
11 changes: 10 additions & 1 deletion cmd/park/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func newCommand() *cli.Command {
schemaJSON bool
listJSON bool
listAll bool
showPlain bool
)

defaultRoot := os.Getenv("PARK_ROOT")
Expand Down Expand Up @@ -294,6 +295,13 @@ func newCommand() *cli.Command {
Name: "show",
Usage: "render a note to the terminal",
ArgsUsage: "<file>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "plain",
Destination: &showPlain,
Usage: "force plain text output (default when output is not a terminal)",
},
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
if cmd.NArg() < 1 {
return ctx, styledExit(fmt.Errorf("usage: park show <file>"), 2)
Expand All @@ -305,7 +313,8 @@ func newCommand() *cli.Command {
if err != nil {
return styledExit(err, 1)
}
if err := render.ShowFile(path, cmd.Root().Writer); err != nil {
plain := showPlain || !writerIsTTY(cmd.Root().Writer)
if err := render.ShowFile(path, cmd.Root().Writer, plain); err != nil {
return styledExit(err, 1)
}
return nil
Expand Down
43 changes: 43 additions & 0 deletions cmd/park/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,49 @@ func TestShowMissingFile(t *testing.T) {
}
}

func TestShowPlainFlag(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"), "plain-note.md", "inbox", "a plain note")

out, _, err := runPark(t, root, "show", "plain-note.md", "--plain")
if err != nil {
t.Fatalf("show --plain error = %v", err)
}
assertPlainShow(t, out, "a plain note")
}

// TestShowNonTTYIsPlain verifies plain output is auto-selected when the
// writer is not a terminal; runPark wires a strings.Builder as cmd.Writer,
// which writerIsTTY treats as non-terminal.
func TestShowNonTTYIsPlain(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"), "piped-note.md", "inbox", "a piped note")

out, _, err := runPark(t, root, "show", "piped-note.md")
if err != nil {
t.Fatalf("show error = %v", err)
}
assertPlainShow(t, out, "a piped note")
}

func assertPlainShow(t *testing.T, out, synopsis string) {
t.Helper()
if strings.Contains(out, "\x1b[") {
t.Errorf("plain show output contains ANSI escapes:\n%q", out)
}
for _, want := range []string{synopsis, "body"} {
if !strings.Contains(out, want) {
t.Errorf("show output missing %q:\n%s", want, out)
}
}
}

func TestStyledExit(t *testing.T) {
err := styledExit(errors.New("boom"), 7)
if err == nil {
Expand Down
13 changes: 12 additions & 1 deletion cmd/park/park.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ func stdinIsTTY() bool {
return isTerminal(os.Stdin)
}

// writerIsTTY reports whether w is connected to an interactive terminal. A
// writer that is not an *os.File (a buffer, a pipe) is treated as
// non-terminal so callers fall back to plain output.
func writerIsTTY(w io.Writer) bool {
f, ok := w.(*os.File)
if !ok {
return false
}
return isTerminal(f)
}

// draftFromCmd builds a note.Draft from the CLI flags and positional args.
func draftFromCmd(cmd *cli.Command) note.Draft {
d := note.Draft{
Expand Down Expand Up @@ -181,7 +192,7 @@ func assistPark(cfg *config.Config, w io.Writer) error {
return fmt.Errorf("unexpected model type from assist")
}
if final.ViewFile != "" {
if err := render.ShowFile(final.ViewFile, w); err != nil {
if err := render.ShowFile(final.ViewFile, w, !writerIsTTY(w)); err != nil {
return err
}
}
Expand Down
69 changes: 50 additions & 19 deletions internal/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,45 +11,76 @@ import (
"github.com/polymorcodeus/park/internal/note"
)

const (
richStyle = "dark"
plainStyle = "notty"
wordWrap = 100
)

var (
renderer *glamour.TermRenderer
rendererOnce sync.Once
rendererErr error
renderersMu sync.Mutex
renderers = map[string]*glamour.TermRenderer{}
rendererErr error
)

func termRenderer() (*glamour.TermRenderer, error) {
rendererOnce.Do(func() {
renderer, rendererErr = glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithWordWrap(100),
)
})
func termRenderer(style string) (*glamour.TermRenderer, error) {
renderersMu.Lock()
defer renderersMu.Unlock()

if rendererErr != nil {
return nil, rendererErr
}
return renderer, nil
if r, ok := renderers[style]; ok {
return r, nil
}
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle(style),
glamour.WithWordWrap(wordWrap),
)
if err != nil {
rendererErr = err
return nil, err
}
renderers[style] = r
return r, nil
}

// noteHeader builds the frontmatter summary line rendered above the body.
// plain selects the decoration-free variant used by notty output.
func noteHeader(n note.Note, plain bool) string {
if plain {
return fmt.Sprintf(
"category: %s created: %s source: %s\n\n> %s\n\n---\n\n",
n.Category, n.Created, n.Source, n.Synopsis,
)
}
return fmt.Sprintf(
"**category:** %s &nbsp;&nbsp; **created:** %s &nbsp;&nbsp; **source:** %s\n\n> %s\n\n---\n\n",
n.Category, n.Created, n.Source, n.Synopsis,
)
}

// ShowFile renders a parked note's frontmatter summary + body to w via
// glamour: the "look deeper" step after the synopsis in the list view
// earned a second look.
func ShowFile(path string, w io.Writer) error {
// earned a second look. When plain is true it uses glamour's notty style,
// producing readable text with no ANSI escapes for pipes and redirects.
func ShowFile(path string, w io.Writer, plain bool) error {
n, err := note.Parse(path)
if err != nil {
return fmt.Errorf("show %q: %w", path, err)
}

header := fmt.Sprintf(
"**category:** %s &nbsp;&nbsp; **created:** %s &nbsp;&nbsp; **source:** %s\n\n> %s\n\n---\n\n",
n.Category, n.Created, n.Source, n.Synopsis,
)
style := richStyle
if plain {
style = plainStyle
}

renderer, err := termRenderer()
renderer, err := termRenderer(style)
if err != nil {
return fmt.Errorf("create glamour renderer: %w", err)
}

out, err := renderer.Render(header + n.Body)
out, err := renderer.Render(noteHeader(n, plain) + n.Body)
if err != nil {
return fmt.Errorf("render %q: %w", path, err)
}
Expand Down
44 changes: 42 additions & 2 deletions internal/render/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestShowFileRendersNote(t *testing.T) {
}

var buf bytes.Buffer
if err := ShowFile(path, &buf); err != nil {
if err := ShowFile(path, &buf, false); err != nil {
t.Fatalf("ShowFile() error = %v", err)
}

Expand All @@ -59,8 +59,48 @@ func TestShowFileRendersNote(t *testing.T) {

func TestShowFileMissing(t *testing.T) {
var buf bytes.Buffer
err := ShowFile(filepath.Join(t.TempDir(), "missing.md"), &buf)
err := ShowFile(filepath.Join(t.TempDir(), "missing.md"), &buf, false)
if err == nil {
t.Fatal("expected error for missing file")
}
}

func TestShowFilePlain(t *testing.T) {
tmp := t.TempDir()
cfg := config.DefaultConfig(tmp)
if err := os.MkdirAll(cfg.Categories[0].Path, 0o755); err != nil {
t.Fatalf("create category folder: %v", err)
}

path := filepath.Join(cfg.Categories[0].Path, "plain-note.md")
n := note.Note{
Body: "# Hello\n\nbody content\n",
Metadata: note.Metadata{
Category: "inbox",
Created: "2026-08-09",
Source: "terminal",
Synopsis: "a rendered note",
},
}
if err := note.Write(path, n); err != nil {
t.Fatalf("Write() error = %v", err)
}

var buf bytes.Buffer
if err := ShowFile(path, &buf, true); err != nil {
t.Fatalf("ShowFile() error = %v", err)
}

out := buf.String()
if ansiEscape.MatchString(out) {
t.Errorf("plain output contains ANSI escapes: %q", out)
}
for _, want := range []string{"a rendered note", "body content", "category: inbox"} {
if !strings.Contains(out, want) {
t.Errorf("plain output missing %q; got %q", want, out)
}
}
if strings.Contains(out, "&nbsp;") {
t.Errorf("plain output contains literal &nbsp;: %q", out)
}
}
Loading