Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions cmd/dump.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cmd

import (
"fmt"
"log"
"time"

"github.com/notnmeyer/daylog-cli/internal/date"
"github.com/notnmeyer/daylog-cli/internal/daylog"
"github.com/spf13/cobra"
)

var dumpCmd = &cobra.Command{
Use: "dump",
Short: "Concatenate all logs",
Long: "Concatenate every log in the project, oldest first",
Example: `
daylog dump
daylog dump -r
daylog dump --reverse
daylog dump -o text
daylog dump -p work
daylog dump --since "1 week ago"
daylog dump --since 2023/01/07 --until yesterday
`,
Args: cobra.NoArgs,

Run: func(cmd *cobra.Command, args []string) {
projectPath, err := daylog.EnsureProjectPath(config.Project)
if err != nil {
log.Fatal(err)
}

format, err := cmd.Flags().GetString("output")
if err != nil {
log.Fatal(err)
}

reverse, err := cmd.Flags().GetBool("reverse")
if err != nil {
log.Fatal(err)
}

since, err := parseDateFlag(cmd, "since")
if err != nil {
log.Fatal(err)
}

until, err := parseDateFlag(cmd, "until")
if err != nil {
log.Fatal(err)
}

contents, err := daylog.Dump(projectPath, format, reverse, since, until)
if err != nil {
log.Fatal(err)
}

fmt.Println(contents)
},
}

// parseDateFlag reads a string flag and, if set, parses it with the same
// casual date syntax the rest of daylog accepts ("yesterday", "3 days ago",
// "2023/01/07", etc). an unset/empty flag returns a nil *time.Time.
func parseDateFlag(cmd *cobra.Command, name string) (*time.Time, error) {
raw, err := cmd.Flags().GetString(name)
if err != nil {
return nil, err
}
if raw == "" {
return nil, nil
}

t, err := date.Parse(raw, time.Now())
if err != nil {
return nil, err
}
return &t, nil
}

func init() {
rootCmd.AddCommand(dumpCmd)
dumpCmd.Flags().StringP("output", "o", "markdown", "Format output")
dumpCmd.Flags().BoolP("reverse", "r", false, "Newest logs first instead of oldest first")
dumpCmd.Flags().String("since", "", "Only include logs on or after this date")
dumpCmd.Flags().String("until", "", "Only include logs on or before this date")
}
51 changes: 51 additions & 0 deletions internal/daylog/daylog.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -309,6 +310,56 @@ func Search(projectPath, query string, ignoreCase bool) ([]SearchMatch, error) {
return matches, nil
}

// dump concatenates every log in the project oldest first by default (reverse=true for newest first).
// since/until, if non-nil, restrict the range to logs dated on or after/before that day (inclusive on both ends).
func Dump(projectPath, format string, reverse bool, since, until *time.Time) (string, error) {
logs, err := file.NewLogProvider().GetLogs(projectPath)
if err != nil {
return "", err
}

// GetLogs returns newest first; reverse that to get oldest-first by default
if !reverse {
slices.Reverse(logs)
}

var b strings.Builder
for _, log := range logs {
if since != nil || until != nil {
d, err := time.Parse("2006/01/02", log)
if err != nil {
return "", err
}
if since != nil && d.Before(dateOnly(*since)) {
continue
}
if until != nil && d.After(dateOnly(*until)) {
continue
}
}

content, err := os.ReadFile(filepath.Join(projectPath, log, "log.md"))
if err != nil {
// a log removed between listing and reading shouldn't sink the
// whole dump; skip it and return what we can
if os.IsNotExist(err) {
continue
}
return "", err
}
b.WriteString(strings.TrimRight(string(content), "\n"))
b.WriteString("\n\n")
}

return outputformatter.Format(format, b.String())
}

// dateOnly strips the time-of-day so since/until comparisons aren't affected
// by what time "3 days ago" etc. happened to resolve to.
func dateOnly(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}

// carryOverTodos reads the log before `before` and returns its unfinished todos.
func carryOverTodos(projectPath string, before time.Time) []string {
prev, err := file.PreviousLog(projectPath, file.NewLogProvider(), before)
Expand Down
103 changes: 103 additions & 0 deletions internal/daylog/daylog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,109 @@ func TestSearch(t *testing.T) {
}
}

func TestDump(t *testing.T) {
project := t.TempDir()
writePrevLog(t, project, "2025/12/01", "# 2025/12/01\n\n- ate a burrito\n")
writePrevLog(t, project, "2025/12/02", "# 2025/12/02\n\n- wrote tests\n")
writePrevLog(t, project, "2025/12/03", "# 2025/12/03\n\n- reviewed a PR\n")

date := func(s string) *time.Time {
t, err := time.Parse("2006/01/02", s)
if err != nil {
panic(err)
}
return &t
}

t.Run("default order is oldest first", func(t *testing.T) {
got, err := Dump(project, "text", false, nil, nil)
if err != nil {
t.Fatalf("Dump() error = %v", err)
}
want := "# 2025/12/01\n\n- ate a burrito\n" +
"# 2025/12/02\n\n- wrote tests\n" +
"# 2025/12/03\n\n- reviewed a PR\n"
if got != want {
t.Errorf("Dump() =\n%q\nwant\n%q", got, want)
}
})

t.Run("reverse gives newest first", func(t *testing.T) {
got, err := Dump(project, "text", true, nil, nil)
if err != nil {
t.Fatalf("Dump() error = %v", err)
}
want := "# 2025/12/03\n\n- reviewed a PR\n" +
"# 2025/12/02\n\n- wrote tests\n" +
"# 2025/12/01\n\n- ate a burrito\n"
if got != want {
t.Errorf("Dump() =\n%q\nwant\n%q", got, want)
}
})

t.Run("no logs returns empty string", func(t *testing.T) {
empty := t.TempDir()
got, err := Dump(empty, "text", false, nil, nil)
if err != nil {
t.Fatalf("Dump() error = %v", err)
}
if got != "" {
t.Errorf("Dump() = %q, want empty string", got)
}
})

t.Run("invalid format errors", func(t *testing.T) {
if _, err := Dump(project, "bogus", false, nil, nil); err == nil {
t.Error("Dump() error = nil, want error for invalid format")
}
})

t.Run("since excludes earlier logs", func(t *testing.T) {
got, err := Dump(project, "text", false, date("2025/12/02"), nil)
if err != nil {
t.Fatalf("Dump() error = %v", err)
}
want := "# 2025/12/02\n\n- wrote tests\n" +
"# 2025/12/03\n\n- reviewed a PR\n"
if got != want {
t.Errorf("Dump() =\n%q\nwant\n%q", got, want)
}
})

t.Run("until excludes later logs", func(t *testing.T) {
got, err := Dump(project, "text", false, nil, date("2025/12/02"))
if err != nil {
t.Fatalf("Dump() error = %v", err)
}
want := "# 2025/12/01\n\n- ate a burrito\n" +
"# 2025/12/02\n\n- wrote tests\n"
if got != want {
t.Errorf("Dump() =\n%q\nwant\n%q", got, want)
}
})

t.Run("since and until together are inclusive on both ends", func(t *testing.T) {
got, err := Dump(project, "text", false, date("2025/12/02"), date("2025/12/02"))
if err != nil {
t.Fatalf("Dump() error = %v", err)
}
want := "# 2025/12/02\n\n- wrote tests\n"
if got != want {
t.Errorf("Dump() =\n%q\nwant\n%q", got, want)
}
})

t.Run("range excluding everything returns empty string", func(t *testing.T) {
got, err := Dump(project, "text", false, date("2026/01/01"), nil)
if err != nil {
t.Fatalf("Dump() error = %v", err)
}
if got != "" {
t.Errorf("Dump() = %q, want empty string", got)
}
})
}

func strPtr(s string) *string { return &s }

func TestSanitizeProject(t *testing.T) {
Expand Down