From 51c1acdb93e0b61f7bfd1c78345a0d2fe6de7db8 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 21 Dec 2025 21:26:08 -0700 Subject: [PATCH] feat(note): implement note module Fully implement note commands with Lunatask API integration: - add: create notes with notebook, content, and source support - list: filter by notebook/source, table and JSON output - show: display note details (renamed from get) - update: modify name, content, notebook, date - delete: with confirmation prompt Add NotebookByID helper to config package. Register shell completions for notebook flag. Assisted-by: Claude Sonnet 4 via Crush --- cmd/note/add.go | 158 +++++++++++++++++++++++++++++--- cmd/note/delete.go | 53 +++++++---- cmd/note/get.go | 34 ------- cmd/note/list.go | 183 ++++++++++++++++++++++++++++++++++++-- cmd/note/note.go | 2 +- cmd/note/show.go | 118 ++++++++++++++++++++++++ cmd/note/update.go | 129 ++++++++++++++++++++++++--- internal/config/config.go | 11 +++ 8 files changed, 607 insertions(+), 81 deletions(-) delete mode 100644 cmd/note/get.go create mode 100644 cmd/note/show.go diff --git a/cmd/note/add.go b/cmd/note/add.go index 184e1b4f8afd0d8883bb04f9a5930223ed590337..0f3a5a2f922be3cc3fb172212924c9b54868c4dd 100644 --- a/cmd/note/add.go +++ b/cmd/note/add.go @@ -5,34 +5,172 @@ package note import ( + "bufio" + "errors" "fmt" + "os" + "strings" + "git.secluded.site/go-lunatask" + "git.secluded.site/lune/internal/client" "git.secluded.site/lune/internal/completion" + "git.secluded.site/lune/internal/config" + "git.secluded.site/lune/internal/ui" "github.com/spf13/cobra" ) +// ErrUnknownNotebook indicates the specified notebook key was not found in config. +var ErrUnknownNotebook = errors.New("unknown notebook key") + +// ErrNoInput indicates no input was provided on stdin. +var ErrNoInput = errors.New("no input provided on stdin") + // AddCmd creates a new note. Exported for potential use by shortcuts. var AddCmd = &cobra.Command{ Use: "add [NAME]", Short: "Create a new note", Long: `Create a new note in Lunatask. +The note name is optional. Use flags to set additional properties. Use "-" as content flag value to read from stdin.`, - RunE: func(cmd *cobra.Command, args []string) error { - // TODO: implement note creation - name := "" - if len(args) > 0 { - name = args[0] - } - fmt.Fprintf(cmd.OutOrStdout(), "Creating note: %s (not yet implemented)\n", name) - - return nil - }, + RunE: runAdd, } func init() { AddCmd.Flags().StringP("notebook", "b", "", "Notebook key (from config)") AddCmd.Flags().StringP("content", "c", "", "Note content (use - for stdin)") + AddCmd.Flags().String("source", "", "Source identifier") + AddCmd.Flags().String("source-id", "", "Source ID") _ = AddCmd.RegisterFlagCompletionFunc("notebook", completion.Notebooks) } + +func runAdd(cmd *cobra.Command, args []string) error { + apiClient, err := client.New() + if err != nil { + return err + } + + builder := apiClient.NewNote() + + if len(args) > 0 { + name, err := resolveName(args[0]) + if err != nil { + return err + } + + builder.WithName(name) + } + + if err := applyNotebook(cmd, builder); err != nil { + return err + } + + if err := applyContent(cmd, builder); err != nil { + return err + } + + applySource(cmd, builder) + + note, err := builder.Create(cmd.Context()) + if err != nil { + return err + } + + if note == nil { + fmt.Fprintln(cmd.OutOrStdout(), ui.Warning.Render("Note already exists (duplicate source)")) + + return nil + } + + fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Created note: "+note.ID)) + + return nil +} + +func resolveName(arg string) (string, error) { + if arg != "-" { + return arg, nil + } + + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + return strings.TrimSpace(scanner.Text()), nil + } + + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("reading stdin: %w", err) + } + + return "", ErrNoInput +} + +func applyNotebook(cmd *cobra.Command, builder *lunatask.NoteBuilder) error { + notebookKey, _ := cmd.Flags().GetString("notebook") + if notebookKey == "" { + cfg, err := config.Load() + if err != nil && !errors.Is(err, config.ErrNotFound) { + return err + } + + if cfg != nil { + notebookKey = cfg.Defaults.Notebook + } + } + + if notebookKey == "" { + return nil + } + + cfg, err := config.Load() + if err != nil { + return err + } + + notebook := cfg.NotebookByKey(notebookKey) + if notebook == nil { + return fmt.Errorf("%w: %s", ErrUnknownNotebook, notebookKey) + } + + builder.InNotebook(notebook.ID) + + return nil +} + +func applyContent(cmd *cobra.Command, builder *lunatask.NoteBuilder) error { + content, _ := cmd.Flags().GetString("content") + if content == "" { + return nil + } + + resolved, err := resolveContent(content) + if err != nil { + return err + } + + builder.WithContent(resolved) + + return nil +} + +func resolveContent(content string) (string, error) { + if content != "-" { + return content, nil + } + + data, err := os.ReadFile("/dev/stdin") + if err != nil { + return "", fmt.Errorf("reading stdin: %w", err) + } + + return strings.TrimSpace(string(data)), nil +} + +func applySource(cmd *cobra.Command, builder *lunatask.NoteBuilder) { + source, _ := cmd.Flags().GetString("source") + sourceID, _ := cmd.Flags().GetString("source-id") + + if source != "" && sourceID != "" { + builder.FromSource(source, sourceID) + } +} diff --git a/cmd/note/delete.go b/cmd/note/delete.go index 5a9d764e8a5cc719bf77ca60f683c97bddca7e24..b7f52a1cd73ce92ce7fe40f268a9ad7dd370801c 100644 --- a/cmd/note/delete.go +++ b/cmd/note/delete.go @@ -7,6 +7,7 @@ package note import ( "fmt" + "git.secluded.site/lune/internal/client" "git.secluded.site/lune/internal/ui" "git.secluded.site/lune/internal/validate" "github.com/spf13/cobra" @@ -16,29 +17,43 @@ import ( var DeleteCmd = &cobra.Command{ Use: "delete ID", Short: "Delete a note", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - id, err := validate.Reference(args[0]) - if err != nil { - return err - } + Long: `Delete a note from Lunatask. + +Accepts a UUID or lunatask:// deep link.`, + Args: cobra.ExactArgs(1), + RunE: runDelete, +} + +func init() { + DeleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") +} + +func runDelete(cmd *cobra.Command, args []string) error { + id, err := validate.Reference(args[0]) + if err != nil { + return err + } - force, _ := cmd.Flags().GetBool("force") - if !force { - if !ui.Confirm(fmt.Sprintf("Delete note %s?", id)) { - fmt.Fprintln(cmd.OutOrStdout(), "Cancelled") + force, _ := cmd.Flags().GetBool("force") + if !force { + if !ui.Confirm(fmt.Sprintf("Delete note %s?", id)) { + fmt.Fprintln(cmd.OutOrStdout(), "Cancelled") - return nil - } + return nil } + } - // TODO: implement note deletion - fmt.Fprintf(cmd.OutOrStdout(), "Deleting note %s (not yet implemented)\n", id) + apiClient, err := client.New() + if err != nil { + return err + } - return nil - }, -} + note, err := apiClient.DeleteNote(cmd.Context(), id) + if err != nil { + return err + } -func init() { - DeleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Deleted note: "+note.ID)) + + return nil } diff --git a/cmd/note/get.go b/cmd/note/get.go deleted file mode 100644 index c14e1ce218e4adbc49fed87d372903f9f452c5d8..0000000000000000000000000000000000000000 --- a/cmd/note/get.go +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package note - -import ( - "fmt" - - "git.secluded.site/lune/internal/validate" - "github.com/spf13/cobra" -) - -// GetCmd retrieves a note by ID. Exported for potential use by shortcuts. -var GetCmd = &cobra.Command{ - Use: "get ID", - Short: "Get a note by ID or reference", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - id, err := validate.Reference(args[0]) - if err != nil { - return err - } - - // TODO: implement note get - fmt.Fprintf(cmd.OutOrStdout(), "Getting note %s (not yet implemented)\n", id) - - return nil - }, -} - -func init() { - GetCmd.Flags().Bool("json", false, "Output as JSON") -} diff --git a/cmd/note/list.go b/cmd/note/list.go index 7a3213ae82088a0729dd80480ac44aedd7e59966..75f4853914e7b5c6ce2cdff37649879b7c6c884b 100644 --- a/cmd/note/list.go +++ b/cmd/note/list.go @@ -5,9 +5,17 @@ package note import ( + "encoding/json" + "errors" "fmt" + "git.secluded.site/go-lunatask" + "git.secluded.site/lune/internal/client" "git.secluded.site/lune/internal/completion" + "git.secluded.site/lune/internal/config" + "git.secluded.site/lune/internal/ui" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" "github.com/spf13/cobra" ) @@ -19,17 +27,180 @@ var ListCmd = &cobra.Command{ Note: Due to end-to-end encryption, note names and content are not available through the API. Only metadata is shown.`, - RunE: func(cmd *cobra.Command, _ []string) error { - // TODO: implement note listing - fmt.Fprintln(cmd.OutOrStdout(), "Note listing not yet implemented") - - return nil - }, + RunE: runList, } func init() { ListCmd.Flags().StringP("notebook", "b", "", "Filter by notebook key") + ListCmd.Flags().String("source", "", "Filter by source") + ListCmd.Flags().String("source-id", "", "Filter by source ID") ListCmd.Flags().Bool("json", false, "Output as JSON") _ = ListCmd.RegisterFlagCompletionFunc("notebook", completion.Notebooks) } + +func runList(cmd *cobra.Command, _ []string) error { + apiClient, err := client.New() + if err != nil { + return err + } + + opts := buildListOptions(cmd) + + notes, err := apiClient.ListNotes(cmd.Context(), opts) + if err != nil { + return err + } + + notebookID, err := resolveNotebookFilter(cmd) + if err != nil { + return err + } + + if notebookID != "" { + notes = filterByNotebook(notes, notebookID) + } + + if len(notes) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No notes found") + + return nil + } + + if mustGetBoolFlag(cmd, "json") { + return outputJSON(cmd, notes) + } + + return outputTable(cmd, notes) +} + +func buildListOptions(cmd *cobra.Command) *lunatask.ListNotesOptions { + source, _ := cmd.Flags().GetString("source") + sourceID, _ := cmd.Flags().GetString("source-id") + + if source == "" && sourceID == "" { + return nil + } + + opts := &lunatask.ListNotesOptions{} + if source != "" { + opts.Source = &source + } + + if sourceID != "" { + opts.SourceID = &sourceID + } + + return opts +} + +func resolveNotebookFilter(cmd *cobra.Command) (string, error) { + notebookKey := mustGetStringFlag(cmd, "notebook") + if notebookKey == "" { + return "", nil + } + + cfg, err := config.Load() + if err != nil { + if errors.Is(err, config.ErrNotFound) { + return "", err + } + + return "", err + } + + notebook := cfg.NotebookByKey(notebookKey) + if notebook == nil { + return "", fmt.Errorf("%w: %s", ErrUnknownNotebook, notebookKey) + } + + return notebook.ID, nil +} + +func filterByNotebook(notes []lunatask.Note, notebookID string) []lunatask.Note { + filtered := make([]lunatask.Note, 0, len(notes)) + + for _, note := range notes { + if note.NotebookID != nil && *note.NotebookID == notebookID { + filtered = append(filtered, note) + } + } + + return filtered +} + +func mustGetStringFlag(cmd *cobra.Command, name string) string { + f := cmd.Flags().Lookup(name) + if f == nil { + panic("flag not defined: " + name) + } + + return f.Value.String() +} + +func mustGetBoolFlag(cmd *cobra.Command, name string) bool { + f := cmd.Flags().Lookup(name) + if f == nil { + panic("flag not defined: " + name) + } + + return f.Value.String() == "true" +} + +func outputJSON(cmd *cobra.Command, notes []lunatask.Note) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + + if err := enc.Encode(notes); err != nil { + return fmt.Errorf("encoding JSON: %w", err) + } + + return nil +} + +func outputTable(cmd *cobra.Command, notes []lunatask.Note) error { + cfg, _ := config.Load() + rows := make([][]string, 0, len(notes)) + + for _, note := range notes { + notebook := "-" + if note.NotebookID != nil { + notebook = *note.NotebookID + if cfg != nil { + if nb := cfg.NotebookByID(*note.NotebookID); nb != nil { + notebook = nb.Key + } + } + } + + dateOn := "-" + if note.DateOn != nil { + dateOn = ui.FormatDate(note.DateOn.Time) + } + + pinned := "" + if note.Pinned { + pinned = "📌" + } + + created := ui.FormatDate(note.CreatedAt) + + rows = append(rows, []string{note.ID, notebook, dateOn, pinned, created}) + } + + tbl := table.New(). + Headers("ID", "NOTEBOOK", "DATE", "📌", "CREATED"). + Rows(rows...). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return ui.Bold + } + + return lipgloss.NewStyle() + }). + Border(lipgloss.HiddenBorder()) + + fmt.Fprintln(cmd.OutOrStdout(), tbl.Render()) + + return nil +} diff --git a/cmd/note/note.go b/cmd/note/note.go index a3bc6ca4023718450f908a9525a9df93d4c21d0d..572cfb91f7fdb7559de5cef5b3805a5945ac3b15 100644 --- a/cmd/note/note.go +++ b/cmd/note/note.go @@ -17,7 +17,7 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(AddCmd) Cmd.AddCommand(ListCmd) - Cmd.AddCommand(GetCmd) + Cmd.AddCommand(ShowCmd) Cmd.AddCommand(UpdateCmd) Cmd.AddCommand(DeleteCmd) } diff --git a/cmd/note/show.go b/cmd/note/show.go new file mode 100644 index 0000000000000000000000000000000000000000..8083813c02a633b9ff80ab7d40f1847e9658dc06 --- /dev/null +++ b/cmd/note/show.go @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package note + +import ( + "encoding/json" + "fmt" + + "git.secluded.site/go-lunatask" + "git.secluded.site/lune/internal/client" + "git.secluded.site/lune/internal/config" + "git.secluded.site/lune/internal/deeplink" + "git.secluded.site/lune/internal/ui" + "git.secluded.site/lune/internal/validate" + "github.com/spf13/cobra" +) + +// ShowCmd displays a note by ID. Exported for potential use by shortcuts. +var ShowCmd = &cobra.Command{ + Use: "show ID", + Short: "Show note details", + Long: `Show detailed information for a note. + +Accepts a UUID or lunatask:// deep link. + +Note: Due to end-to-end encryption, note name and content +are not available through the API. Only metadata is shown.`, + Args: cobra.ExactArgs(1), + RunE: runShow, +} + +func init() { + ShowCmd.Flags().Bool("json", false, "Output as JSON") +} + +func runShow(cmd *cobra.Command, args []string) error { + id, err := validate.Reference(args[0]) + if err != nil { + return err + } + + apiClient, err := client.New() + if err != nil { + return err + } + + note, err := apiClient.GetNote(cmd.Context(), id) + if err != nil { + return err + } + + if mustGetBoolFlag(cmd, "json") { + return outputNoteJSON(cmd, note) + } + + return printNoteDetails(cmd, note) +} + +func outputNoteJSON(cmd *cobra.Command, note *lunatask.Note) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + + if err := enc.Encode(note); err != nil { + return fmt.Errorf("encoding JSON: %w", err) + } + + return nil +} + +func printNoteDetails(cmd *cobra.Command, note *lunatask.Note) error { + link, _ := deeplink.Build(deeplink.Note, note.ID) + + fmt.Fprintf(cmd.OutOrStdout(), "%s\n", ui.H1.Render("Note")) + fmt.Fprintf(cmd.OutOrStdout(), " ID: %s\n", note.ID) + fmt.Fprintf(cmd.OutOrStdout(), " Link: %s\n", link) + + printNotebook(cmd, note) + + if note.DateOn != nil { + fmt.Fprintf(cmd.OutOrStdout(), " Date: %s\n", ui.FormatDate(note.DateOn.Time)) + } + + if note.Pinned { + fmt.Fprintf(cmd.OutOrStdout(), " Pinned: yes\n") + } + + if len(note.Sources) > 0 { + fmt.Fprintf(cmd.OutOrStdout(), " Sources:\n") + + for _, src := range note.Sources { + fmt.Fprintf(cmd.OutOrStdout(), " - %s: %s\n", src.Source, src.SourceID) + } + } + + fmt.Fprintf(cmd.OutOrStdout(), " Created: %s\n", ui.FormatDate(note.CreatedAt)) + fmt.Fprintf(cmd.OutOrStdout(), " Updated: %s\n", ui.FormatDate(note.UpdatedAt)) + + return nil +} + +func printNotebook(cmd *cobra.Command, note *lunatask.Note) { + if note.NotebookID == nil { + return + } + + notebookDisplay := *note.NotebookID + cfg, _ := config.Load() + + if cfg != nil { + if notebook := cfg.NotebookByID(*note.NotebookID); notebook != nil { + notebookDisplay = fmt.Sprintf("%s (%s)", notebook.Name, notebook.Key) + } + } + + fmt.Fprintf(cmd.OutOrStdout(), " Notebook: %s\n", notebookDisplay) +} diff --git a/cmd/note/update.go b/cmd/note/update.go index 8dff187a64e629c58de0d4631a5d7d9742088e58..c57eb5bc98c69a2c20b5b4f2de6f9672977b8d12 100644 --- a/cmd/note/update.go +++ b/cmd/note/update.go @@ -7,7 +7,12 @@ package note import ( "fmt" + "git.secluded.site/go-lunatask" + "git.secluded.site/lune/internal/client" "git.secluded.site/lune/internal/completion" + "git.secluded.site/lune/internal/config" + "git.secluded.site/lune/internal/dateutil" + "git.secluded.site/lune/internal/ui" "git.secluded.site/lune/internal/validate" "github.com/spf13/cobra" ) @@ -16,18 +21,12 @@ import ( var UpdateCmd = &cobra.Command{ Use: "update ID", Short: "Update a note", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - id, err := validate.Reference(args[0]) - if err != nil { - return err - } + Long: `Update an existing note in Lunatask. - // TODO: implement note update - fmt.Fprintf(cmd.OutOrStdout(), "Updating note %s (not yet implemented)\n", id) - - return nil - }, +Accepts a UUID or lunatask:// deep link. +Only specified flags are modified; other fields remain unchanged.`, + Args: cobra.ExactArgs(1), + RunE: runUpdate, } func init() { @@ -38,3 +37,111 @@ func init() { _ = UpdateCmd.RegisterFlagCompletionFunc("notebook", completion.Notebooks) } + +func runUpdate(cmd *cobra.Command, args []string) error { + id, err := validate.Reference(args[0]) + if err != nil { + return err + } + + apiClient, err := client.New() + if err != nil { + return err + } + + builder := apiClient.NewNoteUpdate(id) + + if err := applyUpdateName(cmd, builder); err != nil { + return err + } + + if err := applyUpdateNotebook(cmd, builder); err != nil { + return err + } + + if err := applyUpdateContent(cmd, builder); err != nil { + return err + } + + if err := applyUpdateDate(cmd, builder); err != nil { + return err + } + + note, err := builder.Update(cmd.Context()) + if err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Updated note: "+note.ID)) + + return nil +} + +func applyUpdateName(cmd *cobra.Command, builder *lunatask.NoteUpdateBuilder) error { + name, _ := cmd.Flags().GetString("name") + if name == "" { + return nil + } + + resolved, err := resolveName(name) + if err != nil { + return err + } + + builder.WithName(resolved) + + return nil +} + +func applyUpdateNotebook(cmd *cobra.Command, builder *lunatask.NoteUpdateBuilder) error { + notebookKey, _ := cmd.Flags().GetString("notebook") + if notebookKey == "" { + return nil + } + + cfg, err := config.Load() + if err != nil { + return err + } + + notebook := cfg.NotebookByKey(notebookKey) + if notebook == nil { + return fmt.Errorf("%w: %s", ErrUnknownNotebook, notebookKey) + } + + builder.InNotebook(notebook.ID) + + return nil +} + +func applyUpdateContent(cmd *cobra.Command, builder *lunatask.NoteUpdateBuilder) error { + content, _ := cmd.Flags().GetString("content") + if content == "" { + return nil + } + + resolved, err := resolveContent(content) + if err != nil { + return err + } + + builder.WithContent(resolved) + + return nil +} + +func applyUpdateDate(cmd *cobra.Command, builder *lunatask.NoteUpdateBuilder) error { + dateStr, _ := cmd.Flags().GetString("date") + if dateStr == "" { + return nil + } + + date, err := dateutil.Parse(dateStr) + if err != nil { + return err + } + + builder.OnDate(date) + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 0281548bb8fb9f398615f7db560e21c734637812..b0c974a52dc7564a4918367370efceddb82e19e7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -223,3 +223,14 @@ func (c *Config) GoalByID(id string) *GoalMatch { return nil } + +// NotebookByID finds a notebook by its ID. +func (c *Config) NotebookByID(id string) *Notebook { + for i := range c.Notebooks { + if c.Notebooks[i].ID == id { + return &c.Notebooks[i] + } + } + + return nil +}