summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-04 00:01:44 +0200
committerPaul Buetow <paul@buetow.org>2026-03-04 00:01:44 +0200
commit10d344d74bbeb9dcd5523efec6f2c79519bc05a4 (patch)
treee232d7aab0b297de47edd6f674ebd3487ecb38fa
parentf33b95c7a26a9ac131719baaff391a0cdedb5072 (diff)
worktime: extract report import parsing from cli
-rw-r--r--internal/cli/work.go151
-rw-r--r--internal/cli/work_test.go23
-rw-r--r--internal/worktime/import.go153
-rw-r--r--internal/worktime/import_test.go76
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])
+ }
+}