diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-04 00:01:44 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-04 00:01:44 +0200 |
| commit | 10d344d74bbeb9dcd5523efec6f2c79519bc05a4 (patch) | |
| tree | e232d7aab0b297de47edd6f674ebd3487ecb38fa | |
| parent | f33b95c7a26a9ac131719baaff391a0cdedb5072 (diff) | |
worktime: extract report import parsing from cli
| -rw-r--r-- | internal/cli/work.go | 151 | ||||
| -rw-r--r-- | internal/cli/work_test.go | 23 | ||||
| -rw-r--r-- | internal/worktime/import.go | 153 | ||||
| -rw-r--r-- | internal/worktime/import_test.go | 76 |
4 files changed, 245 insertions, 158 deletions
diff --git a/internal/cli/work.go b/internal/cli/work.go index ce96115..775a0d7 100644 --- a/internal/cli/work.go +++ b/internal/cli/work.go @@ -1,9 +1,9 @@ package cli import ( - "bufio" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -350,11 +350,23 @@ func newWorkImportCmd() *cobra.Command { return err } - imported, err := importReportFile(ctx, args[0]) + file, err := os.Open(args[0]) if err != nil { return err } + imported, err := importReportFile(ctx, file) + closeErr := file.Close() + if err != nil { + if closeErr != nil { + return errors.Join(err, fmt.Errorf("close import file %q: %w", args[0], closeErr)) + } + return err + } + if closeErr != nil { + return fmt.Errorf("close import file %q: %w", args[0], closeErr) + } + return printOutput(cmd, fmt.Sprintf("Imported %d entries.", imported)) }, } @@ -411,139 +423,8 @@ func activeCategories(entries []worktime.Entry) []string { return active } -func importReportFile(ctx workContext, path string) (imported int, err error) { - file, err := os.Open(path) - if err != nil { - return 0, err - } - defer func() { - closeErr := file.Close() - if closeErr == nil { - return - } - wrappedCloseErr := fmt.Errorf("close import file %q: %w", path, closeErr) - if err == nil { - err = wrappedCloseErr - return - } - err = errors.Join(err, wrappedCloseErr) - }() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - if !strings.Contains(line, "lunch:") { - continue - } - - when, workHours, lunchHours, offHours, err := parseImportLine(line) - if err != nil { - return imported, err - } - - workSeconds := int64(workHours * float64(time.Hour/time.Second)) - lunchSeconds := int64(lunchHours * float64(time.Hour/time.Second)) - offSeconds := int64(offHours * float64(time.Hour/time.Second)) - - if lunchSeconds > 0 { - workSeconds += lunchSeconds - } - - if workSeconds > 0 { - if _, err := worktime.Add(ctx.dbDir, ctx.host, "work", time.Duration(workSeconds)*time.Second, when, ""); err != nil { - return imported, err - } - imported++ - } - if lunchSeconds > 0 { - if _, err := worktime.Add(ctx.dbDir, ctx.host, "lunch", time.Duration(lunchSeconds)*time.Second, when, ""); err != nil { - return imported, err - } - imported++ - } - if offSeconds > 0 { - if _, err := worktime.Add(ctx.dbDir, ctx.host, "off", time.Duration(offSeconds)*time.Second, when, ""); err != nil { - return imported, err - } - imported++ - } - } - - if err := scanner.Err(); err != nil { - return imported, err - } - - return imported, nil -} - -func parseImportLine(line string) (time.Time, float64, float64, float64, error) { - fields := strings.Fields(line) - if len(fields) < 7 { - return time.Time{}, 0, 0, 0, fmt.Errorf("unsupported import line: %q", line) - } - - dateToken := strings.TrimSuffix(fields[1], ":") - workToken := fields[2] - lunchToken := fields[4] - offToken := fields[6] - - when, err := parseImportDate(dateToken) - if err != nil { - return time.Time{}, 0, 0, 0, err - } - - workHours, err := parseHourToken(workToken) - if err != nil { - return time.Time{}, 0, 0, 0, err - } - lunchHours, err := parseHourToken(lunchToken) - if err != nil { - return time.Time{}, 0, 0, 0, err - } - offHours, err := parseHourToken(offToken) - if err != nil { - return time.Time{}, 0, 0, 0, err - } - - return when, workHours, lunchHours, offHours, nil -} - -func parseHourToken(token string) (float64, error) { - clean := strings.TrimSpace(token) - if idx := strings.Index(clean, ":"); idx >= 0 { - clean = clean[idx+1:] - } - clean = strings.TrimSuffix(clean, "h") - - value, err := strconv.ParseFloat(clean, 64) - if err != nil { - return 0, fmt.Errorf("parse hour token %q: %w", token, err) - } - return value, nil -} - -func parseImportDate(token string) (time.Time, error) { - trimmed := strings.TrimSpace(token) - if trimmed == "" { - return time.Time{}, errors.New("import date is empty") - } - - parsed, parseErr := timefmt.Parse(trimmed) - if parseErr == nil { - return parsed, nil - } - - layouts := []string{ - "02.01.2006", - "20060102", - } - for _, layout := range layouts { - if parsed, err := time.ParseInLocation(layout, trimmed, time.Local); err == nil { - return parsed, nil - } - } - - return time.Time{}, fmt.Errorf("unsupported import date %q: %w", token, parseErr) +func importReportFile(ctx workContext, report io.Reader) (int, error) { + return worktime.ImportReport(ctx.dbDir, ctx.host, report) } func workDBPath(dbDir, host string) string { diff --git a/internal/cli/work_test.go b/internal/cli/work_test.go index 2be0231..9621bfb 100644 --- a/internal/cli/work_test.go +++ b/internal/cli/work_test.go @@ -7,7 +7,6 @@ import ( "strconv" "strings" "testing" - "time" timrTimer "codeberg.org/snonux/timr/internal/timer" ) @@ -109,28 +108,6 @@ func TestWorkLoginLogoutWithTimerFlags(t *testing.T) { } } -func TestParseImportDateFallbackLayout(t *testing.T) { - parsed, err := parseImportDate("06.01.2026") - if err != nil { - t.Fatalf("parseImportDate() error = %v", err) - } - - expected := time.Date(2026, 1, 6, 0, 0, 0, 0, time.Local) - if parsed.Year() != expected.Year() || parsed.Month() != expected.Month() || parsed.Day() != expected.Day() { - t.Fatalf("parsed date = %v, want %v", parsed, expected) - } -} - -func TestParseImportDateIncludesUnderlyingParseError(t *testing.T) { - _, err := parseImportDate("not-a-date") - if err == nil { - t.Fatal("parseImportDate() error = nil, want error") - } - if !strings.Contains(err.Error(), "unsupported import date") { - t.Fatalf("parseImportDate() error = %v, want unsupported import date context", err) - } -} - func writeWorkConfig(t *testing.T, dbDir, host string) string { return writeWorkConfigWithAuto(t, dbDir, host, false) } diff --git a/internal/worktime/import.go b/internal/worktime/import.go new file mode 100644 index 0000000..d84becd --- /dev/null +++ b/internal/worktime/import.go @@ -0,0 +1,153 @@ +package worktime + +import ( + "bufio" + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" + + "codeberg.org/snonux/timr/internal/timefmt" +) + +const importSecondsPerHour = 3600 + +// ImportLine is a parsed report line with hourly values by category. +type ImportLine struct { + When time.Time + WorkHours float64 + LunchHours float64 + OffHours float64 +} + +// ImportReport imports report-format lines into the worktime database. +func ImportReport(dbDir, hostname string, report io.Reader) (imported int, err error) { + scanner := bufio.NewScanner(report) + for scanner.Scan() { + line := scanner.Text() + if !strings.Contains(line, "lunch:") { + continue + } + + parsed, parseErr := ParseImportLine(line) + if parseErr != nil { + return imported, parseErr + } + + workSeconds := hoursToSeconds(parsed.WorkHours) + lunchSeconds := hoursToSeconds(parsed.LunchHours) + offSeconds := hoursToSeconds(parsed.OffHours) + + if lunchSeconds > 0 { + workSeconds += lunchSeconds + } + + if workSeconds > 0 { + if _, addErr := Add(dbDir, hostname, "work", time.Duration(workSeconds)*time.Second, parsed.When, ""); addErr != nil { + return imported, addErr + } + imported++ + } + if lunchSeconds > 0 { + if _, addErr := Add(dbDir, hostname, "lunch", time.Duration(lunchSeconds)*time.Second, parsed.When, ""); addErr != nil { + return imported, addErr + } + imported++ + } + if offSeconds > 0 { + if _, addErr := Add(dbDir, hostname, "off", time.Duration(offSeconds)*time.Second, parsed.When, ""); addErr != nil { + return imported, addErr + } + imported++ + } + } + + if scanErr := scanner.Err(); scanErr != nil { + return imported, scanErr + } + + return imported, nil +} + +// ParseImportLine parses one report-format day line. +func ParseImportLine(line string) (ImportLine, error) { + fields := strings.Fields(line) + if len(fields) < 7 { + return ImportLine{}, fmt.Errorf("unsupported import line: %q", line) + } + + dateToken := strings.TrimSuffix(fields[1], ":") + workToken := fields[2] + lunchToken := fields[4] + offToken := fields[6] + + when, err := ParseImportDate(dateToken) + if err != nil { + return ImportLine{}, err + } + + workHours, err := parseHourToken(workToken) + if err != nil { + return ImportLine{}, err + } + lunchHours, err := parseHourToken(lunchToken) + if err != nil { + return ImportLine{}, err + } + offHours, err := parseHourToken(offToken) + if err != nil { + return ImportLine{}, err + } + + return ImportLine{ + When: when, + WorkHours: workHours, + LunchHours: lunchHours, + OffHours: offHours, + }, nil +} + +// ParseImportDate parses date values used by work report imports. +func ParseImportDate(token string) (time.Time, error) { + trimmed := strings.TrimSpace(token) + if trimmed == "" { + return time.Time{}, errors.New("import date is empty") + } + + parsed, parseErr := timefmt.Parse(trimmed) + if parseErr == nil { + return parsed, nil + } + + layouts := []string{ + "02.01.2006", + "20060102", + } + for _, layout := range layouts { + if parsed, err := time.ParseInLocation(layout, trimmed, time.Local); err == nil { + return parsed, nil + } + } + + return time.Time{}, fmt.Errorf("unsupported import date %q: %w", token, parseErr) +} + +func parseHourToken(token string) (float64, error) { + clean := strings.TrimSpace(token) + if idx := strings.Index(clean, ":"); idx >= 0 { + clean = clean[idx+1:] + } + clean = strings.TrimSuffix(clean, "h") + + value, err := strconv.ParseFloat(clean, 64) + if err != nil { + return 0, fmt.Errorf("parse hour token %q: %w", token, err) + } + return value, nil +} + +func hoursToSeconds(hours float64) int64 { + return int64(hours * importSecondsPerHour) +} diff --git a/internal/worktime/import_test.go b/internal/worktime/import_test.go new file mode 100644 index 0000000..0a317e0 --- /dev/null +++ b/internal/worktime/import_test.go @@ -0,0 +1,76 @@ +package worktime + +import ( + "strings" + "testing" + "time" +) + +func TestParseImportDateFallbackLayout(t *testing.T) { + parsed, err := ParseImportDate("06.01.2026") + if err != nil { + t.Fatalf("ParseImportDate() error = %v", err) + } + + expected := time.Date(2026, 1, 6, 0, 0, 0, 0, time.Local) + if parsed.Year() != expected.Year() || parsed.Month() != expected.Month() || parsed.Day() != expected.Day() { + t.Fatalf("parsed date = %v, want %v", parsed, expected) + } +} + +func TestParseImportDateIncludesUnderlyingParseError(t *testing.T) { + _, err := ParseImportDate("not-a-date") + if err == nil { + t.Fatal("ParseImportDate() error = nil, want error") + } + if !strings.Contains(err.Error(), "unsupported import date") { + t.Fatalf("ParseImportDate() error = %v, want unsupported import date context", err) + } +} + +func TestParseImportLine(t *testing.T) { + line := "Mon 06.01.2026: +8.00h lunch: +0.50h off: +1.00h" + parsed, err := ParseImportLine(line) + if err != nil { + t.Fatalf("ParseImportLine() error = %v", err) + } + + if parsed.WorkHours != 8 { + t.Fatalf("WorkHours = %v, want 8", parsed.WorkHours) + } + if parsed.LunchHours != 0.5 { + t.Fatalf("LunchHours = %v, want 0.5", parsed.LunchHours) + } + if parsed.OffHours != 1 { + t.Fatalf("OffHours = %v, want 1", parsed.OffHours) + } +} + +func TestImportReport(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + report := strings.NewReader("Mon 06.01.2026: +8.00h lunch: +0.50h off: +0.00h\n") + + imported, err := ImportReport(dbDir, host, report) + if err != nil { + t.Fatalf("ImportReport() error = %v", err) + } + if imported != 2 { + t.Fatalf("imported = %d, want 2", imported) + } + + db, err := LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } + entries := db.Entries[host] + if len(entries) != 2 { + t.Fatalf("entries len = %d, want 2", len(entries)) + } + if entries[0].What != "work" || entries[0].Value != 30600 { + t.Fatalf("work entry = %+v, want 8.5h in seconds", entries[0]) + } + if entries[1].What != "lunch" || entries[1].Value != 1800 { + t.Fatalf("lunch entry = %+v, want 0.5h in seconds", entries[1]) + } +} |
