feat(note): implement note module

Amolith created

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

Change summary

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(-)

Detailed changes

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)
+	}
+}

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
 }

cmd/note/get.go 🔗

@@ -1,34 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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")
-}

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
+}

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)
 }

cmd/note/show.go 🔗

@@ -0,0 +1,118 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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)
+}

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
+}

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
+}