Skip to content
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.2.1
v0.3.0
47 changes: 28 additions & 19 deletions cmd/park/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,15 @@ func Main() {

var (
parkRoot, parkConfig string
category string
reclassifyCategory string
newCategory string
)

defaultRoot := os.Getenv("PARK_ROOT")
if defaultRoot == "" {
defaultRoot = config.DefaultRootPath()
}

cmd := &cli.Command{
Name: "park",
Usage: "IPAA: a parking lot for markdown notes (Inbox/Projects/Areas/Archive)",
Expand All @@ -60,14 +65,14 @@ func Main() {
&cli.StringFlag{
Name: "park-root",
Destination: &parkRoot,
Value: config.DefaultRootPath(),
Value: defaultRoot,
Sources: cli.EnvVars("PARK_ROOT"),
Usage: "root directory for parked notes",
},
&cli.StringFlag{
Name: "park-config",
Destination: &parkConfig,
Value: config.DefaultConfigPath(),
Value: config.DefaultConfigPathFor(defaultRoot),
Sources: cli.EnvVars("PARK_CONFIG"),
Usage: "path to the config file",
},
Expand All @@ -84,7 +89,7 @@ func Main() {
Name: "assist",
Usage: "browse parked files and/or edit categories",
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := assistPark(cfg); err != nil {
if err := assistPark(cfg, cmd.Root().Writer); err != nil {
return styledExit(err, 1)
}
return nil
Expand All @@ -94,12 +99,14 @@ func Main() {
Name: "config",
Usage: "print the loaded configuration",
Action: func(ctx context.Context, cmd *cli.Command) error {
out, err := config.DumpConfig(cfg)
out, err := cfg.Dump()
if err != nil {
return styledExit(err, 1)
}
_, err = fmt.Fprint(cmd.Root().Writer, out)
return err
if _, err := fmt.Fprint(cmd.Root().Writer, out); err != nil {
return fmt.Errorf("write config output: %w", err)
}
return nil
},
},
{
Expand All @@ -113,7 +120,7 @@ func Main() {
if len(missing) > 0 {
for _, p := range missing {
if _, err := fmt.Fprintf(cmd.Root().Writer, "missing: %s\n", p); err != nil {
return err
return fmt.Errorf("write check output: %w", err)
}
}
return styledExit(fmt.Errorf("%d category folder(s) missing", len(missing)), 1)
Expand All @@ -129,9 +136,9 @@ func Main() {
if err != nil {
return styledExit(err, 1)
}
msg := formatInitMessage(created, existed)
msg := store.FormatInitResult(created, existed)
if _, err := fmt.Fprintln(cmd.Root().Writer, msg); err != nil {
return err
return fmt.Errorf("write init output: %w", err)
}
return nil
},
Expand Down Expand Up @@ -178,7 +185,7 @@ func Main() {
return ctx, nil
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := addPark(cfg, cmd); err != nil {
if err := addPark(cfg, cmd, cmd.Root().Writer); err != nil {
return styledExit(err, 1)
}
return nil
Expand All @@ -194,21 +201,21 @@ func Main() {
Name: "category",
Aliases: []string{"c"},
Required: true,
Destination: &category,
Destination: &reclassifyCategory,
Usage: "category to move the note into",
},
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
if cmd.NArg() < 1 {
return ctx, styledExit(fmt.Errorf("usage: park reclassify <file> --category <category>"), 2)
}
if !cfg.HasCategory(category) {
return ctx, styledExit(fmt.Errorf("unknown category %qvalid: %s", category, strings.Join(cfg.CategoryNames(), ", ")), 2)
if !cfg.HasCategory(reclassifyCategory) {
return ctx, styledExit(fmt.Errorf("unknown category %q; valid: %s", reclassifyCategory, strings.Join(cfg.CategoryNames(), ", ")), 2)
}
return ctx, nil
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := store.Reclassify(cfg, cmd.Args().First(), category); err != nil {
if err := store.Reclassify(cfg, cmd.Args().First(), reclassifyCategory); err != nil {
return styledExit(err, 1)
}
return nil
Expand All @@ -225,7 +232,11 @@ func Main() {
return ctx, nil
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := render.ShowFile(store.ResolvePath(cfg, cmd.Args().First()), cmd.Root().Writer); err != nil {
path, err := store.ResolvePath(cfg, cmd.Args().First())
if err != nil {
return styledExit(err, 1)
}
if err := render.ShowFile(path, cmd.Root().Writer); err != nil {
return styledExit(err, 1)
}
return nil
Expand All @@ -246,8 +257,6 @@ func styledExit(err error, code int) error {
return cli.Exit(styledError(err), code)
}

const errorBullet string = "󰯷" // "nf-md-alpha_e_box_outline

// StyledError returns a user-facing error string, styled when stdout is a
// terminal.
func styledError(e error) string {
Expand All @@ -263,6 +272,6 @@ func styledError(e error) string {
Render("HEAVENS TO MURGATROYD!")
body := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.CharmRed)).
Render(errorBullet, e.Error())
Render(theme.CurrentGlyphs().ErrorBullet, e.Error())
return header + "\n" + body
}
84 changes: 39 additions & 45 deletions cmd/park/park.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"io"
"os"
"strings"

tea "charm.land/bubbletea/v2"
"github.com/urfave/cli/v3"
Expand All @@ -15,6 +14,9 @@ import (
"github.com/polymorcodeus/park/internal/render"
)

// isTerminal reports whether the given file descriptor is connected to an
// interactive terminal. It is used for both stdin (see stdinIsTTY) and stderr
// (for styled error output).
func isTerminal(f *os.File) bool {
info, err := f.Stat()
if err != nil {
Expand All @@ -28,50 +30,57 @@ func stdinIsTTY() bool {
return isTerminal(os.Stdin)
}

// addPark parks a new note. It reads CLI input and delegates the creation
// decision to internal/note. If the input is incomplete, it opens the
// bubbletea form for the missing metadata.
func addPark(cfg *config.Config, cmd *cli.Command) error {
in := note.NoteInput{
// draftFromCmd builds a note.Draft from the CLI flags and positional args.
func draftFromCmd(cmd *cli.Command) note.Draft {
d := note.Draft{
Filename: cmd.String("filename"),
Synopsis: cmd.String("synopsis"),
Source: cmd.String("source"),
Category: cmd.String("category"),
Metadata: note.Metadata{
Synopsis: cmd.String("synopsis"),
Source: cmd.String("source"),
Category: cmd.String("category"),
},
FromFile: cmd.String("from-file"),
}
if in.Filename == "" {
in.Filename = cmd.Args().First()
if d.Filename == "" {
d.Filename = cmd.Args().First()
}
return d
}

if in.FromFile != "" {
data, err := os.ReadFile(in.FromFile)
if err != nil {
return fmt.Errorf("from-file: %w", err)
}
in.Body = string(data)
} else if !stdinIsTTY() {
// printParked writes the standard "parked: <path>" confirmation.
func printParked(w io.Writer, path string) error {
if _, err := fmt.Fprintln(w, "parked:", path); err != nil {
return fmt.Errorf("write output: %w", err)
}
return nil
}

// addPark parks a new note. It reads CLI input and delegates the creation
// decision to internal/note. If the input is incomplete, it opens the
// bubbletea form for the missing metadata.
func addPark(cfg *config.Config, cmd *cli.Command, w io.Writer) error {
d := draftFromCmd(cmd)

if !stdinIsTTY() {
data, err := io.ReadAll(cmd.Reader)
if err != nil {
return fmt.Errorf("read stdin: %w", err)
}
in.Body = string(data)
d.Body = string(data)
}

outcome, err := note.AddNote(cfg, in)
outcome, err := note.Add(cfg, d)
if err != nil {
return err
}
if outcome.Path != "" {
if _, err := fmt.Fprintln(cmd.Root().Writer, "parked:", outcome.Path); err != nil {
return err
}
return nil
return printParked(w, outcome.Path)
}
return runNoteForm(cfg, cmd, outcome.Form)
return runNoteForm(cfg, w, outcome.Form)
}

func runNoteForm(cfg *config.Config, cmd *cli.Command, form *note.NoteForm) error {
m, err := model.NewNoteFormModel(cfg, form.Filename, form.Synopsis, form.Source, form.Category, form.Body, form.FromFile)
func runNoteForm(cfg *config.Config, w io.Writer, seed *note.Draft) error {
m, err := model.NewNoteFormModel(cfg, *seed)
if err != nil {
return err
}
Expand All @@ -90,27 +99,12 @@ func runNoteForm(cfg *config.Config, cmd *cli.Command, form *note.NoteForm) erro
return res.Err
}
if res.Path != "" {
if _, err := fmt.Fprintln(cmd.Root().Writer, "parked:", res.Path); err != nil {
return err
}
return printParked(w, res.Path)
}
return nil
}

// formatInitMessage formats the result of store.Init for user-facing output.
func formatInitMessage(created, existed []string) string {
if len(created) == 0 {
return "all park folders already exist"
}

msg := fmt.Sprintf("created park folders: %s", strings.Join(created, ", "))
if len(existed) > 0 {
msg += fmt.Sprintf(" (%s already existed)", strings.Join(existed, ", "))
}
return msg
}

func assistPark(cfg *config.Config) error {
func assistPark(cfg *config.Config, w io.Writer) error {
m, err := model.NewAssistModel(cfg)
if err != nil {
return err
Expand All @@ -126,7 +120,7 @@ func assistPark(cfg *config.Config) error {
return fmt.Errorf("unexpected model type from assist")
}
if final.ViewFile != "" {
if err := render.ShowFile(final.ViewFile, os.Stdout); err != nil {
if err := render.ShowFile(final.ViewFile, w); err != nil {
return err
}
}
Expand Down
59 changes: 29 additions & 30 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ func (c *Config) LoadConfig(root, configPath string) error {
return fmt.Errorf("config receiver is nil")
}

configPath = fs.ExpandPath(configPath)
configPath, err := fs.ExpandPath(configPath)
if err != nil {
return fmt.Errorf("expand config path: %w", err)
}
data, err := os.ReadFile(configPath)
if os.IsNotExist(err) {
*c = *DefaultConfig(root)
Expand All @@ -41,7 +44,11 @@ func (c *Config) LoadConfig(root, configPath string) error {

// Expand ~ in paths
for i := range c.Categories {
c.Categories[i].Path = fs.ExpandPath(c.Categories[i].Path)
path, err := fs.ExpandPath(c.Categories[i].Path)
if err != nil {
return fmt.Errorf("expand category path %q: %w", c.Categories[i].Path, err)
}
c.Categories[i].Path = path
}

if err := c.Validate(); err != nil {
Expand Down Expand Up @@ -130,30 +137,18 @@ func (c Config) Dump() (string, error) {
return b.String(), nil
}

// DumpDefault returns the default config as a TOML string.
func DumpDefault(root string) (string, error) {
cfg := DefaultConfig(root)
var b strings.Builder
enc := toml.NewEncoder(&b)
if err := enc.Encode(cfg); err != nil {
return "", fmt.Errorf("encode config: %w", err)
}
return b.String(), nil
// DefaultConfigPath returns the default path to the park configuration file.
func DefaultConfigPath() string {
return DefaultConfigPathFor(DefaultRootPath())
}

// DumpConfig returns the loaded config as a TOML string.
func DumpConfig(cfg *Config) (string, error) {
var b strings.Builder
enc := toml.NewEncoder(&b)
if err := enc.Encode(cfg); err != nil {
return "", fmt.Errorf("encode config: %w", err)
// DefaultConfigPathFor returns the default configuration path under the given
// root. An empty root falls back to DefaultRootPath().
func DefaultConfigPathFor(root string) string {
if root == "" {
root = DefaultRootPath()
}
return b.String(), nil
}

// DefaultConfigPath returns the default path to the park configuration file.
func DefaultConfigPath() string {
return filepath.Join(DefaultRootPath(), "config")
return filepath.Join(root, "config")
}

// DefaultRootPath returns the park root directory.
Expand All @@ -165,15 +160,19 @@ func DefaultConfigPath() string {
// ~/.config/park on Linux and other Unix systems).
// 3. $HOME/.config/park if os.UserConfigDir fails.
func DefaultRootPath() string {
if os.Getenv("XDG_CONFIG_HOME") == "" {
rootDir, err := os.UserConfigDir()
if err != nil {
rootDir = filepath.Join(fs.ExpandPath("~"), ".config")
}
return filepath.Join(rootDir, "park")
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "park")
}

return filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "park")
rootDir, err := os.UserConfigDir()
if err != nil {
home, homeErr := fs.ExpandPath("~")
if homeErr != nil {
return ""
}
rootDir = filepath.Join(home, ".config")
}
return filepath.Join(rootDir, "park")
}

// Category defines a single category (inbox, project, area, archive, or
Expand Down
Loading
Loading