diff --git a/README.md b/README.md index 2c2602e..72b418b 100644 --- a/README.md +++ b/README.md @@ -4,34 +4,37 @@ Keep track of what you're doing when you do it and forget trying to write summar ![demo](./demo.gif) -## Usage +## Installation -To write or edit today's file, run `daylog` and today's log will be opened in `$EDITOR`. +### Homebrew +``` +brew tap notnmeyer/daylog-cli +brew install daylog +``` -To view today's file, run `daylog show`. +### Releases +Grab a release directly from the [releases page]() -To interact with a past or future log supply a date (`daylog show -- 2023/01/07`), or a more casual realtive reference, "tomorrow", "yesterday", "1 day ago", etc. +### From source +`go build -o ~/bin/daylog main.go`, substituting `~/bin/daylog` for a different path if you prefer. -### Log storage +## Usage -Logs are stored in `$XDG_DATA_HOME/daylog`. Use `daylog info` to print the exact directory. +To write or edit today's log, run `daylog` and today's log will be opened in `$EDITOR`. -## Installation +To view today's log, run `daylog show`. -### Install a prebuilt binary +To interact with a past or future log supply a date (`daylog show -- 2023/01/07`), or a more casual realtive reference, "tomorrow", "yesterday", "1 day ago", etc. -Via Homebrew, +You can pipe updates as well, `echo "- ate a burrito" | daylog`. -``` -brew tap notnmeyer/daylog-cli -brew install daylog -``` +For other commands and options see, `daylog --help`. -Or grab a release directly from the [releases page]() +### Log storage -### From source +Logs are stored in `$XDG_DATA_HOME/daylog`. Use `daylog info` to print the exact directory. -1. Build the project with, `go build -o ~/bin/daylog main.go`, substituting `~/bin/daylog` for a different path if you prefer. +--- [^1]: DayLog ah ahh ahhhhhh, fighter of the night log ah ahh ahhhhh. diff --git a/cmd/root.go b/cmd/root.go index cfe8ed8..51847d5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,9 +2,11 @@ package cmd import ( "context" + "io" "log" "os" "path/filepath" + "strings" "github.com/charmbracelet/fang" "github.com/notnmeyer/daylog-cli/internal/daylog" @@ -32,17 +34,25 @@ var rootCmd = &cobra.Command{ log.Fatal(err) } - showPrevious, err := cmd.PersistentFlags().GetBool("prev") + if err := applyPrevFlag(cmd, dl); err != nil { + log.Fatal(err) + } + + piped, err := stdinIsPiped() if err != nil { - log.Fatalf("%s", err.Error()) + log.Fatal(err) } - if showPrevious { - prev, err := file.PreviousLog(dl.ProjectPath, file.LogProvider{}) + if piped { + content, err := io.ReadAll(os.Stdin) if err != nil { log.Fatal(err) } - dl.Path = filepath.Join(dl.ProjectPath, prev, "log.md") + formatted := formatStdinContent(string(content)) + if err := dl.Append(formatted); err != nil { + log.Fatal(err) + } + return } if err := dl.Edit(); err != nil { @@ -71,3 +81,38 @@ func init() { rootCmd.PersistentFlags().StringVarP(&config.Project, "project", "p", "default", "The daylog project to use") rootCmd.PersistentFlags().Bool("prev", false, "Operate on the most recent log that isn't today's") } + +func applyPrevFlag(cmd *cobra.Command, dl *daylog.DayLog) error { + showPrevious, err := cmd.PersistentFlags().GetBool("prev") + if err != nil { + return err + } + if showPrevious { + prev, err := file.PreviousLog(dl.ProjectPath, file.LogProvider{}) + if err != nil { + return err + } + dl.Path = filepath.Join(dl.ProjectPath, prev, "log.md") + } + return nil +} + +func formatStdinContent(content string) string { + // if a string like "hello\nworld" is piped, the markdown formatter (the default) will smash + // it together like "helloworld". so if there are \n's just make it a code block so the line + // breaks render correctly. + content = strings.TrimRight(content, "\n") + if strings.Contains(content, "\n") { + return "```\n" + content + "\n```" + } + return content +} + +func stdinIsPiped() (bool, error) { + stat, err := os.Stdin.Stat() + if err != nil { + return false, err + } + // terminals set ModeCharDevice. pipes don't. so if the bit is zero, we have piped input + return (stat.Mode() & os.ModeCharDevice) == 0, nil +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..a8e4391 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,46 @@ +package cmd + +import "testing" + +func TestFormatStdinContent(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "single line", + input: "hello world", + expected: "hello world", + }, + { + name: "single line with trailing newline", + input: "hello world\n", + expected: "hello world", + }, + { + name: "multi-line becomes code block", + input: "hello\nworld", + expected: "```\nhello\nworld\n```", + }, + { + name: "multi-line with trailing newline becomes code block", + input: "hello\nworld\n", + expected: "```\nhello\nworld\n```", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatStdinContent(tt.input) + if result != tt.expected { + t.Errorf("formatStdinContent(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/internal/daylog/daylog.go b/internal/daylog/daylog.go index 26d428d..b237472 100644 --- a/internal/daylog/daylog.go +++ b/internal/daylog/daylog.go @@ -50,6 +50,38 @@ func New(args []string, project string) (*DayLog, error) { }, nil } +// append content to the log for the specified date +func (d *DayLog) Append(content string) error { + if err := createIfMissing(d); err != nil { + return err + } + + existing, err := os.ReadFile(d.Path) + if err != nil { + return err + } + + if len(existing) > 0 && existing[len(existing)-1] != '\n' { + existing = append(existing, '\n') + } + + content = strings.TrimRight(content, "\n") + "\n" + + if err := os.WriteFile(d.Path, append(existing, []byte(content)...), 0644); err != nil { + return err + } + + if d.gitEnabled() { + msg := fmt.Sprintf("update log for %d/%d/%d\n", d.Date.Year(), int(d.Date.Month()), d.Date.Day()) + output, err := git.AddAndCommit(d.ProjectPath, d.Path, msg) + if err != nil { + return fmt.Errorf("%s: %s", err, output.Stderr.String()) + } + } + + return nil +} + // edit the log for the specified date func (d *DayLog) Edit() error { if err := createIfMissing(d); err != nil { @@ -195,7 +227,7 @@ func createIfMissing(d *DayLog) error { } year, month, day := d.Date.Year(), int(d.Date.Month()), d.Date.Day() - header := fmt.Sprintf("# %d/%02d/%02d", year, month, day) + header := fmt.Sprintf("# %d/%02d/%02d\n\n", year, month, day) _, err = file.WriteString(header) if err != nil { return err diff --git a/internal/daylog/daylog_test.go b/internal/daylog/daylog_test.go new file mode 100644 index 0000000..47dfd06 --- /dev/null +++ b/internal/daylog/daylog_test.go @@ -0,0 +1,91 @@ +package daylog + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func testDayLog(t *testing.T) *DayLog { + t.Helper() + dir := t.TempDir() + date := time.Date(2025, 12, 2, 0, 0, 0, 0, time.UTC) + return &DayLog{ + Path: filepath.Join(dir, "log.md"), + ProjectPath: dir, + Date: &date, + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading file: %v", err) + } + return string(b) +} + +func TestAppend(t *testing.T) { + tests := []struct { + name string + existing *string // nil means file doesn't exist yet + content string + expectedFinal string + }{ + { + name: "new file gets header then content", + existing: nil, + content: "hello world", + expectedFinal: "# 2025/12/02\n\nhello world\n", + }, + { + name: "existing file with trailing newline", + existing: strPtr("# 2025/12/02\n\nexisting entry\n"), + content: "new entry", + expectedFinal: "# 2025/12/02\n\nexisting entry\nnew entry\n", + }, + { + name: "existing file without trailing newline", + existing: strPtr("# 2025/12/02\n\nexisting entry"), + content: "new entry", + expectedFinal: "# 2025/12/02\n\nexisting entry\nnew entry\n", + }, + { + name: "trailing newlines in content are normalized to one", + existing: strPtr("# 2025/12/02\n\n"), + content: "hello\n\n", + expectedFinal: "# 2025/12/02\n\nhello\n", + }, + { + name: "multi-line content (code block) appended intact", + existing: strPtr("# 2025/12/02\n\n"), + content: "```\nline one\nline two\n```", + expectedFinal: "# 2025/12/02\n\n```\nline one\nline two\n```\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dl := testDayLog(t) + + if tt.existing != nil { + if err := os.WriteFile(dl.Path, []byte(*tt.existing), 0644); err != nil { + t.Fatalf("writing existing file: %v", err) + } + } + + if err := dl.Append(tt.content); err != nil { + t.Fatalf("Append() error = %v", err) + } + + got := readFile(t, dl.Path) + if got != tt.expectedFinal { + t.Errorf("file contents =\n%q\nwant\n%q", got, tt.expectedFinal) + } + }) + } +} + +func strPtr(s string) *string { return &s }