diff --git a/VERSION b/VERSION index eac0a14..d4dfa56 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.2.1 \ No newline at end of file +v0.3.0 \ No newline at end of file diff --git a/cmd/park/main.go b/cmd/park/main.go index b0fd5b1..e076d0c 100644 --- a/cmd/park/main.go +++ b/cmd/park/main.go @@ -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)", @@ -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", }, @@ -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 @@ -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 }, }, { @@ -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) @@ -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 }, @@ -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 @@ -194,7 +201,7 @@ func Main() { Name: "category", Aliases: []string{"c"}, Required: true, - Destination: &category, + Destination: &reclassifyCategory, Usage: "category to move the note into", }, }, @@ -202,13 +209,13 @@ func Main() { if cmd.NArg() < 1 { return ctx, styledExit(fmt.Errorf("usage: park reclassify --category "), 2) } - if !cfg.HasCategory(category) { - return ctx, styledExit(fmt.Errorf("unknown category %q — valid: %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 @@ -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 @@ -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 { @@ -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 } diff --git a/cmd/park/park.go b/cmd/park/park.go index 7c3179c..edfac08 100644 --- a/cmd/park/park.go +++ b/cmd/park/park.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "os" - "strings" tea "charm.land/bubbletea/v2" "github.com/urfave/cli/v3" @@ -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 { @@ -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: " 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 } @@ -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 @@ -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 } } diff --git a/internal/config/config.go b/internal/config/config.go index f78855f..c2c45e4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) @@ -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 { @@ -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. @@ -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 diff --git a/internal/fs/filesystem.go b/internal/fs/filesystem.go index 0477ab1..c557d7e 100644 --- a/internal/fs/filesystem.go +++ b/internal/fs/filesystem.go @@ -2,29 +2,31 @@ package fs import ( + "fmt" "os" "strings" ) -func homeDir() string { - dir, err := os.UserHomeDir() - if err != nil { - dir = os.Getenv("HOME") +// ExpandPath expands a leading `~` or `$HOME` in path using the user's home +// directory. It returns the path unchanged if no expansion is needed. +func ExpandPath(path string) (string, error) { + if !strings.HasPrefix(path, "~") && !strings.HasPrefix(path, "$HOME") { + return path, nil } - return dir -} -func ExpandPath(path string) string { - home := homeDir() + home, err := os.UserHomeDir() + if err != nil { + home = os.Getenv("HOME") + if home == "" { + return "", fmt.Errorf("cannot expand home in path %q: %w", path, err) + } + } - // Expand ~ if after, ok := strings.CutPrefix(path, "~"); ok { - return home + after + return home + after, nil } - // Expand $HOME if after, ok := strings.CutPrefix(path, "$HOME"); ok { - return home + after + return home + after, nil } - - return path + return path, nil } diff --git a/internal/fs/filesystem_test.go b/internal/fs/filesystem_test.go new file mode 100644 index 0000000..d10180e --- /dev/null +++ b/internal/fs/filesystem_test.go @@ -0,0 +1,81 @@ +package fs + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestExpandPathNoExpansion(t *testing.T) { + got, err := ExpandPath("/absolute/path") + if err != nil { + t.Fatalf("ExpandPath() error = %v", err) + } + if got != "/absolute/path" { + t.Errorf("ExpandPath() = %q, want %q", got, "/absolute/path") + } +} + +func TestExpandPathTilde(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + got, err := ExpandPath("~/notes") + if err != nil { + t.Fatalf("ExpandPath() error = %v", err) + } + want := filepath.Join(home, "notes") + if got != want { + t.Errorf("ExpandPath() = %q, want %q", got, want) + } +} + +func TestExpandPathHomeVar(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + got, err := ExpandPath("$HOME/notes") + if err != nil { + t.Fatalf("ExpandPath() error = %v", err) + } + want := filepath.Join(home, "notes") + if got != want { + t.Errorf("ExpandPath() = %q, want %q", got, want) + } +} + +func TestExpandPathEmpty(t *testing.T) { + got, err := ExpandPath("") + if err != nil { + t.Fatalf("ExpandPath() error = %v", err) + } + if got != "" { + t.Errorf("ExpandPath() = %q, want empty", got) + } +} + +func TestExpandPathOnlyHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + got, err := ExpandPath("~") + if err != nil { + t.Fatalf("ExpandPath() error = %v", err) + } + if got != home { + t.Errorf("ExpandPath() = %q, want %q", got, home) + } +} + +func TestExpandPathPreservesSuffix(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + got, err := ExpandPath("~/a/b/c") + if err != nil { + t.Fatalf("ExpandPath() error = %v", err) + } + if !strings.HasSuffix(got, "/a/b/c") { + t.Errorf("ExpandPath() = %q, want suffix /a/b/c", got) + } +} diff --git a/internal/model/assist.go b/internal/model/assist.go index 55976ff..7cba034 100644 --- a/internal/model/assist.go +++ b/internal/model/assist.go @@ -9,7 +9,6 @@ import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/polymorcodeus/park/internal/config" "github.com/polymorcodeus/park/internal/store" ) @@ -20,15 +19,15 @@ type listItem struct { } func (i listItem) Title() string { - created, _ := time.Parse("2006-01-02", i.item.Frontmatter.Created) + created, _ := time.Parse("2006-01-02", i.item.Created) if created.IsZero() { created = i.item.ModTime } return fmt.Sprintf("%s · %s", i.item.Filename, humanAge(created)) } -func (i listItem) Description() string { return i.item.Frontmatter.Synopsis } +func (i listItem) Description() string { return i.item.Synopsis } func (i listItem) FilterValue() string { - return i.item.Filename + " " + i.item.Frontmatter.Synopsis + return i.item.Filename + " " + i.item.Synopsis } // styledDelegate returns a list.DefaultDelegate with the themed foreground colors. @@ -147,6 +146,9 @@ func NewAssistModel(cfg *config.Config) (AssistModel, error) { break } } + if idx < 0 { + return AssistModel{}, fmt.Errorf("default category %q not found", cfg.DefaultCategory) + } s := newStyles() delegate := s.styledDelegate() @@ -164,79 +166,59 @@ func NewAssistModel(cfg *config.Config) (AssistModel, error) { m.list.SetShowHelp(false) m.list.SetShowTitle(false) m.list.SetShowStatusBar(false) - categoryName := cfg.Categories[idx].Name - items, err := store.Scan(cfg, categoryName) - if err != nil { - return AssistModel{}, err - } - listItems := make([]list.Item, len(items)) - for i, it := range items { - listItems[i] = listItem{item: it} - } - m.list.SetItems(listItems) - // m.list.Title = fmt.Sprintf("%s (%d)", categoryName, len(listItems)) - return m, nil } func (m AssistModel) switchCategory(delta int) AssistModel { - m.categoryIdx += delta - if m.categoryIdx < 0 { - m.categoryIdx = len(m.cfg.Categories) - 1 - } else if m.categoryIdx >= len(m.cfg.Categories) { - m.categoryIdx = 0 - } + m.categoryIdx = cycleIndex(m.categoryIdx, delta, len(m.cfg.Categories)) return m } -// itemsLoadedMsg carries the items for a category after an async load. -type itemsLoadedMsg struct { +// loadResult carries the outcome of an async category load back to Update: +// either the loaded items (err == nil) or a load error. +type loadResult struct { categoryName string items []list.Item -} - -// errMsg carries a load error back to Update. -type errMsg struct { - categoryName string err error } -// reclassifiedMsg signals that the selected item was moved to a new category. -type reclassifiedMsg struct{ item listItem } - -// reclassifyErrMsg carries an error from an async reclassify command. -type reclassifyErrMsg struct{ err error } +// reclassifyResult carries the outcome of an async reclassify back to +// Update: either the moved item (err == nil) or a reclassify error. +type reclassifyResult struct { + item listItem + err error +} -func (m *AssistModel) loadItemsCmd() tea.Cmd { +func (m AssistModel) loadItemsCmd() tea.Cmd { categoryName := m.cfg.Categories[m.categoryIdx].Name cfg := m.cfg return func() tea.Msg { items, err := store.Scan(cfg, categoryName) if err != nil { - return errMsg{categoryName: categoryName, err: err} + return loadResult{categoryName: categoryName, err: err} } listItems := make([]list.Item, len(items)) for i, it := range items { listItems[i] = listItem{item: it} } - return itemsLoadedMsg{categoryName: categoryName, items: listItems} + return loadResult{categoryName: categoryName, items: listItems} } } -func (m *AssistModel) reclassifyCmd(targetCategory string) tea.Cmd { +func (m AssistModel) reclassifyCmd(targetCategory string) tea.Cmd { it, ok := m.list.SelectedItem().(listItem) if !ok { return func() tea.Msg { - return reclassifyErrMsg{err: fmt.Errorf("no item selected")} + return reclassifyResult{err: fmt.Errorf("no item selected")} } } filename := it.item.Filename cfg := m.cfg return func() tea.Msg { if err := store.Reclassify(cfg, filename, targetCategory); err != nil { - return reclassifyErrMsg{err: err} + return reclassifyResult{err: err} } - return reclassifiedMsg{item: it} + return reclassifyResult{item: it} } } @@ -250,7 +232,7 @@ func (m AssistModel) removeItem(target listItem) AssistModel { return m } -func (m AssistModel) Init() tea.Cmd { return nil } +func (m AssistModel) Init() tea.Cmd { return m.loadItemsCmd() } // keeps assist and new TUI screens approx same size const maxListHeight = 28 @@ -259,37 +241,40 @@ func (m AssistModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = min(minWidth, msg.Width) - m.help.SetWidth(msg.Width) - m.list.SetSize(msg.Width, maxListHeight) + m.help.SetWidth(m.width) + m.list.SetSize(m.width, min(max(5, msg.Height-6), maxListHeight)) - case itemsLoadedMsg: + case loadResult: currentCategory := m.cfg.Categories[m.categoryIdx].Name if msg.categoryName != currentCategory { return m, nil } + if msg.err != nil { + m.err = msg.err + return m, nil + } m.list.SetItems(msg.items) m.list.Title = fmt.Sprintf("%s (%d)", currentCategory, len(msg.items)) m.err = nil return m, nil - case errMsg: - if msg.categoryName != m.cfg.Categories[m.categoryIdx].Name { + case reclassifyResult: + if msg.err != nil { + m.err = msg.err return m, nil } - m.err = msg.err - return m, nil - - case reclassifiedMsg: m = m.removeItem(msg.item) m.err = nil return m, nil - case reclassifyErrMsg: - m.err = msg.err - return m, nil - case tea.KeyPressMsg: m.err = nil + if m.list.SettingFilter() { + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + break + } switch { case key.Matches(msg, m.keys.Quit): return m, tea.Quit @@ -308,9 +293,6 @@ func (m AssistModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.help.ShowAll = !m.help.ShowAll return m, nil default: - if m.list.SettingFilter() { - break - } if cl, ok := m.cfg.CategoryByKey(msg.String()); ok { currentCategory := m.cfg.Categories[m.categoryIdx].Name if cl.Name == currentCategory { @@ -335,24 +317,7 @@ func (m AssistModel) View() tea.View { doc := strings.Builder{} s := m.styles - var renderedTabs []string - for i, cl := range m.cfg.Categories { - var style lipgloss.Style - isActive := i == m.categoryIdx - if isActive { - style = s.activeTab - } else { - style = s.inactiveTab - } - - renderedTabs = append(renderedTabs, style.Render(cl.Name)) - } - - row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) - - gapWidth := max(0, m.width-lipgloss.Width(row)) - gap := s.topBorder.Render(strings.Repeat(" ", gapWidth)) - header := lipgloss.JoinHorizontal(lipgloss.Bottom, row, gap) + header := s.renderTabs(m.cfg.Categories, m.categoryIdx, m.width, false) doc.WriteString(header) doc.WriteString("\n") diff --git a/internal/model/assist_test.go b/internal/model/assist_test.go new file mode 100644 index 0000000..bd5de1a --- /dev/null +++ b/internal/model/assist_test.go @@ -0,0 +1,215 @@ +package model + +import ( + "os" + "path/filepath" + "testing" + "time" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + + "github.com/polymorcodeus/park/internal/config" + "github.com/polymorcodeus/park/internal/note" + "github.com/polymorcodeus/park/internal/store" +) + +func newTestAssistModel(t *testing.T, items ...store.Item) AssistModel { + t.Helper() + cfg := config.DefaultConfig(t.TempDir()) + if _, _, err := store.Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + + m, err := NewAssistModel(cfg) + if err != nil { + t.Fatalf("NewAssistModel() error = %v", err) + } + + if len(items) > 0 { + updated, _ := m.Update(makeLoadResult(cfg.DefaultCategory, items...)) + m = updated.(AssistModel) + } + return m +} + +func keyPress(r rune) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: r, Text: string(r)} +} + +func makeLoadResult(category string, items ...store.Item) loadResult { + listItems := make([]list.Item, len(items)) + for i, it := range items { + listItems[i] = listItem{item: it} + } + return loadResult{categoryName: category, items: listItems} +} + +func TestNewAssistModelDefaultCategory(t *testing.T) { + cfg := config.DefaultConfig(t.TempDir()) + m, err := NewAssistModel(cfg) + if err != nil { + t.Fatalf("NewAssistModel() error = %v", err) + } + if m.cfg.Categories[m.categoryIdx].Name != cfg.DefaultCategory { + t.Errorf("default category = %q, want %q", m.cfg.Categories[m.categoryIdx].Name, cfg.DefaultCategory) + } +} + +func TestAssistModelLoadResult(t *testing.T) { + items := []store.Item{ + { + Metadata: note.Metadata{Category: "inbox", Created: "2026-08-09", Source: "test", Synopsis: "first"}, + Path: "/tmp/inbox/first.md", + Filename: "first.md", + ModTime: time.Now(), + }, + { + Metadata: note.Metadata{Category: "inbox", Created: "2026-08-09", Source: "test", Synopsis: "second"}, + Path: "/tmp/inbox/second.md", + Filename: "second.md", + ModTime: time.Now(), + }, + } + m := newTestAssistModel(t, items...) + + if len(m.list.Items()) != 2 { + t.Errorf("list items = %d, want 2", len(m.list.Items())) + } +} + +func TestAssistModelStaleLoadResultIgnored(t *testing.T) { + cfg := config.DefaultConfig(t.TempDir()) + if _, _, err := store.Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + m, err := NewAssistModel(cfg) + if err != nil { + t.Fatalf("NewAssistModel() error = %v", err) + } + + // Switch to projects, then deliver an inbox load result. + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + m = updated.(AssistModel) + + updated, _ = m.Update(makeLoadResult("inbox", store.Item{Path: "/tmp/inbox/old.md", Filename: "old.md"})) + m = updated.(AssistModel) + + if len(m.list.Items()) != 0 { + t.Errorf("stale result items = %d, want 0", len(m.list.Items())) + } +} + +func TestAssistModelCycleCategory(t *testing.T) { + m := newTestAssistModel(t) + startIdx := m.categoryIdx + + updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + final := updated.(AssistModel) + if final.categoryIdx == startIdx { + t.Error("category index did not advance") + } + if cmd == nil { + t.Fatal("expected load command after category change") + } +} + +func TestAssistModelQuit(t *testing.T) { + m := newTestAssistModel(t) + updated, cmd := m.Update(keyPress('q')) + _ = updated.(AssistModel) + if cmd == nil { + t.Fatal("expected quit command") + } +} + +func TestAssistModelToggleHelp(t *testing.T) { + m := newTestAssistModel(t) + updated, _ := m.Update(keyPress('?')) + final := updated.(AssistModel) + if !final.help.ShowAll { + t.Error("help.ShowAll = false after toggling help") + } +} + +func TestAssistModelReclassifySameCategory(t *testing.T) { + items := []store.Item{ + { + Metadata: note.Metadata{Category: "inbox", Created: "2026-08-09", Source: "test", Synopsis: "stay"}, + Path: "/tmp/inbox/stay.md", + Filename: "stay.md", + ModTime: time.Now(), + }, + } + m := newTestAssistModel(t, items...) + + updated, cmd := m.Update(keyPress('i')) + final := updated.(AssistModel) + if cmd != nil { + t.Fatal("expected no command for same-category reclassify") + } + if final.err == nil { + t.Fatal("expected error for same-category reclassify") + } +} + +func TestAssistModelReclassifySuccess(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if _, _, err := store.Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + + path, err := note.Create(cfg, note.Draft{ + Filename: "Move Me", + Metadata: note.Metadata{Synopsis: "move", Source: "test", Category: "inbox"}, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + m, err := NewAssistModel(cfg) + if err != nil { + t.Fatalf("NewAssistModel() error = %v", err) + } + + items, err := store.Scan(cfg, "inbox") + if err != nil { + t.Fatalf("Scan() error = %v", err) + } + updated, _ := m.Update(makeLoadResult("inbox", items...)) + m = updated.(AssistModel) + + if len(m.list.Items()) != 1 { + t.Fatalf("list items = %d, want 1", len(m.list.Items())) + } + + updated, cmd := m.Update(keyPress('p')) + m = updated.(AssistModel) + if cmd == nil { + t.Fatal("expected reclassify command") + } + + msg := cmd() + res, ok := msg.(reclassifyResult) + if !ok { + t.Fatalf("expected reclassifyResult, got %T", msg) + } + if res.err != nil { + t.Fatalf("reclassify command error = %v", res.err) + } + + updated, _ = m.Update(res) + final := updated.(AssistModel) + if final.err != nil { + t.Errorf("model err = %v", final.err) + } + if len(final.list.Items()) != 0 { + t.Errorf("list items after reclassify = %d, want 0", len(final.list.Items())) + } + + projectsPath := filepath.Join(tmp, "_projects", filepath.Base(path)) + if _, err := os.Stat(projectsPath); err != nil { + t.Errorf("file not in projects: %v", err) + } +} diff --git a/internal/model/form.go b/internal/model/form.go index e6c8755..3f0fd56 100644 --- a/internal/model/form.go +++ b/internal/model/form.go @@ -3,17 +3,16 @@ package model import ( "fmt" - "os" "strings" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/polymorcodeus/park/internal/config" "github.com/polymorcodeus/park/internal/fs" "github.com/polymorcodeus/park/internal/note" + "github.com/polymorcodeus/park/internal/theme" ) const maxFilePreviewLines = 6 @@ -60,6 +59,20 @@ var noteKeys = noteKeyMap{ ), } +// formField identifies a focusable field in the note form. Its ordering +// mirrors the visual layout (filename → synopsis → source → body → +// category → submit); fieldBody is skipped when the form has no body field. +type formField int + +const ( + fieldFilename formField = iota + fieldSynopsis + fieldSource + fieldBody + fieldCategory + fieldSubmit +) + // NoteFormModel is a bubbletea form for interactively creating or ingesting a // parked note. Pre-filled values come from CLI flags; missing fields are // collected here. @@ -68,7 +81,8 @@ type NoteFormModel struct { inputs []textinput.Model bodyInput textarea.Model categoryIdx int - focusIndex int + fields []formField // focusable fields in tab order for this form + focusIndex int // index into fields, not a formField itself fromFile string filePreview string bodyCursorReset bool @@ -95,17 +109,17 @@ func (s *styles) textInputStyles() textinput.Styles { return st } -// NewNoteFormModel builds a form model with the supplied initial values. -func NewNoteFormModel(cfg *config.Config, filename, synopsis, source, category, body, fromFile string) (NoteFormModel, error) { +// NewNoteFormModel builds a form model from a draft seed. +func NewNoteFormModel(cfg *config.Config, seed note.Draft) (NoteFormModel, error) { idx := -1 for i, cl := range cfg.Categories { - if cl.Name == category { + if cl.Name == seed.Category { idx = i break } } if idx < 0 { - return NoteFormModel{}, fmt.Errorf("unknown category %q", category) + return NoteFormModel{}, fmt.Errorf("unknown category %q", seed.Category) } s := newStyles() @@ -121,39 +135,46 @@ func NewNoteFormModel(cfg *config.Config, filename, synopsis, source, category, case 0: ti.Prompt = "filename: " ti.Placeholder = "note filename" - ti.SetValue(filename) + ti.SetValue(seed.Filename) case 1: ti.Prompt = "synopsis: " ti.Placeholder = "one-line description" - ti.SetValue(synopsis) + ti.SetValue(seed.Synopsis) case 2: ti.Prompt = "source: " ti.Placeholder = "where this came from" - ti.SetValue(source) + ti.SetValue(seed.Source) } inputs[i] = ti } ta := textarea.New() - ta.Placeholder = "note body (optional)" - ta.SetValue(body) + ta.Placeholder = "note body" + ta.SetValue(seed.Body) ta.SetStyles(textarea.DefaultStyles(true)) ta.MaxHeight = 10 - var preview string - if fromFile != "" { - preview = previewFile(fromFile) + preview := "" + if seed.FromFile != "" { + preview = "(loading preview...)" + } + + fields := []formField{fieldFilename, fieldSynopsis, fieldSource} + if seed.FromFile == "" { + fields = append(fields, fieldBody) } + fields = append(fields, fieldCategory, fieldSubmit) m := NoteFormModel{ cfg: cfg, inputs: inputs, bodyInput: ta, categoryIdx: idx, + fields: fields, focusIndex: 0, - fromFile: fromFile, + fromFile: seed.FromFile, filePreview: preview, - bodyCursorReset: body != "", + bodyCursorReset: seed.Body != "", styles: s, keys: noteKeys, width: minWidth, @@ -167,43 +188,35 @@ func (m NoteFormModel) hasBodyField() bool { return m.fromFile == "" } -func (m NoteFormModel) bodyIndex() int { - if !m.hasBodyField() { - return -1 - } - return 3 -} - -func (m NoteFormModel) categoryIndex() int { - if !m.hasBodyField() { - return 3 - } - return 4 -} - -func (m NoteFormModel) submitIndex() int { - if !m.hasBodyField() { - return 4 - } - return 5 +// currentField returns the formField the cursor is currently on. +func (m NoteFormModel) currentField() formField { + return m.fields[m.focusIndex] } -func (m NoteFormModel) lastIndex() int { - return m.submitIndex() +// advanceFocus moves focusIndex forward or backward by delta, wrapping +// around the ends of m.fields. There is no longer a need to special-case +// the body field: it's simply absent from m.fields when there's no body. +func (m NoteFormModel) advanceFocus(delta int) NoteFormModel { + m.focusIndex = cycleIndex(m.focusIndex, delta, len(m.fields)) + return m } // updateFocus applies focus/blur to every field based on the current focus // index and returns the collected commands. func (m NoteFormModel) updateFocus() (NoteFormModel, tea.Cmd) { cmds := make([]tea.Cmd, 0, 6) + cur := m.currentField() + + // inputs[0..2] correspond 1:1 to fieldFilename..fieldSource. for i := range m.inputs { - if i == m.focusIndex { + if formField(i) == cur { cmds = append(cmds, m.inputs[i].Focus()) continue } m.inputs[i].Blur() } - if m.hasBodyField() && m.focusIndex == m.bodyIndex() { + + if cur == fieldBody { cmds = append(cmds, m.bodyInput.Focus()) if m.bodyCursorReset { m.bodyInput.CursorStart() @@ -237,10 +250,6 @@ func (m NoteFormModel) categoryName() string { return m.cfg.Categories[m.categoryIdx].Name } -func (m NoteFormModel) canSubmit() bool { - return m.filename() != "" && m.synopsis() != "" && m.source() != "" -} - func (m NoteFormModel) body() string { if m.fromFile != "" { return "" @@ -248,14 +257,18 @@ func (m NoteFormModel) body() string { return strings.TrimRight(m.bodyInput.Value(), "\n") } -// previewFile returns the first few lines of a file for display in the form. +// previewFile returns the first few lines of a file's body for display in the +// form. It uses note.Parse so the preview mirrors how the note will be stored. func previewFile(path string) string { - path = fs.ExpandPath(path) - data, err := os.ReadFile(path) + path, err := fs.ExpandPath(path) + if err != nil { + return fmt.Sprintf("(unable to expand %s: %v)", path, err) + } + n, err := note.Parse(path) if err != nil { return fmt.Sprintf("(unable to read %s: %v)", path, err) } - lines := strings.Split(string(data), "\n") + lines := strings.Split(n.Body, "\n") if len(lines) > maxFilePreviewLines { lines = lines[:maxFilePreviewLines] lines = append(lines, "...") @@ -263,6 +276,18 @@ func previewFile(path string) string { return strings.Join(lines, "\n") } +// filePreviewLoadedMsg carries the async-loaded file preview body. +type filePreviewLoadedMsg struct { + preview string +} + +// loadPreviewCmd reads the file preview off the UI thread. +func loadPreviewCmd(path string) tea.Cmd { + return func() tea.Msg { + return filePreviewLoadedMsg{preview: previewFile(path)} + } +} + // submitMsg signals that the form should be submitted. type submitMsg struct{} @@ -273,35 +298,48 @@ func (m NoteFormModel) submitCmd() tea.Cmd { } func (m NoteFormModel) createCmd() tea.Cmd { - filename := m.filename() - synopsis := m.synopsis() - source := m.source() - category := m.categoryName() - body := m.body() - fromFile := m.fromFile + d := note.Draft{ + Filename: m.filename(), + Body: m.body(), + FromFile: m.fromFile, + Metadata: note.Metadata{ + Synopsis: m.synopsis(), + Source: m.source(), + Category: m.categoryName(), + }, + } cfg := m.cfg return func() tea.Msg { - var path string - var err error - if fromFile != "" { - path, err = note.IngestFile(cfg, fromFile, filename, synopsis, source, category, body) - } else { - path, err = note.NewWithBody(cfg, filename, synopsis, source, category, body) - } + outcome, err := note.Add(cfg, d) if err != nil { - return formErrorMsg{err: err} + return createResult{err: err} + } + if outcome.Form != nil { + missing := outcome.Form.MissingFields() + if len(missing) > 0 { + return createResult{err: fmt.Errorf("missing required fields: %s", strings.Join(missing, ", "))} + } + return createResult{err: fmt.Errorf("form submission incomplete")} } - return formCreatedMsg{path: path} + return createResult{path: outcome.Path} } } -type formCreatedMsg struct{ path string } -type formErrorMsg struct{ err error } +// createResult carries the outcome of an async note creation back to +// Update: either the created note's path (err == nil) or a creation error. +type createResult struct { + path string + err error +} // Init implements tea.Model. func (m NoteFormModel) Init() tea.Cmd { - return tea.Batch(textinput.Blink, textarea.Blink, tea.RequestBackgroundColor) + cmds := []tea.Cmd{textinput.Blink, textarea.Blink, tea.RequestBackgroundColor} + if m.fromFile != "" { + cmds = append(cmds, loadPreviewCmd(m.fromFile)) + } + return tea.Batch(cmds...) } // Update implements tea.Model. @@ -309,28 +347,29 @@ func (m NoteFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = min(minWidth, msg.Width) + inputWidth := max(20, m.width-20) for i := range m.inputs { - m.inputs[i].SetWidth(msg.Width - 20) + m.inputs[i].SetWidth(inputWidth) } - m.bodyInput.SetWidth(msg.Width - 20) + m.bodyInput.SetWidth(inputWidth) m.bodyInput.SetHeight(minHeight) case tea.BackgroundColorMsg: m.bodyInput.SetStyles(textarea.DefaultStyles(msg.IsDark())) - case formCreatedMsg: + case createResult: + if msg.err != nil { + m.err = msg.err + return m, nil + } m.createdPath = msg.path return m, tea.Quit - case formErrorMsg: - m.err = msg.err + case filePreviewLoadedMsg: + m.filePreview = msg.preview return m, nil case submitMsg: - if !m.canSubmit() { - m.err = fmt.Errorf("filename, synopsis, and source are required") - return m, nil - } return m, m.createCmd() case tea.KeyPressMsg: @@ -340,24 +379,12 @@ func (m NoteFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case key.Matches(msg, m.keys.Tab): - m.focusIndex++ - if m.focusIndex > m.lastIndex() { - m.focusIndex = 0 - } - if !m.hasBodyField() && m.focusIndex == m.bodyIndex() { - m.focusIndex++ - } + m = m.advanceFocus(+1) m, cmd := m.updateFocus() return m, cmd case key.Matches(msg, m.keys.ShiftTab): - m.focusIndex-- - if m.focusIndex < 0 { - m.focusIndex = m.lastIndex() - } - if !m.hasBodyField() && m.focusIndex == m.bodyIndex() { - m.focusIndex-- - } + m = m.advanceFocus(-1) m, cmd := m.updateFocus() return m, cmd @@ -365,36 +392,24 @@ func (m NoteFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.submitCmd() case key.Matches(msg, m.keys.Enter): - if !m.hasBodyField() || m.focusIndex != m.bodyIndex() { - if m.focusIndex == m.submitIndex() || m.focusIndex == m.categoryIndex() { + if m.currentField() != fieldBody { + if m.currentField() == fieldSubmit || m.currentField() == fieldCategory { return m, m.submitCmd() } - m.focusIndex++ - if m.focusIndex > m.lastIndex() { - m.focusIndex = 0 - } - if !m.hasBodyField() && m.focusIndex == m.bodyIndex() { - m.focusIndex++ - } + m = m.advanceFocus(+1) m, cmd := m.updateFocus() return m, cmd } case key.Matches(msg, m.keys.CatLeft): - if m.focusIndex == m.categoryIndex() { - m.categoryIdx-- - if m.categoryIdx < 0 { - m.categoryIdx = len(m.cfg.Categories) - 1 - } + if m.currentField() == fieldCategory { + m.categoryIdx = cycleIndex(m.categoryIdx, -1, len(m.cfg.Categories)) return m, nil } case key.Matches(msg, m.keys.CatRight): - if m.focusIndex == m.categoryIndex() { - m.categoryIdx++ - if m.categoryIdx >= len(m.cfg.Categories) { - m.categoryIdx = 0 - } + if m.currentField() == fieldCategory { + m.categoryIdx = cycleIndex(m.categoryIdx, +1, len(m.cfg.Categories)) return m, nil } } @@ -416,22 +431,7 @@ func (m NoteFormModel) View() tea.View { // Category tabs. The selected category is always highlighted so users // know which category the note will be parked in. When the category area // itself is focused, the selected tab is rendered with a visible indicator. - var renderedTabs []string - for i, cl := range m.cfg.Categories { - style := s.inactiveTab - label := cl.Name - if i == m.categoryIdx { - style = s.activeTab - if m.focusIndex == m.categoryIndex() { - label = "❯ " + cl.Name + " ❮" - } - } - renderedTabs = append(renderedTabs, style.Render(label)) - } - row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) - gapWidth := max(0, m.width-lipgloss.Width(row)) - gap := s.topBorder.Render(strings.Repeat(" ", gapWidth)) - header := lipgloss.JoinHorizontal(lipgloss.Bottom, row, gap) + header := s.renderTabs(m.cfg.Categories, m.categoryIdx, m.width, m.currentField() == fieldCategory) b.WriteString(header) b.WriteString("\n\n") @@ -463,8 +463,6 @@ func (m NoteFormModel) View() tea.View { // Help / error. help := "tab/shift+tab: move · ←/→: change category · enter: next/submit · esc: cancel" - // b.WriteString(s.helpText.Render(help)) - b.WriteString(s.window.Width(m.width).Render(s.helpText.Render(help))) if m.err != nil { @@ -478,8 +476,9 @@ func (m NoteFormModel) View() tea.View { } func (m NoteFormModel) renderSubmitButton() string { - label := "󰄽 Submit 󰄾" - if m.focusIndex == m.submitIndex() { + glyphs := theme.CurrentGlyphs() + label := fmt.Sprintf("%sSubmit%s", glyphs.SubmitLeft, glyphs.SubmitRight) + if m.currentField() == fieldSubmit { return m.styles.submitButtonFocus.Render(label) } return m.styles.submitButton.Render(label) diff --git a/internal/model/form_test.go b/internal/model/form_test.go index 58b6225..8db75fa 100644 --- a/internal/model/form_test.go +++ b/internal/model/form_test.go @@ -10,9 +10,23 @@ import ( "github.com/polymorcodeus/park/internal/store" ) +func fieldIndex(m NoteFormModel, f formField) int { + for i, ff := range m.fields { + if ff == f { + return i + } + } + return -1 +} + func TestNewNoteFormModel(t *testing.T) { cfg := config.DefaultConfig(t.TempDir()) - m, err := NewNoteFormModel(cfg, "filename", "synopsis", "source", "inbox", "body", "") + seed := note.Draft{ + Filename: "filename", + Body: "body", + Metadata: note.Metadata{Synopsis: "synopsis", Source: "source", Category: "inbox"}, + } + m, err := NewNoteFormModel(cfg, seed) if err != nil { t.Fatalf("NewNoteFormModel() error = %v", err) } @@ -36,7 +50,7 @@ func TestNewNoteFormModel(t *testing.T) { func TestNewNoteFormModelUnknownCategory(t *testing.T) { cfg := config.DefaultConfig(t.TempDir()) - _, err := NewNoteFormModel(cfg, "", "", "", "nope", "", "") + _, err := NewNoteFormModel(cfg, note.Draft{Metadata: note.Metadata{Category: "nope"}}) if err == nil { t.Fatal("expected error for unknown category") } @@ -49,7 +63,12 @@ func TestNoteFormModelSubmission(t *testing.T) { t.Fatalf("Init() error = %v", err) } - m, err := NewNoteFormModel(cfg, "Form Note", "form synopsis", "test", "inbox", "# Form Note\n", "") + seed := note.Draft{ + Filename: "Form Note", + Body: "# Form Note\n", + Metadata: note.Metadata{Synopsis: "form synopsis", Source: "test", Category: "inbox"}, + } + m, err := NewNoteFormModel(cfg, seed) if err != nil { t.Fatalf("NewNoteFormModel() error = %v", err) } @@ -67,9 +86,12 @@ func TestNoteFormModelSubmission(t *testing.T) { } msg := cmd() - created, ok := msg.(formCreatedMsg) + created, ok := msg.(createResult) if !ok { - t.Fatalf("expected formCreatedMsg, got %T", msg) + t.Fatalf("expected createResult, got %T", msg) + } + if created.err != nil { + t.Fatalf("unexpected create error: %v", created.err) } if created.path == "" { t.Fatal("expected non-empty path") @@ -83,7 +105,11 @@ func TestNoteFormModelRequiresSource(t *testing.T) { t.Fatalf("Init() error = %v", err) } - m, err := NewNoteFormModel(cfg, "Filename", "Synopsis", "", "inbox", "", "") + seed := note.Draft{ + Filename: "Filename", + Metadata: note.Metadata{Synopsis: "Synopsis", Category: "inbox"}, + } + m, err := NewNoteFormModel(cfg, seed) if err != nil { t.Fatalf("NewNoteFormModel() error = %v", err) } @@ -93,17 +119,27 @@ func TestNoteFormModelRequiresSource(t *testing.T) { if !ok { t.Fatalf("unexpected model type") } - if final.err == nil { + if cmd == nil { + t.Fatal("expected validation command") + } + + msg := cmd() + res, ok := msg.(createResult) + if !ok { + t.Fatalf("expected createResult, got %T", msg) + } + if res.err == nil { t.Fatal("expected error for missing source") } - if cmd != nil { - t.Fatal("expected no command when validation fails") + final.err = res.err + if final.err == nil { + t.Fatal("expected error for missing source") } } func TestNoteFormModelView(t *testing.T) { cfg := config.DefaultConfig(t.TempDir()) - m, err := NewNoteFormModel(cfg, "", "", "", "inbox", "", "") + m, err := NewNoteFormModel(cfg, note.Draft{Metadata: note.Metadata{Category: "inbox"}}) if err != nil { t.Fatalf("NewNoteFormModel() error = %v", err) } @@ -113,12 +149,12 @@ func TestNoteFormModelView(t *testing.T) { func TestNoteFormModelCategoryNavigation(t *testing.T) { cfg := config.DefaultConfig(t.TempDir()) - m, err := NewNoteFormModel(cfg, "", "", "", "inbox", "", "") + m, err := NewNoteFormModel(cfg, note.Draft{Metadata: note.Metadata{Category: "inbox"}}) if err != nil { t.Fatalf("NewNoteFormModel() error = %v", err) } - m.focusIndex = m.categoryIndex() + m.focusIndex = fieldIndex(m, fieldCategory) updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) final, ok := updated.(NoteFormModel) if !ok { @@ -132,12 +168,12 @@ func TestNoteFormModelCategoryNavigation(t *testing.T) { func TestNoteFormModelBodyCursorStartsAtTop(t *testing.T) { cfg := config.DefaultConfig(t.TempDir()) body := strings.Repeat("line\n", 20) - m, err := NewNoteFormModel(cfg, "", "", "", "inbox", body, "") + m, err := NewNoteFormModel(cfg, note.Draft{Body: body, Metadata: note.Metadata{Category: "inbox"}}) if err != nil { t.Fatalf("NewNoteFormModel() error = %v", err) } - m.focusIndex = m.bodyIndex() + m.focusIndex = fieldIndex(m, fieldBody) m, _ = m.updateFocus() if m.bodyInput.ScrollYOffset() != 0 { @@ -149,17 +185,25 @@ func TestNoteFormModelFilePreview(t *testing.T) { tmp := t.TempDir() cfg := config.DefaultConfig(tmp) path := tmp + "/draft.md" - if err := note.WriteFrontmatter(path, note.Frontmatter{}, "# Draft\n\ncontent\n"); err != nil { - t.Fatalf("WriteFrontmatter() error = %v", err) + if err := note.Write(path, note.Note{Body: "# Draft\n\ncontent\n", Metadata: note.Metadata{Category: "inbox"}}); err != nil { + t.Fatalf("Write() error = %v", err) } - m, err := NewNoteFormModel(cfg, "", "", "", "inbox", "", path) + m, err := NewNoteFormModel(cfg, note.Draft{FromFile: path, Metadata: note.Metadata{Category: "inbox"}}) if err != nil { t.Fatalf("NewNoteFormModel() error = %v", err) } if m.hasBodyField() { t.Fatal("expected body field to be hidden when from-file is set") } + + cmd := loadPreviewCmd(path) + msg := cmd() + previewMsg, ok := msg.(filePreviewLoadedMsg) + if !ok { + t.Fatalf("expected filePreviewLoadedMsg, got %T", msg) + } + m.filePreview = previewMsg.preview if m.filePreview == "" { t.Fatal("expected file preview to be populated") } diff --git a/internal/model/tui.go b/internal/model/tui.go index 67289bf..b8d346e 100644 --- a/internal/model/tui.go +++ b/internal/model/tui.go @@ -1,7 +1,10 @@ package model import ( + "strings" + "charm.land/lipgloss/v2" + "github.com/polymorcodeus/park/internal/config" "github.com/polymorcodeus/park/internal/theme" ) @@ -33,6 +36,12 @@ type styles struct { listDimmedDesc lipgloss.Style } +// fg builds a style with only a foreground color set. Most of the theme's +// styles are just this, so this collapses them from four lines to one. +func fg(color string) lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color(color)) +} + func newStyles() *styles { inactiveTabBorder := tabBorderWithBottom("┴", "─", "┴") activeTabBorder := tabBorderWithBottom("┘", " ", "└") @@ -54,45 +63,60 @@ func newStyles() *styles { s.topBorder = lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), false, false, true, false). BorderForeground(lipgloss.Color(theme.CharmPink)) - s.highlight = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmPink)) + s.highlight = fg(theme.CharmPink) s.window = lipgloss.NewStyle(). BorderBottom(true). Padding(1, 2). Align(lipgloss.Center). UnsetBorderTop() - s.errorText = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmRed)) - s.helpText = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmTextFaint)) - s.submitButton = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmTextFaint)) + s.errorText = fg(theme.CharmRed) + s.helpText = fg(theme.CharmTextFaint) + s.submitButton = fg(theme.CharmTextFaint) s.submitButtonFocus = lipgloss.NewStyle(). Foreground(lipgloss.Color(theme.CharmBG)). Background(lipgloss.Color(theme.CharmPink)) - s.focusedPrompt = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmPink)) - s.focusedText = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmText)) - s.blurredPrompt = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmTextFaint)) - s.blurredText = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmText)) - s.listNormalTitle = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmText)) - s.listNormalDesc = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmTextFaint)) - s.listSelectedTitle = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmPurpleLt)) - s.listSelectedDesc = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmTextMute)) - s.listDimmedTitle = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmTextFaint)) - s.listDimmedDesc = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.CharmTextFaint)) + s.focusedPrompt = fg(theme.CharmPink) + s.focusedText = fg(theme.CharmText) + s.blurredPrompt = fg(theme.CharmTextFaint) + s.blurredText = fg(theme.CharmText) + s.listNormalTitle = fg(theme.CharmText) + s.listNormalDesc = fg(theme.CharmTextFaint) + s.listSelectedTitle = fg(theme.CharmPurpleLt) + s.listSelectedDesc = fg(theme.CharmTextMute) + s.listDimmedTitle = fg(theme.CharmTextFaint) + s.listDimmedDesc = fg(theme.CharmTextFaint) return s } +// renderTabs renders a row of category tabs padded out to width with the +// top-border gap style, highlighting the tab at activeIdx. When focused is +// true, the active tab additionally gets a "❯ name ❮" indicator, used by +// the note form to show that the category selector itself has focus. +func (s *styles) renderTabs(categories []config.Category, activeIdx, width int, focused bool) string { + var rendered []string + for i, cl := range categories { + style := s.inactiveTab + label := cl.Name + if i == activeIdx { + style = s.activeTab + if focused { + label = "❯ " + cl.Name + " ❮" + } + } + rendered = append(rendered, style.Render(label)) + } + row := lipgloss.JoinHorizontal(lipgloss.Top, rendered...) + gapWidth := max(0, width-lipgloss.Width(row)) + gap := s.topBorder.Render(strings.Repeat(" ", gapWidth)) + return lipgloss.JoinHorizontal(lipgloss.Bottom, row, gap) +} + +// cycleIndex advances idx by delta and wraps around within [0, n). Used to +// cycle category selection in both the assist list and the note form. +func cycleIndex(idx, delta, n int) int { + return ((idx+delta)%n + n) % n +} + func tabBorderWithBottom(left, middle, right string) lipgloss.Border { b := lipgloss.RoundedBorder() b.BottomLeft = left diff --git a/internal/note/note.go b/internal/note/note.go index b9be78d..95d8b2c 100644 --- a/internal/note/note.go +++ b/internal/note/note.go @@ -14,72 +14,158 @@ import ( "github.com/polymorcodeus/park/internal/fs" ) -// Frontmatter is the fixed schema for every parked note. Deliberately flat -// (no nesting) so it can be parsed line-by-line without a YAML dependency. -type Frontmatter struct { +// Metadata is the set of fields persisted as frontmatter in every note. +type Metadata struct { Category string Created string Source string Synopsis string } -// MissingFields returns the required frontmatter fields that are empty. -func (fm Frontmatter) MissingFields() []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 strings.TrimSpace(fm.Category) == "" { + if !fieldSet(m.Category) { missing = append(missing, "category") } - if strings.TrimSpace(fm.Created) == "" { + if !fieldSet(m.Created) { missing = append(missing, "created") } - if strings.TrimSpace(fm.Source) == "" { + if !fieldSet(m.Source) { + missing = append(missing, "source") + } + if !fieldSet(m.Synopsis) { + missing = append(missing, "synopsis") + } + return missing +} + +// 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. +type Note struct { + Body string + Path string + Metadata +} + +// HasCompleteMetadata reports whether all frontmatter fields are present. +func (n Note) HasCompleteMetadata() bool { + return n.IsComplete() +} + +// Draft is the creation-time representation of a note. Created may be empty +// and is populated when the draft is converted to a Note. +type Draft struct { + Filename string + Body string + FromFile string + Metadata +} + +// WithDefaults fills in the default category and derives the filename from +// the source file when either is empty. +func (d Draft) WithDefaults(cfg *config.Config) Draft { + if d.Category == "" { + d.Category = cfg.DefaultCategory + } + if d.Filename == "" && d.FromFile != "" { + d.Filename = filepath.Base(d.FromFile) + } + return d +} + +// ReadyToCreate reports whether the draft has all fields required to create a +// note. Created is intentionally not checked; it is populated on write. +func (d Draft) ReadyToCreate() bool { + return fieldSet(d.Filename) && fieldSet(d.Category) && fieldSet(d.Source) && fieldSet(d.Synopsis) && d.Slug() != "" +} + +// MissingFields returns the user-supplied fields still required to create a note. +func (d Draft) MissingFields() []string { + var missing []string + if !fieldSet(d.Filename) || !fieldSet(d.Slug()) { + missing = append(missing, "filename") + } + if !fieldSet(d.Category) { + missing = append(missing, "category") + } + if !fieldSet(d.Source) { missing = append(missing, "source") } - if strings.TrimSpace(fm.Synopsis) == "" { + if !fieldSet(d.Synopsis) { missing = append(missing, "synopsis") } return missing } -// ParseFrontmatter reads the leading `---` delimited block from a markdown -// file and returns the parsed fields plus the raw body that follows. -func ParseFrontmatter(path string) (Frontmatter, string, error) { +// Slug returns a URL-safe slug derived from the draft filename. +func (d Draft) Slug() string { + return slugify(d.Filename) +} + +// H1 scans the body for a single-line H1 heading and returns the heading text. +func (d Draft) H1() (string, bool) { + for line := range strings.SplitSeq(d.Body, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if after, ok := strings.CutPrefix(trimmed, "# "); ok { + return strings.TrimSpace(after), true + } + break + } + return "", false +} + +// Parse reads the leading `---` delimited block from a markdown file and +// returns the parsed note. The note's Path is set to the file path. +// Unknown frontmatter keys are ignored. +func Parse(path string) (Note, error) { f, err := os.Open(path) if err != nil { - return Frontmatter{}, "", fmt.Errorf("open %q: %w", path, err) + return Note{}, fmt.Errorf("open %q: %w", path, err) } defer func() { _ = f.Close() }() - fm, body, _, err := parseFrontmatter(newScanner(f)) + n, _, err := parse(newScanner(f)) if err != nil { - return fm, "", fmt.Errorf("parse %q: %w", path, err) + return n, fmt.Errorf("parse %q: %w", path, err) } - return fm, body, nil + n.Path = path + return n, nil } -// ParseFrontmatterString parses the leading `---` delimited block from a -// markdown string and returns the parsed fields, the raw body that follows, -// and a flag indicating whether a frontmatter block was found. -func ParseFrontmatterString(content string) (Frontmatter, string, bool) { - fm, body, found, _ := parseFrontmatter(newStringScanner(content)) - return fm, body, found +// ParseString parses the leading `---` delimited block from a markdown string +// and returns the parsed note, a flag indicating whether a frontmatter block +// was found, and any parse error. Unknown frontmatter keys are ignored. +func ParseString(content string) (Note, bool, error) { + n, found, err := parse(newStringScanner(content)) + if err != nil { + return n, found, fmt.Errorf("parse string: %w", err) + } + return n, found, nil } func newScanner(f *os.File) *bufio.Scanner { return bufio.NewScanner(f) } func newStringScanner(s string) *bufio.Scanner { return bufio.NewScanner(strings.NewReader(s)) } -func parseFrontmatter(scanner *bufio.Scanner) (Frontmatter, string, bool, error) { - fm := Frontmatter{} +func parse(scanner *bufio.Scanner) (Note, bool, error) { + n := Note{} - // Frontmatter must start on the very first line of the file. if !scanner.Scan() { if err := scanner.Err(); err != nil { - return fm, "", false, err + return n, false, err } - return fm, "", false, nil + return n, false, nil } firstLine := scanner.Text() if strings.TrimSpace(firstLine) != "---" { @@ -88,10 +174,10 @@ func parseFrontmatter(scanner *bufio.Scanner) (Frontmatter, string, bool, error) bodyLines = append(bodyLines, scanner.Text()) } if err := scanner.Err(); err != nil { - return fm, "", false, err + return n, false, err } - body := strings.TrimLeft(strings.Join(bodyLines, "\n"), "\n") - return fm, body, false, nil + n.Body = strings.TrimLeft(strings.Join(bodyLines, "\n"), "\n") + return n, false, nil } var bodyLines []string @@ -109,22 +195,23 @@ func parseFrontmatter(scanner *bufio.Scanner) (Frontmatter, string, bool, error) } switch key { case "category": - fm.Category = val + n.Category = val case "created": - fm.Created = val + n.Created = val case "source": - fm.Source = val + n.Source = val case "synopsis": - fm.Synopsis = val + n.Synopsis = val } continue } bodyLines = append(bodyLines, line) } if err := scanner.Err(); err != nil { - return fm, "", false, err + return n, false, err } - return fm, strings.TrimLeft(strings.Join(bodyLines, "\n"), "\n"), true, nil + n.Body = strings.TrimLeft(strings.Join(bodyLines, "\n"), "\n") + return n, true, nil } func splitKV(line string) (key, val string, ok bool) { @@ -137,10 +224,10 @@ func splitKV(line string) (key, val string, ok bool) { return key, val, key != "" } -// WriteFrontmatter writes (or overwrites) a markdown file with the given -// frontmatter and body. Writes to a temp file and renames into place so a -// crash mid-write never leaves a partially-written note. -func WriteFrontmatter(path string, fm Frontmatter, body string) (err error) { +// Write writes (or overwrites) a markdown file with the note's frontmatter +// and body. It writes to a temp file and renames into place so a crash +// mid-write never leaves a partially-written note. +func Write(path string, n Note) (err error) { tmpPath := path + ".tmp" f, err := os.Create(tmpPath) if err != nil { @@ -156,7 +243,7 @@ func WriteFrontmatter(path string, fm Frontmatter, body string) (err error) { }() if _, err = fmt.Fprintf(f, "---\ncategory: %s\ncreated: %s\nsource: %s\nsynopsis: %s\n---\n\n%s\n", - fm.Category, fm.Created, fm.Source, fm.Synopsis, body); err != nil { + n.Category, n.Created, n.Source, n.Synopsis, n.Body); err != nil { return fmt.Errorf("write temp %q: %w", tmpPath, err) } @@ -171,89 +258,163 @@ func Today() string { return time.Now().Format("2006-01-02") } -// NewWithBody creates a fresh parked note with an explicit body. The filename -// is slugified to form the note's filename. -func NewWithBody(cfg *config.Config, filename, synopsis, source, targetCategory, body string) (string, error) { - cl, ok := cfg.CategoryByName(targetCategory) - if !ok { - return "", fmt.Errorf("unknown category %q — valid: %s", targetCategory, strings.Join(cfg.CategoryNames(), ", ")) - } +// Result is the outcome of attempting to add a note headlessly. +// Exactly one of Path or Form is set. +type Result struct { + Path string + Form *Draft +} - slug := slugify(filename) - if slug == "" { - slug = "note-" + Today() +// IngestFile reads the source file into the draft body when FromFile is set +// and Body is empty, merging any file frontmatter metadata with the draft's +// existing metadata (draft values take precedence). +func IngestFile(d Draft) (Draft, error) { + if d.FromFile == "" || d.Body != "" { + return d, nil } - path := filepath.Join(cl.Path, slug+".md") - - if _, err := os.Stat(cl.Path); os.IsNotExist(err) { - return "", fmt.Errorf("category folder %q does not exist; run `park init` to create it", cl.Path) + parsed, err := Parse(d.FromFile) + if err != nil { + return Draft{}, fmt.Errorf("parse source file %q: %w", d.FromFile, err) } - - fm := Frontmatter{ - Category: targetCategory, - Created: Today(), - Source: source, - Synopsis: synopsis, + if d.Category == "" { + d.Category = parsed.Category } - - if err := WriteFrontmatter(path, fm, body); err != nil { - return "", fmt.Errorf("write note %q: %w", path, err) + if d.Source == "" { + d.Source = parsed.Source } - return path, nil + if d.Synopsis == "" { + d.Synopsis = parsed.Synopsis + } + d.Body = parsed.Body + return d, nil } -// IngestFile moves an existing markdown file into the park, rewriting its -// frontmatter. If body is empty, the original file content is preserved; -// otherwise the supplied body is used. The source file is removed after a -// successful write. -func IngestFile(cfg *config.Config, srcPath, filename, synopsis, source, targetCategory, body string) (string, error) { - cl, ok := cfg.CategoryByName(targetCategory) - if !ok { - return "", fmt.Errorf("unknown category %q — valid: %s", targetCategory, strings.Join(cfg.CategoryNames(), ", ")) +// Add decides whether a note can be created headlessly or needs the +// interactive form. It parses the source file and body frontmatter when +// present, merging metadata with CLI values taking precedence, then applies +// config defaults for anything still empty. +func Add(cfg *config.Config, d Draft) (Result, error) { + if d.FromFile != "" && d.Body != "" { + return Result{}, fmt.Errorf("cannot specify both --from-file and a body") } - srcPath = fs.ExpandPath(srcPath) - - info, err := os.Stat(srcPath) - if err != nil { - return "", fmt.Errorf("stat source file %q: %w", srcPath, err) - } - if info.IsDir() { - return "", fmt.Errorf("source path %q is a directory", srcPath) + if d.FromFile != "" { + var err error + d, err = IngestFile(d) + if err != nil { + return Result{}, err + } + } else if d.Body != "" { + parsed, hasFM, err := ParseString(d.Body) + if err != nil { + return Result{}, err + } + if hasFM { + if d.Category == "" { + d.Category = parsed.Category + } + if d.Source == "" { + d.Source = parsed.Source + } + if d.Synopsis == "" { + d.Synopsis = parsed.Synopsis + } + var missing []string + if d.Category == "" { + missing = append(missing, "category") + } + if d.Source == "" { + missing = append(missing, "source") + } + if d.Synopsis == "" { + missing = append(missing, "synopsis") + } + if len(missing) > 0 { + return Result{}, fmt.Errorf("incomplete frontmatter: missing %s", strings.Join(missing, ", ")) + } + d.Body = parsed.Body + } } - if body == "" { - bodyBytes, err := os.ReadFile(srcPath) + d = d.WithDefaults(cfg) + + if d.ReadyToCreate() { + path, err := Create(cfg, d) if err != nil { - return "", fmt.Errorf("read source file %q: %w", srcPath, err) + return Result{}, err } - body = string(bodyBytes) + return Result{Path: path}, nil } - slug := slugify(filename) - if slug == "" { - slug = "note-" + Today() + if d.Body != "" || d.FromFile != "" { + if d.Filename == "" { + title, ok := d.H1() + if !ok { + return Result{}, fmt.Errorf("missing filename, retry with --filename") + } + d.Filename = title + if d.ReadyToCreate() { + path, err := Create(cfg, d) + if err != nil { + return Result{}, err + } + return Result{Path: path}, nil + } + } } - dstPath := filepath.Join(cl.Path, slug+".md") + + return Result{Form: &d}, nil +} + +// Create writes a note from a complete draft. Callers (Add and the form) are +// responsible for ensuring required fields are present; this function resolves +// the category path, writes the note, and removes the source file if FromFile +// is set. +func Create(cfg *config.Config, d Draft) (string, error) { + d = d.WithDefaults(cfg) + + cl, ok := cfg.CategoryByName(d.Category) + if !ok { + return "", fmt.Errorf("unknown category %q; valid: %s", d.Category, strings.Join(cfg.CategoryNames(), ", ")) + } + + path := filepath.Join(cl.Path, d.Slug()+".md") if _, err := os.Stat(cl.Path); os.IsNotExist(err) { return "", fmt.Errorf("category folder %q does not exist; run `park init` to create it", cl.Path) } - fm := Frontmatter{ - Category: targetCategory, - Created: Today(), - Source: source, - Synopsis: synopsis, + if _, err := os.Stat(path); err == nil { + return "", fmt.Errorf("note already exists: %s", path) + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("check note path %q: %w", path, err) } - if err := WriteFrontmatter(dstPath, fm, body); err != nil { - return "", fmt.Errorf("write ingested note %q: %w", dstPath, err) + n := Note{ + Path: path, + Body: d.Body, + Metadata: Metadata{ + Category: d.Category, + Created: Today(), + Source: d.Source, + Synopsis: d.Synopsis, + }, } - if err := os.Remove(srcPath); err != nil { - return "", fmt.Errorf("remove source file %q: %w", srcPath, err) + + if err := Write(path, n); err != nil { + return "", fmt.Errorf("write note %q: %w", path, err) } - return dstPath, nil + + if d.FromFile != "" { + srcPath, err := fs.ExpandPath(d.FromFile) + if err != nil { + return "", fmt.Errorf("expand source path %q: %w", d.FromFile, err) + } + if err := os.Remove(srcPath); err != nil { + return "", fmt.Errorf("remove source file %q: %w", srcPath, err) + } + } + return path, nil } func slugify(s string) string { @@ -276,171 +437,6 @@ func slugify(s string) string { return strings.Trim(b.String(), "-") } -// extractHeading scans a markdown body for a single-line H1 heading, skipping -// any leading frontmatter block. If found, it returns the heading text and -// the body with both the frontmatter and the heading removed so the title is -// not duplicated in the rendered note. -func extractHeading(body string) (title, remaining string, ok bool) { - _, body, _ = ParseFrontmatterString(body) - - lines := strings.Split(body, "\n") - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - continue - } - if after, ok := strings.CutPrefix(trimmed, "# "); ok { - title = strings.TrimSpace(after) - remainingLines := append(lines[:i], lines[i+1:]...) - remaining = strings.TrimLeft(strings.Join(remainingLines, "\n"), "\n") - return title, remaining, true - } - break - } - return "", body, false -} - -// ExtractH1 scans a markdown body for a single-line H1 heading and returns -// the heading text. It stops at the first non-empty line that is not an H1. -func ExtractH1(body string) (string, bool) { - for line := range strings.SplitSeq(body, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - continue - } - if after, ok := strings.CutPrefix(trimmed, "# "); ok { - return strings.TrimSpace(after), true - } - break - } - return "", false -} - -// NoteInput captures the raw inputs for creating a parked note. -type NoteInput struct { - Filename string - Synopsis string - Source string - Category string // explicit target category; empty means the config default - Body string // raw content from stdin or a file read by the caller - FromFile string // path to the original file when ingesting -} - -// NoteOutcome is the result of attempting to create a note headlessly. -// Exactly one of Path or Form is set. -type NoteOutcome struct { - Path string - Form *NoteForm -} - -// NoteForm holds the starting values for the interactive note form. -type NoteForm struct { - Filename string - Synopsis string - Source string - Category string - Body string - FromFile string -} - -// AddNote attempts to create a note without user interaction. If required -// metadata is missing, it returns a NoteOutcome with Form populated and no -// error. -func AddNote(cfg *config.Config, in NoteInput) (NoteOutcome, error) { - target := cfg.DefaultCategory - if in.Category != "" { - target = in.Category - } - - hasInput := in.Body != "" || in.FromFile != "" - hasMetadata := fieldSet(in.Filename) && fieldSet(in.Synopsis) && fieldSet(in.Source) - - if hasInput && hasMetadata { - // Caller supplied all required metadata and a body; create the note - // directly. Strip any frontmatter in the body so it isn't duplicated - // by WriteFrontmatter; if there is no frontmatter, keep the body as-is. - body := in.Body - if _, parsed, hasFM := ParseFrontmatterString(in.Body); hasFM { - body = parsed - } - var path string - var err error - if in.FromFile != "" { - path, err = IngestFile(cfg, in.FromFile, in.Filename, in.Synopsis, in.Source, target, body) - } else { - path, err = NewWithBody(cfg, in.Filename, in.Synopsis, in.Source, target, body) - } - if err != nil { - return NoteOutcome{}, err - } - return NoteOutcome{Path: path}, nil - } - - if hasInput { - return addFromInput(cfg, in, target) - } - - if hasMetadata { - path, err := NewWithBody(cfg, in.Filename, in.Synopsis, in.Source, target, "") - if err != nil { - return NoteOutcome{}, err - } - return NoteOutcome{Path: path}, nil - } - - return NoteOutcome{Form: &NoteForm{ - Filename: in.Filename, - Synopsis: in.Synopsis, - Source: in.Source, - Category: target, - FromFile: in.FromFile, - }}, nil -} - -func addFromInput(cfg *config.Config, in NoteInput, target string) (NoteOutcome, error) { - fm, body, hasFM := ParseFrontmatterString(in.Body) - if !hasFM { - return NoteOutcome{Form: &NoteForm{ - Filename: in.Filename, - Synopsis: in.Synopsis, - Source: in.Source, - Category: target, - Body: in.Body, - FromFile: in.FromFile, - }}, nil - } - - if missing := fm.MissingFields(); len(missing) > 0 { - return NoteOutcome{}, fmt.Errorf("incomplete frontmatter: missing %s", strings.Join(missing, ", ")) - } - cl, ok := cfg.CategoryByName(fm.Category) - if !ok { - return NoteOutcome{}, fmt.Errorf("unknown category %q in frontmatter — valid: %s", fm.Category, strings.Join(cfg.CategoryNames(), ", ")) - } - target = cl.Name - - filename := strings.TrimSpace(in.Filename) - if filename == "" { - h1, hasH1 := ExtractH1(body) - if !hasH1 { - return NoteOutcome{}, fmt.Errorf("missing filename, retry with --filename") - } - filename = h1 - } - - var path string - var err error - if in.FromFile != "" { - path, err = IngestFile(cfg, in.FromFile, filename, fm.Synopsis, fm.Source, target, body) - } else { - path, err = NewWithBody(cfg, filename, fm.Synopsis, fm.Source, target, body) - } - if err != nil { - return NoteOutcome{}, err - } - return NoteOutcome{Path: path}, nil -} - func fieldSet(s string) bool { return strings.TrimSpace(s) != "" } diff --git a/internal/note/note_test.go b/internal/note/note_test.go index bfd4906..51064d9 100644 --- a/internal/note/note_test.go +++ b/internal/note/note_test.go @@ -9,17 +9,17 @@ import ( "github.com/polymorcodeus/park/internal/config" ) -func TestParseFrontmatter(t *testing.T) { +func TestParse(t *testing.T) { tests := []struct { name string content string - want Frontmatter + want Metadata wantBody string }{ { name: "all fields", content: "---\ncategory: inbox\ncreated: 2026-07-28\nsource: terminal\nsynopsis: a test note\n---\n\n# hello\n", - want: Frontmatter{ + want: Metadata{ Category: "inbox", Created: "2026-07-28", Source: "terminal", @@ -30,7 +30,7 @@ func TestParseFrontmatter(t *testing.T) { { name: "empty body", content: "---\ncategory: archive\ncreated: 2026-07-28\nsource: chat\nsynopsis:\n---\n", - want: Frontmatter{ + want: Metadata{ Category: "archive", Created: "2026-07-28", Source: "chat", @@ -48,50 +48,56 @@ func TestParseFrontmatter(t *testing.T) { t.Fatalf("write test file: %v", err) } - got, body, err := ParseFrontmatter(path) + got, err := Parse(path) if err != nil { - t.Fatalf("ParseFrontmatter() error = %v", err) + t.Fatalf("Parse() error = %v", err) } - if got != tt.want { - t.Errorf("ParseFrontmatter() = %+v, want %+v", got, tt.want) + if got.Path != path { + t.Errorf("Parse() path = %q, want %q", got.Path, path) + } + if got.Category != tt.want.Category || got.Created != tt.want.Created || got.Source != tt.want.Source || got.Synopsis != tt.want.Synopsis { + t.Errorf("Parse() = %+v, want %+v", got.Metadata, tt.want) } - if body != tt.wantBody { - t.Errorf("ParseFrontmatter() body = %q, want %q", body, tt.wantBody) + if got.Body != tt.wantBody { + t.Errorf("Parse() body = %q, want %q", got.Body, tt.wantBody) } }) } } -func TestParseFrontmatterMissingFile(t *testing.T) { - _, _, err := ParseFrontmatter(filepath.Join(t.TempDir(), "nope.md")) +func TestParseMissingFile(t *testing.T) { + _, err := Parse(filepath.Join(t.TempDir(), "nope.md")) if err == nil { t.Fatal("expected error for missing file") } } -func TestWriteFrontmatterRoundTrip(t *testing.T) { +func TestWriteRoundTrip(t *testing.T) { tmp := t.TempDir() path := filepath.Join(tmp, "note.md") - fm := Frontmatter{ - Category: "projects", - Created: "2026-07-28", - Source: "test", - Synopsis: "round trip", + n := Note{ + Metadata: Metadata{ + Category: "projects", + Created: "2026-07-28", + Source: "test", + Synopsis: "round trip", + }, + Body: "# title\n", } - if err := WriteFrontmatter(path, fm, "# title\n"); err != nil { - t.Fatalf("WriteFrontmatter() error = %v", err) + if err := Write(path, n); err != nil { + t.Fatalf("Write() error = %v", err) } - got, body, err := ParseFrontmatter(path) + got, err := Parse(path) if err != nil { - t.Fatalf("ParseFrontmatter() error = %v", err) + t.Fatalf("Parse() error = %v", err) } - if got != fm { - t.Errorf("frontmatter mismatch: %+v, want %+v", got, fm) + if got.Metadata != n.Metadata { + t.Errorf("frontmatter mismatch: %+v, want %+v", got.Metadata, n.Metadata) } - if body != "# title\n" { - t.Errorf("body = %q, want %q", body, "# title\n") + if got.Body != "# title\n" { + t.Errorf("body = %q, want %q", got.Body, "# title\n") } } @@ -102,42 +108,67 @@ func TestToday(t *testing.T) { } } -func TestNewWithBody(t *testing.T) { +func TestCreate(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) } - body := "## heading\n\nparagraph\n" - path, err := NewWithBody(cfg, "Body Note", "with body", "test", "inbox", body) - if err != nil { - t.Fatalf("NewWithBody() error = %v", err) - } + t.Run("creates note with body", func(t *testing.T) { + body := "## heading\n\nparagraph\n" + path, err := Create(cfg, Draft{ + Filename: "Body Note", + Body: body, + Metadata: Metadata{Synopsis: "with body", Source: "test", Category: "inbox"}, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } - fm, parsedBody, err := ParseFrontmatter(path) - if err != nil { - t.Fatalf("ParseFrontmatter() error = %v", err) - } - if fm.Category != "inbox" { - t.Errorf("category = %q, want inbox", fm.Category) - } - if parsedBody != body { - t.Errorf("body = %q, want %q", parsedBody, body) - } -} + got, err := Parse(path) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if got.Category != "inbox" { + t.Errorf("category = %q, want inbox", got.Category) + } + if got.Body != body { + t.Errorf("body = %q, want %q", got.Body, body) + } + }) -func TestNewWithBodyMissingFolder(t *testing.T) { - tmp := t.TempDir() - cfg := config.DefaultConfig(tmp) + t.Run("missing folder returns error", func(t *testing.T) { + cfgNoFolder := config.DefaultConfig(t.TempDir()) + _, err := Create(cfgNoFolder, Draft{ + Filename: "Note", + Metadata: Metadata{Synopsis: "synopsis", Source: "test", Category: "inbox"}, + }) + if err == nil { + t.Fatal("expected error when category folder does not exist") + } + }) + + t.Run("duplicate slug returns error", func(t *testing.T) { + _, err := Create(cfg, Draft{ + Filename: "Duplicate", + Metadata: Metadata{Synopsis: "first", Source: "test", Category: "inbox"}, + }) + if err != nil { + t.Fatalf("first create: %v", err) + } + _, err = Create(cfg, Draft{ + Filename: "Duplicate", + Metadata: Metadata{Synopsis: "second", Source: "test", Category: "inbox"}, + }) + if err == nil { + t.Fatal("expected error for duplicate slug") + } + }) - _, err := NewWithBody(cfg, "Note", "synopsis", "test", "inbox", "") - if err == nil { - t.Fatal("expected error when category folder does not exist") - } } -func TestIngestFile(t *testing.T) { +func TestCreateFromFile(t *testing.T) { tmp := t.TempDir() cfg := config.DefaultConfig(tmp) if err := os.MkdirAll(cfg.Categories[1].Path, 0o755); err != nil { @@ -150,159 +181,102 @@ func TestIngestFile(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } - path, err := IngestFile(cfg, src, "Draft Note", "ingested", "agent", "projects", "") + d, err := IngestFile(Draft{ + Filename: "Draft Note", + FromFile: src, + Metadata: Metadata{Synopsis: "ingested", Source: "agent", Category: "projects"}, + }) if err != nil { t.Fatalf("IngestFile() error = %v", err) } + path, err := Create(cfg, d) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if _, err := os.Stat(src); !os.IsNotExist(err) { t.Errorf("source file was not removed: %v", err) } - fm, body, err := ParseFrontmatter(path) + got, err := Parse(path) if err != nil { - t.Fatalf("ParseFrontmatter() error = %v", err) + t.Fatalf("Parse() error = %v", err) } - if fm.Category != "projects" { - t.Errorf("category = %q, want projects", fm.Category) + if got.Category != "projects" { + t.Errorf("category = %q, want projects", got.Category) } - if fm.Synopsis != "ingested" { - t.Errorf("synopsis = %q, want ingested", fm.Synopsis) + if got.Synopsis != "ingested" { + t.Errorf("synopsis = %q, want ingested", got.Synopsis) } - if fm.Source != "agent" { - t.Errorf("source = %q, want agent", fm.Source) + if got.Source != "agent" { + t.Errorf("source = %q, want agent", got.Source) } - if body != content { - t.Errorf("body = %q, want %q", body, content) + wantBody := strings.TrimRight(content, "\n") + if got.Body != wantBody { + t.Errorf("body = %q, want %q", got.Body, wantBody) } } func TestIngestFileMissingSource(t *testing.T) { tmp := t.TempDir() - cfg := config.DefaultConfig(tmp) - _, err := IngestFile(cfg, filepath.Join(tmp, "missing.md"), "Filename", "synopsis", "test", "inbox", "") + _, err := IngestFile(Draft{ + Filename: "Filename", + FromFile: filepath.Join(tmp, "missing.md"), + Metadata: Metadata{Synopsis: "synopsis", Source: "test", Category: "inbox"}, + }) if err == nil { t.Fatal("expected error for missing source file") } } -func TestIngestFileDirectory(t *testing.T) { +func TestIngestFileDirectorySource(t *testing.T) { tmp := t.TempDir() - cfg := config.DefaultConfig(tmp) dir := filepath.Join(tmp, "adir") if err := os.Mkdir(dir, 0o755); err != nil { t.Fatalf("Mkdir() error = %v", err) } - _, err := IngestFile(cfg, dir, "Filename", "synopsis", "test", "inbox", "") + _, err := IngestFile(Draft{ + Filename: "Filename", + FromFile: dir, + Metadata: Metadata{Synopsis: "synopsis", Source: "test", Category: "inbox"}, + }) if err == nil { t.Fatal("expected error for directory source path") } } -func TestExtractHeading(t *testing.T) { - tests := []struct { - name string - body string - wantHeading string - wantRemaining string - wantOK bool - }{ - { - name: "h1 at start", - body: "# My Title\n\nbody content\n", - wantHeading: "My Title", - wantRemaining: "body content", - wantOK: true, - }, - { - name: "h1 after blank lines", - body: "\n\n# Another Title\ncontent\n", - wantHeading: "Another Title", - wantRemaining: "content", - wantOK: true, - }, - { - name: "no h1", - body: "just some text\n", - wantHeading: "", - wantRemaining: "just some text", - wantOK: false, - }, - { - name: "h2 not extracted", - body: "## Section\n", - wantHeading: "", - wantRemaining: "## Section", - wantOK: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - heading, remaining, ok := extractHeading(tt.body) - if ok != tt.wantOK { - t.Fatalf("extractHeading() ok = %v, want %v", ok, tt.wantOK) - } - if heading != tt.wantHeading { - t.Errorf("heading = %q, want %q", heading, tt.wantHeading) - } - if remaining != tt.wantRemaining { - t.Errorf("remaining = %q, want %q", remaining, tt.wantRemaining) - } - }) - } -} - -func TestExtractH1(t *testing.T) { +func TestDraftH1(t *testing.T) { tests := []struct { name string body string wantHeading string wantOK bool }{ - { - name: "h1 present", - body: "# My Title\n\nbody\n", - wantHeading: "My Title", - wantOK: true, - }, - { - name: "h1 after blank lines", - body: "\n\n# Another Title\ncontent\n", - wantHeading: "Another Title", - wantOK: true, - }, - { - name: "no h1", - body: "just some text\n", - wantHeading: "", - wantOK: false, - }, - { - name: "h2 not extracted", - body: "## Section\n", - wantHeading: "", - wantOK: false, - }, + {"h1 at start", "# My Title\n\nbody content\n", "My Title", true}, + {"h1 after blank lines", "\n\n# Another Title\ncontent\n", "Another Title", true}, + {"no h1", "just some text\n", "", false}, + {"h2 not extracted", "## Section\n", "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - heading, ok := ExtractH1(tt.body) + d := Draft{Body: tt.body} + heading, ok := d.H1() if ok != tt.wantOK { - t.Fatalf("ExtractH1() ok = %v, want %v", ok, tt.wantOK) + t.Fatalf("H1() ok = %v, want %v", ok, tt.wantOK) } if heading != tt.wantHeading { - t.Errorf("ExtractH1() heading = %q, want %q", heading, tt.wantHeading) + t.Errorf("H1() heading = %q, want %q", heading, tt.wantHeading) } }) } } -func TestAddNote(t *testing.T) { +func TestAdd(t *testing.T) { tmp := t.TempDir() cfg := config.DefaultConfig(tmp) for i := range cfg.Categories { @@ -312,15 +286,13 @@ func TestAddNote(t *testing.T) { } t.Run("all metadata no body creates note", func(t *testing.T) { - in := NoteInput{ + d := Draft{ Filename: "test-note", - Synopsis: "a test", - Source: "terminal", - Category: "inbox", + Metadata: Metadata{Synopsis: "a test", Source: "terminal", Category: "inbox"}, } - out, err := AddNote(cfg, in) + out, err := Add(cfg, d) if err != nil { - t.Fatalf("AddNote() error = %v", err) + t.Fatalf("Add() error = %v", err) } if out.Form != nil { t.Fatal("expected direct creation, got form") @@ -328,12 +300,12 @@ func TestAddNote(t *testing.T) { if out.Path == "" { t.Fatal("expected path, got empty") } - fm, _, err := ParseFrontmatter(out.Path) + got, err := Parse(out.Path) if err != nil { - t.Fatalf("ParseFrontmatter() error = %v", err) + t.Fatalf("Parse() error = %v", err) } - if fm.Synopsis != "a test" || fm.Source != "terminal" || fm.Category != "inbox" { - t.Errorf("frontmatter mismatch: %+v", fm) + if got.Synopsis != "a test" || got.Source != "terminal" || got.Category != "inbox" { + t.Errorf("frontmatter mismatch: %+v", got.Metadata) } }) @@ -343,17 +315,14 @@ func TestAddNote(t *testing.T) { if err := os.WriteFile(src, []byte(content), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } - in := NoteInput{ + d := Draft{ Filename: "from-file", - Synopsis: "from file", - Source: "migration", - Category: "archive", - Body: content, FromFile: src, + Metadata: Metadata{Synopsis: "from file", Source: "migration", Category: "archive"}, } - out, err := AddNote(cfg, in) + out, err := Add(cfg, d) if err != nil { - t.Fatalf("AddNote() error = %v", err) + t.Fatalf("Add() error = %v", err) } if out.Form != nil { t.Fatal("expected direct creation, got form") @@ -361,97 +330,111 @@ func TestAddNote(t *testing.T) { if _, err := os.Stat(src); !os.IsNotExist(err) { t.Errorf("source file was not removed") } - fm, body, err := ParseFrontmatter(out.Path) + got, err := Parse(out.Path) if err != nil { - t.Fatalf("ParseFrontmatter() error = %v", err) + t.Fatalf("Parse() error = %v", err) } - if fm.Category != "archive" || fm.Synopsis != "from file" || fm.Source != "migration" { - t.Errorf("frontmatter mismatch: %+v", fm) + if got.Category != "archive" || got.Synopsis != "from file" || got.Source != "migration" { + t.Errorf("frontmatter mismatch: %+v", got.Metadata) } - if body != content { - t.Errorf("body = %q, want %q", body, content) + wantBody := strings.TrimRight(content, "\n") + if got.Body != wantBody { + t.Errorf("body = %q, want %q", got.Body, wantBody) + } + }) + + t.Run("from-file and body together returns error", func(t *testing.T) { + src := filepath.Join(tmp, "conflict.md") + if err := os.WriteFile(src, []byte("body from file\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + d := Draft{ + Filename: "conflict", + FromFile: src, + Body: "body from stdin\n", + Metadata: Metadata{Synopsis: "conflict", Source: "test", Category: "inbox"}, + } + _, err := Add(cfg, d) + if err == nil { + t.Fatal("expected error when both --from-file and body are provided") } }) t.Run("stdin body without frontmatter and all metadata creates directly", func(t *testing.T) { - in := NoteInput{ + d := Draft{ Filename: "piped", - Synopsis: "piped body", - Source: "stdin", - Category: "projects", Body: "# Piped title\n\ntext\n", + Metadata: Metadata{Synopsis: "piped body", Source: "stdin", Category: "projects"}, } - out, err := AddNote(cfg, in) + out, err := Add(cfg, d) if err != nil { - t.Fatalf("AddNote() error = %v", err) + t.Fatalf("Add() error = %v", err) } if out.Form != nil { t.Fatal("expected direct creation, got form") } - fm, body, err := ParseFrontmatter(out.Path) + got, err := Parse(out.Path) if err != nil { - t.Fatalf("ParseFrontmatter() error = %v", err) + t.Fatalf("Parse() error = %v", err) } - if fm.Category != "projects" { - t.Errorf("category = %q, want projects", fm.Category) + if got.Category != "projects" { + t.Errorf("category = %q, want projects", got.Category) } - if body != "# Piped title\n\ntext\n" { - t.Errorf("body = %q, want %q", body, "# Piped title\n\ntext\n") + if got.Body != "# Piped title\n\ntext\n" { + t.Errorf("body = %q, want %q", got.Body, "# Piped title\n\ntext\n") } }) t.Run("body with frontmatter uses frontmatter values", func(t *testing.T) { - in := NoteInput{ + d := Draft{ Body: "---\ncategory: areas\nsource: chat\nsynopsis: fm-driven\ncreated: 2026-07-01\n---\n\n# Title\n\nbody\n", } - out, err := AddNote(cfg, in) + out, err := Add(cfg, d) if err != nil { - t.Fatalf("AddNote() error = %v", err) + t.Fatalf("Add() error = %v", err) } if out.Form != nil { t.Fatal("expected direct creation, got form") } - fm, _, err := ParseFrontmatter(out.Path) + got, err := Parse(out.Path) if err != nil { - t.Fatalf("ParseFrontmatter() error = %v", err) + t.Fatalf("Parse() error = %v", err) } - if fm.Category != "areas" || fm.Source != "chat" || fm.Synopsis != "fm-driven" { - t.Errorf("frontmatter mismatch: %+v", fm) + if got.Category != "areas" || got.Source != "chat" || got.Synopsis != "fm-driven" { + t.Errorf("frontmatter mismatch: %+v", got.Metadata) } }) t.Run("body with frontmatter and explicit metadata uses explicit metadata", func(t *testing.T) { - in := NoteInput{ + d := Draft{ Filename: "explicit", - Synopsis: "explicit", - Source: "explicit", - Category: "archive", Body: "---\ncategory: areas\nsource: chat\nsynopsis: fm-driven\ncreated: 2026-07-01\n---\n\n# Title\n\nbody\n", + Metadata: Metadata{Synopsis: "explicit", Source: "explicit", Category: "archive"}, } - out, err := AddNote(cfg, in) + out, err := Add(cfg, d) if err != nil { - t.Fatalf("AddNote() error = %v", err) + t.Fatalf("Add() error = %v", err) } if out.Form != nil { t.Fatal("expected direct creation, got form") } - fm, body, err := ParseFrontmatter(out.Path) + got, err := Parse(out.Path) if err != nil { - t.Fatalf("ParseFrontmatter() error = %v", err) + t.Fatalf("Parse() error = %v", err) } - if fm.Category != "archive" || fm.Source != "explicit" || fm.Synopsis != "explicit" { - t.Errorf("frontmatter mismatch: %+v", fm) + if got.Category != "archive" || got.Source != "explicit" || got.Synopsis != "explicit" { + t.Errorf("frontmatter mismatch: %+v", got.Metadata) } - if strings.Contains(body, "fm-driven") { + if strings.Contains(got.Body, "fm-driven") { t.Errorf("body still contains old frontmatter") } }) t.Run("missing metadata without body returns form", func(t *testing.T) { - in := NoteInput{Filename: "only-filename"} - out, err := AddNote(cfg, in) + d := Draft{Filename: "only-filename"} + out, err := Add(cfg, d) if err != nil { - t.Fatalf("AddNote() error = %v", err) + t.Fatalf("Add() error = %v", err) } if out.Form == nil { t.Fatal("expected form outcome") @@ -459,14 +442,89 @@ func TestAddNote(t *testing.T) { }) t.Run("body with incomplete frontmatter returns error", func(t *testing.T) { - in := NoteInput{ + d := Draft{ Body: "---\ncategory: inbox\n---\n\nbody\n", } - _, err := AddNote(cfg, in) + _, err := Add(cfg, d) if err == nil { t.Fatal("expected error for incomplete frontmatter") } }) + + t.Run("body with incomplete frontmatter and CLI metadata creates directly", func(t *testing.T) { + d := Draft{ + Filename: "merged", + Body: "---\ncategory: inbox\n---\n\n# Title\n\nbody\n", + Metadata: Metadata{Synopsis: "cli synopsis", Source: "cli"}, + } + out, err := Add(cfg, d) + if err != nil { + t.Fatalf("Add() error = %v", err) + } + if out.Form != nil { + t.Fatal("expected direct creation, got form") + } + got, err := Parse(out.Path) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if got.Category != "inbox" || got.Source != "cli" || got.Synopsis != "cli synopsis" { + t.Errorf("frontmatter mismatch: %+v", got.Metadata) + } + if strings.Contains(got.Body, "category:") { + t.Errorf("body still contains frontmatter") + } + }) + + t.Run("from-file without filename uses source basename", func(t *testing.T) { + src := filepath.Join(tmp, "rando-file.md") + content := "## Random\n\ncontent\n" + if err := os.WriteFile(src, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + d := Draft{ + FromFile: src, + Metadata: Metadata{Synopsis: "from source file", Source: "migration", Category: "archive"}, + } + out, err := Add(cfg, d) + if err != nil { + t.Fatalf("Add() error = %v", err) + } + if out.Form != nil { + t.Fatal("expected direct creation, got form") + } + if !strings.HasSuffix(out.Path, "rando-file.md") { + t.Errorf("path = %q, expected suffix rando-file.md", out.Path) + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Errorf("source file was not removed") + } + }) + + t.Run("from-file with missing metadata returns form with filename populated", func(t *testing.T) { + src := filepath.Join(tmp, "draft-note.md") + content := "plain body without frontmatter\n" + if err := os.WriteFile(src, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + d := Draft{ + FromFile: src, + Metadata: Metadata{Source: "migration"}, + } + out, err := Add(cfg, d) + if err != nil { + t.Fatalf("Add() error = %v", err) + } + if out.Form == nil { + t.Fatal("expected form outcome") + } + if out.Form.Filename != "draft-note.md" { + t.Errorf("form filename = %q, want draft-note.md", out.Form.Filename) + } + if out.Form.Source != "migration" { + t.Errorf("form source = %q, want migration", out.Form.Source) + } + }) } func TestSlugify(t *testing.T) { diff --git a/internal/render/render.go b/internal/render/render.go index 0bcaf2a..64e08a6 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -4,38 +4,57 @@ package render import ( "fmt" "io" + "sync" "charm.land/glamour/v2" "github.com/polymorcodeus/park/internal/note" ) +var ( + renderer *glamour.TermRenderer + rendererOnce sync.Once + rendererErr error +) + +func termRenderer() (*glamour.TermRenderer, error) { + rendererOnce.Do(func() { + renderer, rendererErr = glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(100), + ) + }) + if rendererErr != nil { + return nil, rendererErr + } + return renderer, nil +} + // ShowFile renders a parked note's frontmatter summary + body to w via -// glamour — the "look deeper" step after the synopsis in the list view +// glamour: the "look deeper" step after the synopsis in the list view // earned a second look. func ShowFile(path string, w io.Writer) error { - fm, body, err := note.ParseFrontmatter(path) + n, err := note.Parse(path) if err != nil { return fmt.Errorf("show %q: %w", path, err) } header := fmt.Sprintf( "**category:** %s    **created:** %s    **source:** %s\n\n> %s\n\n---\n\n", - fm.Category, fm.Created, fm.Source, fm.Synopsis, + n.Category, n.Created, n.Source, n.Synopsis, ) - renderer, err := glamour.NewTermRenderer( - glamour.WithStandardStyle("dark"), - glamour.WithWordWrap(100), - ) + renderer, err := termRenderer() if err != nil { return fmt.Errorf("create glamour renderer: %w", err) } - out, err := renderer.Render(header + body) + out, err := renderer.Render(header + n.Body) if err != nil { return fmt.Errorf("render %q: %w", path, err) } - _, err = fmt.Fprint(w, out) - return err + if _, err := fmt.Fprint(w, out); err != nil { + return fmt.Errorf("write rendered output: %w", err) + } + return nil } diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..524897c --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,66 @@ +package render + +import ( + "bytes" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/polymorcodeus/park/internal/config" + "github.com/polymorcodeus/park/internal/note" +) + +var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*[a-zA-Z]") + +func stripANSI(s string) string { + return ansiEscape.ReplaceAllString(s, "") +} + +func TestShowFileRendersNote(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, "test-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); err != nil { + t.Fatalf("ShowFile() error = %v", err) + } + + out := buf.String() + if out == "" { + t.Fatal("ShowFile() produced empty output") + } + plain := stripANSI(out) + if !strings.Contains(plain, "a rendered note") { + t.Errorf("output missing synopsis; got %q", plain) + } + if !strings.Contains(plain, "Hello") { + t.Errorf("output missing body; got %q", plain) + } +} + +func TestShowFileMissing(t *testing.T) { + var buf bytes.Buffer + err := ShowFile(filepath.Join(t.TempDir(), "missing.md"), &buf) + if err == nil { + t.Fatal("expected error for missing file") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index cb96420..0ecfefb 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -16,10 +16,10 @@ import ( // Item is a single parked note as seen by the scanner/TUI. type Item struct { - Path string - Filename string - Frontmatter note.Frontmatter - ModTime time.Time + note.Metadata + Path string + Filename string + ModTime time.Time } // Init creates the category folders defined in cfg. It returns the paths @@ -69,7 +69,7 @@ func Check(cfg *config.Config) ([]string, error) { func Scan(cfg *config.Config, categoryName string) ([]Item, error) { cl, ok := cfg.CategoryByName(categoryName) if !ok { - return nil, fmt.Errorf("unknown category %q — valid: %s", categoryName, strings.Join(cfg.CategoryNames(), ", ")) + return nil, fmt.Errorf("unknown category %q; valid: %s", categoryName, strings.Join(cfg.CategoryNames(), ", ")) } entries, err := os.ReadDir(cl.Path) @@ -86,7 +86,7 @@ func Scan(cfg *config.Config, categoryName string) ([]Item, error) { continue } path := filepath.Join(cl.Path, e.Name()) - fm, _, err := note.ParseFrontmatter(path) + n, err := note.Parse(path) if err != nil { return nil, fmt.Errorf("parse frontmatter for %q: %w", path, err) } @@ -95,10 +95,10 @@ func Scan(cfg *config.Config, categoryName string) ([]Item, error) { return nil, fmt.Errorf("stat %q: %w", path, err) } items = append(items, Item{ - Path: path, - Filename: e.Name(), - Frontmatter: fm, - ModTime: info.ModTime(), + Metadata: n.Metadata, + Path: path, + Filename: e.Name(), + ModTime: info.ModTime(), }) } sort.Slice(items, func(i, j int) bool { @@ -113,18 +113,17 @@ func Scan(cfg *config.Config, categoryName string) ([]Item, error) { func Reclassify(cfg *config.Config, filename string, targetCategory string) error { cl, ok := cfg.CategoryByName(targetCategory) if !ok { - return fmt.Errorf("unknown category %q — valid: %s", targetCategory, strings.Join(cfg.CategoryNames(), ", ")) + return fmt.Errorf("unknown category %q; valid: %s", targetCategory, strings.Join(cfg.CategoryNames(), ", ")) } var src string - var fm note.Frontmatter - var body string + var n note.Note for _, c := range cfg.Categories { candidate := filepath.Join(c.Path, filename) if _, statErr := os.Stat(candidate); statErr == nil { src = candidate var parseErr error - fm, body, parseErr = note.ParseFrontmatter(candidate) + n, parseErr = note.Parse(candidate) if parseErr != nil { return fmt.Errorf("parse frontmatter for %q: %w", candidate, parseErr) } @@ -139,12 +138,18 @@ func Reclassify(cfg *config.Config, filename string, targetCategory string) erro return fmt.Errorf("already in %s", targetCategory) } - fm.Category = targetCategory + n.Category = targetCategory dst := filepath.Join(cl.Path, filename) - // Rewrite frontmatter in place first, then move — if the move fails + if _, err := os.Stat(dst); err == nil { + return fmt.Errorf("already exists in %s: %s", targetCategory, filename) + } else if !os.IsNotExist(err) { + return fmt.Errorf("check destination %q: %w", dst, err) + } + + // Rewrite frontmatter in place first, then move; if the move fails // (e.g. cross-device), the file is still left in a consistent state. - if err := note.WriteFrontmatter(src, fm, body); err != nil { + if err := note.Write(src, n); err != nil { return fmt.Errorf("rewrite frontmatter for %q: %w", src, err) } if err := os.Rename(src, dst); err != nil { @@ -153,17 +158,31 @@ func Reclassify(cfg *config.Config, filename string, targetCategory string) erro return nil } +// FormatInitResult formats the result of Init for user-facing output. +func FormatInitResult(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 +} + // ResolvePath accepts either a bare filename (searched across all category -// folders) or a full path used as-is. -func ResolvePath(cfg *config.Config, filename string) string { +// folders) or a full path used as-is. It returns os.ErrNotExist when no file +// can be resolved. +func ResolvePath(cfg *config.Config, filename string) (string, error) { if _, err := os.Stat(filename); err == nil { - return filename + return filename, nil } for _, cl := range cfg.Categories { p := filepath.Join(cl.Path, filename) if _, err := os.Stat(p); err == nil { - return p + return p, nil } } - return filename + return "", os.ErrNotExist } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index f023364..3ecac38 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1,6 +1,7 @@ package store import ( + "errors" "os" "path/filepath" "testing" @@ -102,27 +103,27 @@ func TestNewCreatesNote(t *testing.T) { t.Fatalf("Init() error = %v", err) } - path, err := note.NewWithBody(cfg, "My Note", "a synopsis", "test", "inbox", "") + path, err := note.Create(cfg, note.Draft{Filename: "My Note", Metadata: note.Metadata{Synopsis: "a synopsis", Source: "test", Category: "inbox"}}) if err != nil { - t.Fatalf("NewWithBody() error = %v", err) + t.Fatalf("Create() error = %v", err) } if _, err := os.Stat(path); err != nil { t.Fatalf("note file missing: %v", err) } - fm, _, err := note.ParseFrontmatter(path) + n, err := note.Parse(path) if err != nil { - t.Fatalf("ParseFrontmatter() error = %v", err) + t.Fatalf("Parse() error = %v", err) } - if fm.Category != "inbox" { - t.Errorf("category = %q, want inbox", fm.Category) + if n.Category != "inbox" { + t.Errorf("category = %q, want inbox", n.Category) } - if fm.Synopsis != "a synopsis" { - t.Errorf("synopsis = %q, want %q", fm.Synopsis, "a synopsis") + if n.Synopsis != "a synopsis" { + t.Errorf("synopsis = %q, want %q", n.Synopsis, "a synopsis") } - if fm.Source != "test" { - t.Errorf("source = %q, want test", fm.Source) + if n.Source != "test" { + t.Errorf("source = %q, want test", n.Source) } } @@ -133,9 +134,9 @@ func TestReclassifyMovesFile(t *testing.T) { t.Fatalf("Init() error = %v", err) } - path, err := note.NewWithBody(cfg, "Move Me", "synopsis", "test", "inbox", "") + path, err := note.Create(cfg, note.Draft{Filename: "Move Me", Metadata: note.Metadata{Synopsis: "synopsis", Source: "test", Category: "inbox"}}) if err != nil { - t.Fatalf("NewWithBody() error = %v", err) + t.Fatalf("Create() error = %v", err) } filename := filepath.Base(path) @@ -153,12 +154,12 @@ func TestReclassifyMovesFile(t *testing.T) { t.Errorf("file missing in projects: %v", err) } - fm, _, err := note.ParseFrontmatter(projectsPath) + n, err := note.Parse(projectsPath) if err != nil { - t.Fatalf("ParseFrontmatter() error = %v", err) + t.Fatalf("Parse() error = %v", err) } - if fm.Category != "projects" { - t.Errorf("category = %q, want projects", fm.Category) + if n.Category != "projects" { + t.Errorf("category = %q, want projects", n.Category) } } @@ -169,9 +170,9 @@ func TestReclassifySameCategory(t *testing.T) { t.Fatalf("Init() error = %v", err) } - path, err := note.NewWithBody(cfg, "Stay Put", "synopsis", "test", "inbox", "") + path, err := note.Create(cfg, note.Draft{Filename: "Stay Put", Metadata: note.Metadata{Synopsis: "synopsis", Source: "test", Category: "inbox"}}) if err != nil { - t.Fatalf("NewWithBody() error = %v", err) + t.Fatalf("Create() error = %v", err) } filename := filepath.Base(path) @@ -208,6 +209,26 @@ func TestReclassifyMissingFile(t *testing.T) { } } +func TestReclassifyDestinationExists(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if _, _, err := Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + + if _, err := note.Create(cfg, note.Draft{Filename: "Collision", Metadata: note.Metadata{Synopsis: "in inbox", Source: "test", Category: "inbox"}}); err != nil { + t.Fatalf("Create() inbox error = %v", err) + } + if _, err := note.Create(cfg, note.Draft{Filename: "Collision", Metadata: note.Metadata{Synopsis: "in projects", Source: "test", Category: "projects"}}); err != nil { + t.Fatalf("Create() projects error = %v", err) + } + + err := Reclassify(cfg, "Collision.md", "projects") + if err == nil { + t.Fatal("expected error when destination file already exists") + } +} + func TestScan(t *testing.T) { tmp := t.TempDir() cfg := config.DefaultConfig(tmp) @@ -215,11 +236,11 @@ func TestScan(t *testing.T) { t.Fatalf("Init() error = %v", err) } - if _, err := note.NewWithBody(cfg, "First", "oldest", "test", "inbox", ""); err != nil { - t.Fatalf("NewWithBody() error = %v", err) + if _, err := note.Create(cfg, note.Draft{Filename: "First", Metadata: note.Metadata{Synopsis: "oldest", Source: "test", Category: "inbox"}}); err != nil { + t.Fatalf("Create() error = %v", err) } - if _, err := note.NewWithBody(cfg, "Second", "newer", "test", "inbox", ""); err != nil { - t.Fatalf("NewWithBody() error = %v", err) + if _, err := note.Create(cfg, note.Draft{Filename: "Second", Metadata: note.Metadata{Synopsis: "newer", Source: "test", Category: "inbox"}}); err != nil { + t.Fatalf("Create() error = %v", err) } items, err := Scan(cfg, "inbox") @@ -248,19 +269,30 @@ func TestResolvePath(t *testing.T) { t.Fatalf("Init() error = %v", err) } - path, err := note.NewWithBody(cfg, "Resolve Me", "synopsis", "test", "inbox", "") + path, err := note.Create(cfg, note.Draft{Filename: "Resolve Me", Metadata: note.Metadata{Synopsis: "synopsis", Source: "test", Category: "inbox"}}) if err != nil { - t.Fatalf("NewWithBody() error = %v", err) + t.Fatalf("Create() error = %v", err) } filename := filepath.Base(path) - got := ResolvePath(cfg, filename) + got, err := ResolvePath(cfg, filename) + if err != nil { + t.Fatalf("ResolvePath(%q) error = %v", filename, err) + } if got != path { t.Errorf("ResolvePath(%q) = %q, want %q", filename, got, path) } - fullPath := ResolvePath(cfg, path) + fullPath, err := ResolvePath(cfg, path) + if err != nil { + t.Fatalf("ResolvePath(%q) error = %v", path, err) + } if fullPath != path { t.Errorf("ResolvePath(%q) = %q, want %q", path, fullPath, path) } + + _, err = ResolvePath(cfg, "missing.md") + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("ResolvePath(missing) error = %v, want os.ErrNotExist", err) + } } diff --git a/internal/theme/glyphs.go b/internal/theme/glyphs.go new file mode 100644 index 0000000..88e8997 --- /dev/null +++ b/internal/theme/glyphs.go @@ -0,0 +1,38 @@ +package theme + +import "os" + +// Glyphs holds the terminal glyphs used in styled CLI output and the TUI. +// Two sets are provided: NerdFont for terminals with a Nerd Font installed, +// and ASCII for plain terminals. The active set is selected by the +// PARK_PLAIN environment variable; when non-empty, ASCII glyphs are used. +type Glyphs struct { + ErrorBullet string + SubmitLeft string + SubmitRight string +} + +var ( + // NerdFont uses private-use-area glyphs. These render correctly only when + // the terminal font includes Nerd Font symbols. + NerdFont = Glyphs{ + ErrorBullet: "\U000f0bf7", + SubmitLeft: "\U000f013d ", + SubmitRight: " \U000f013e", + } + + // ASCII uses plain characters so output is readable on any terminal. + ASCII = Glyphs{ + ErrorBullet: "!", + SubmitLeft: "[ ", + SubmitRight: " ]", + } +) + +// CurrentGlyphs returns the glyph set selected by the environment. +func CurrentGlyphs() Glyphs { + if os.Getenv("PARK_PLAIN") != "" { + return ASCII + } + return NerdFont +} diff --git a/internal/theme/glyphs_test.go b/internal/theme/glyphs_test.go new file mode 100644 index 0000000..01250cc --- /dev/null +++ b/internal/theme/glyphs_test.go @@ -0,0 +1,20 @@ +package theme + +import ( + "testing" +) + +func TestCurrentGlyphsDefault(t *testing.T) { + gs := CurrentGlyphs() + if gs.ErrorBullet == "" { + t.Error("default glyph set has empty error bullet") + } +} + +func TestCurrentGlyphsPlain(t *testing.T) { + t.Setenv("PARK_PLAIN", "1") + gs := CurrentGlyphs() + if gs.ErrorBullet != ASCII.ErrorBullet { + t.Errorf("PARK_PLAIN error bullet = %q, want %q", gs.ErrorBullet, ASCII.ErrorBullet) + } +} diff --git a/main.go b/main.go index 254f901..5e91e1e 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,5 @@ // Command park is a standalone parking-lot for markdown notes, organized -// as IPAA — Inbox / Projects / Areas / Archive, a PARA variant. +// as IPAA: Inbox / Projects / Areas / Archive, a PARA variant. package main @@ -19,14 +19,10 @@ var ( buildTime string ) -// use embedded VERSION file for local `go install`d version -func init() { +func main() { if version == "" { version = strings.TrimSpace(versionFile) } -} - -func main() { cmd.SetVersion(version) cmd.SetBuildTime(buildTime) cmd.Main()