Kaynağa Gözat

cli: configurable date formats (chatto + CLI), ISO for JSON/MCP; tests & docs

Paul Klumpp 2 gün önce
ebeveyn
işleme
94790ce16c

+ 38 - 0
.env.example

@@ -0,0 +1,38 @@
+# .env in the project root (godotenv). Never commit the real .env file.
+CHATTO_TOKEN="<token>"               # set by the operator
+# CHATTO_BASE_URL=https://chat.chatto.run   # optional, is the default
+
+# Date format for chatto-send output. Default: 02.01.2006 (unambiguous).
+# Accepts Go time layouts (reference time Mon Jan 2 15:04:05 MST 2006)
+# OR common placeholders (dd, MM, yyyy, HH, mm, ss, ...):
+# Go layouts:
+#   02.01.2006          -> 31.12.2026
+#   2006-01-02          -> 2026-12-31
+#   02.01.2006 15:04    -> 31.12.2026 23:59
+#   January 2, 2006     -> December 31, 2026
+# Placeholders:
+#   dd.MM.yyyy          -> 31.12.2026
+#   yyyy-MM-dd          -> 2026-12-31
+#   dd.MM.yyyy HH:mm    -> 31.12.2026 23:59
+# Reference: https://pkg.go.dev/time#Time.Format
+# CHEAPO_CHATTO_OUTPUT_DATE_FORMAT=2006-01-02
+# CHEAPO_CHATTO_OUTPUT_DATE_FORMAT=dd.MM.yyyy
+# CHEAPO_CHATTO_OUTPUT_DATE_FORMAT=yyyy-MM-dd
+CHEAPO_CHATTO_OUTPUT_DATE_FORMAT=02.01.2006
+
+# Date format for CLI 'Free until:' output. Default: RFC3339 (ISO, unambiguous).
+# JSON output and MCP always use ISO-8601 regardless of this setting.
+# Accepts Go time layouts OR common placeholders (dd, MM, yyyy, HH, mm, ss, ...).
+# Go layouts:
+#   2006-01-02                      -> 2026-12-31
+#   2006-01-02T15:04:05Z07:00      -> 2026-12-31T23:59:59Z
+# Placeholders:
+#   dd.MM.yyyy                      -> 31.12.2026
+#   yyyy-MM-dd                      -> 2026-12-31
+#   dd.MM.yyyy HH:mm                -> 31.12.2026 23:59
+# Default (ISO-8601 / RFC3339): 2006-01-02T15:04:05Z07:00
+# CHEAPO_CLI_OUTPUT_DATE_FORMAT=2006-01-02
+# CHEAPO_CLI_OUTPUT_DATE_FORMAT=2006-01-02T15:04:05Z07:00
+# CHEAPO_CLI_OUTPUT_DATE_FORMAT=dd.MM.yyyy
+# CHEAPO_CLI_OUTPUT_DATE_FORMAT=yyyy-MM-dd
+# CHEAPO_CLI_OUTPUT_DATE_FORMAT=dd.MM.yyyy HH:mm

+ 9 - 0
AGENTS.md

@@ -96,6 +96,15 @@ How it gets the info:
   `internal/mcpserver`.
 - User-facing strings in English (CLI output, help texts, error messages),
   code comments in English.
+- **Unambiguous date outputs:** prefer ISO-8601 (RFC-3339 / `2006-01-02`)
+  for machine-readable/structured surfaces (CLI `--json`, MCP `offer` wire type);
+  compact locale-style formats (e.g. `02.01.`) are only for human-facing chat
+  messages and must be overridable via `CHEAPO_CHATTO_OUTPUT_DATE_FORMAT`. The CLI's human-
+  readable "Free until:" line honors `CHEAPO_CLI_OUTPUT_DATE_FORMAT` (default
+  RFC-3339). Avoid ambiguous `DD.MM.`/`MM/DD` outputs without a year.
+  Both `CHEAPO_CLI_OUTPUT_DATE_FORMAT` and `CHEAPO_CHATTO_OUTPUT_DATE_FORMAT`
+  accept either a Go time layout (`02.01.2006`) or common placeholders
+  (`dd.MM.yyyy`); see `.env.example`.
 - Wrap errors with context: `fmt.Errorf("epic: ...: %w", err)`.
 - New store platforms (Prime, GOG, …) follow the provider pattern (above).
 - **Chatto client:** `internal/chatto` is a hand-built Connect-JSON client

+ 5 - 0
README.md

@@ -161,6 +161,11 @@ CLI commands (credentials deliberately provided by the operator, never fetched a
 # .env in the project root (godotenv), never commit it — it is in .gitignore:
 CHATTO_TOKEN="<token>"               # set by the operator
 # CHATTO_BASE_URL=https://chat.chatto.run   # optional, is the default
+#
+# Date format for CLI 'Free until:' / chatto-send output (optional):
+#   CHEAPO_CLI_OUTPUT_DATE_FORMAT=dd.MM.yyyy
+#   CHEAPO_CHATTO_OUTPUT_DATE_FORMAT=dd.MM.yyyy
+# Accepts Go layouts (02.01.2006) or placeholders (dd, MM, yyyy, HH, mm, ss).
 
 cheapo chatto-rooms                     # list rooms (client verification)
 cheapo chatto-send --room <room_id>     # post current free games

+ 7 - 4
cmd/cheapo/chatto.go

@@ -64,19 +64,22 @@ func formatGames(games []game.Game, mode string) string {
 	b.WriteString("| Game | Store | Price | Period |\n")
 	b.WriteString("|---|---|---|---|\n")
 	for _, g := range games {
+		dateFmt := parseDateLayout(os.Getenv("CHEAPO_CHATTO_OUTPUT_DATE_FORMAT"))
+		if dateFmt == "" {
+			dateFmt = "02.01.2006"
+		}
 		period := "—"
 		if g.Kind == "upcoming" && !g.StartDate.IsZero() && !g.EndDate.IsZero() {
 			period = fmt.Sprintf("from %s, until %s",
-				g.StartDate.Local().Format("02.01."),
-				g.EndDate.Local().Format("02.01."))
+				g.StartDate.Local().Format(dateFmt),
+				g.EndDate.Local().Format(dateFmt))
 		} else if !g.EndDate.IsZero() {
-			period = "until " + g.EndDate.Local().Format("02.01.")
+			period = "until " + g.EndDate.Local().Format(dateFmt)
 		}
 		fmt.Fprintf(&b, "| **%s** | [%s](%s) | %s | %s |\n",
 			escapePipe(g.Title), g.Store, g.URL, priceCell(g), period)
 	}
 
-	b.WriteString("\n_As of now — free from/until 17:00 Europe/Berlin each._")
 	return b.String()
 }
 

+ 35 - 0
cmd/cheapo/chatto_test.go

@@ -0,0 +1,35 @@
+package main
+
+import (
+	"strings"
+	"testing"
+	"time"
+
+	"gogs.netdome.biz/paul/cheapo/internal/game"
+)
+
+func mustParse(s string) time.Time {
+	tt, err := time.Parse(time.RFC3339, s)
+	if err != nil {
+		panic(err)
+	}
+	return tt
+}
+
+func TestFormatGamesDefaultDate(t *testing.T) {
+	t.Setenv("CHEAPO_CHATTO_OUTPUT_DATE_FORMAT", "")
+	games := []game.Game{{Title: "G", Store: "epic", URL: "u", Kind: "free-now", EndDate: mustParse("2026-11-02T00:00:00Z")}}
+	out := formatGames(games, "")
+	if !strings.Contains(out, "until 02.11.2026") {
+		t.Fatalf("expected compact date, got:\n%s", out)
+	}
+}
+
+func TestFormatGamesCustomDate(t *testing.T) {
+	t.Setenv("CHEAPO_CHATTO_OUTPUT_DATE_FORMAT", "2006-01-02")
+	games := []game.Game{{Title: "G", Store: "epic", URL: "u", Kind: "free-now", EndDate: mustParse("2026-11-02T00:00:00Z")}}
+	out := formatGames(games, "")
+	if !strings.Contains(out, "until 2026-11-02") {
+		t.Fatalf("expected ISO date, got:\n%s", out)
+	}
+}

+ 86 - 0
cmd/cheapo/datefmt.go

@@ -0,0 +1,86 @@
+package main
+
+import (
+	"strings"
+	"time"
+)
+
+// refTime is the canonical reference instant used to validate Go time layouts.
+var refTime = time.Date(2006, 1, 2, 15, 4, 5, 0, time.UTC)
+
+// parseDateLayout accepts either a Go time layout (e.g. "02.01.2006",
+// "2006-01-02") or a common placeholder format (e.g. "dd.MM.yyyy",
+// "yyyy-MM-dd HH:mm") and returns a Go time layout. An empty input yields
+// "" so the caller can apply its own default. Values that are neither a
+// valid Go layout nor a recognized placeholder string are returned
+// unchanged (the caller's Format call will then produce whatever Go does).
+func parseDateLayout(s string) string {
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return ""
+	}
+	// If it already behaves like a Go layout, keep it as-is.
+	if isGoLayout(s) {
+		return s
+	}
+	return placeholdersToGoLayout(s)
+}
+
+// isGoLayout reports whether s is a usable Go time layout by round-tripping
+// a reference instant through Format and Parse.
+func isGoLayout(s string) bool {
+	formatted := refTime.Format(s)
+	parsed, err := time.Parse(s, formatted)
+	if err != nil {
+		return false
+	}
+	return parsed.UTC().Equal(refTime)
+}
+
+// placeholdersToGoLayout converts common date/time placeholders into the
+// equivalent Go time layout. Tokens are matched case-sensitively, longest
+// first, so that "MM" (month) is not confused with "mm" (minute).
+func placeholdersToGoLayout(s string) string {
+	// Ordered longest-first to avoid partial token matches.
+	replacements := []struct{ from, to string }{
+		{"yyyy", "2006"},
+		{"MMMM", "January"},
+		{"MMM", "Jan"},
+		{"MM", "01"},
+		{"dd", "02"},
+		{"HH", "15"},
+		{"mm", "04"},
+		{"ss", "05"},
+		{"yy", "06"},
+		{"ZZZ", "MST"},
+		{"ZZ", "Z07:00"},
+		{"Z", "Z07:00"},
+		{"zzz", "MST"},
+		{"zz", "Z07:00"},
+		{"z", "MST"},
+		{"M", "1"},
+		{"d", "2"},
+		{"H", "3"},
+		{"m", "4"},
+		{"s", "5"},
+	}
+	// Walk the string, consuming the longest matching token at each position.
+	var b strings.Builder
+	i := 0
+	for i < len(s) {
+		matched := false
+		for _, r := range replacements {
+			if strings.HasPrefix(s[i:], r.from) {
+				b.WriteString(r.to)
+				i += len(r.from)
+				matched = true
+				break
+			}
+		}
+		if !matched {
+			b.WriteByte(s[i])
+			i++
+		}
+	}
+	return b.String()
+}

+ 37 - 0
cmd/cheapo/datefmt_test.go

@@ -0,0 +1,37 @@
+package main
+
+import (
+	"testing"
+)
+
+func TestParseDateLayoutEmpty(t *testing.T) {
+	if got := parseDateLayout(""); got != "" {
+		t.Fatalf("expected empty, got %q", got)
+	}
+	if got := parseDateLayout("   "); got != "" {
+		t.Fatalf("expected empty, got %q", got)
+	}
+}
+
+func TestParseDateLayoutGoPassThrough(t *testing.T) {
+	cases := []string{"02.01.2006", "2006-01-02", "2006-01-02T15:04:05Z07:00", "02.01.2006 15:04"}
+	for _, c := range cases {
+		if got := parseDateLayout(c); got != c {
+			t.Errorf("Go layout %q changed to %q", c, got)
+		}
+	}
+}
+
+func TestParseDateLayoutPlaceholders(t *testing.T) {
+	cases := []struct{ in, want string }{
+		{"dd.MM.yyyy", "02.01.2006"},
+		{"yyyy-MM-dd", "2006-01-02"},
+		{"dd.MM.yyyy HH:mm", "02.01.2006 15:04"},
+		{"dd.MM.yyyy HH:mm:ss", "02.01.2006 15:04:05"},
+	}
+	for _, c := range cases {
+		if got := parseDateLayout(c.in); got != c.want {
+			t.Errorf("parseDateLayout(%q) = %q, want %q", c.in, got, c.want)
+		}
+	}
+}

+ 13 - 3
cmd/cheapo/main.go

@@ -30,6 +30,11 @@ var (
 )
 
 func main() {
+	// Load .env (godotenv) so values like CHEAPO_CLI_OUTPUT_DATE_FORMAT and
+	// CHEAPO_MCP_TOKEN apply to every command, not just mcp/chatto-*.
+	// Real environment variables take precedence over .env values.
+	loadEnv()
+
 	root := &cobra.Command{
 		Use:   "cheapo",
 		Short: "cheapo — free games lister (Go edition)",
@@ -41,8 +46,8 @@ References: https://github.com/vogler/free-games-claimer
 		SilenceUsage: true,
 	}
 
-	root.PersistentFlags().StringVar(&flagCountry, "country", "DE", "ISO country code for store region (epic)")
-	root.PersistentFlags().StringVar(&flagLocale, "locale", "de", "store locale (e.g. de, en-US)")
+	root.PersistentFlags().StringVar(&flagCountry, "country", "DE", "ISO country code for store region; determines currency/availability (epic only)")
+	root.PersistentFlags().StringVar(&flagLocale, "locale", "de", "display locale for Epic (formatting only, e.g. de -> '22,39 €', en-US -> '€22.39'; currency comes from --country)")
 	root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON")
 	root.PersistentFlags().StringVar(&flagStore, "store", "all", "which store(s): epic, steam, gog, all")
 
@@ -113,7 +118,12 @@ func printGames(cmd *cobra.Command, games []game.Game) error {
 				g.StartDate.Local().Format("02.01.2006 15:04"),
 				g.EndDate.Local().Format("02.01.2006 15:04"))
 		} else if !g.EndDate.IsZero() {
-			fmt.Fprintf(out, "   Free until: %s\n", g.EndDate.Local().Format("02.01.2006 15:04 (MST)"))
+			fmtStr := parseDateLayout(os.Getenv("CHEAPO_CLI_OUTPUT_DATE_FORMAT"))
+			if fmtStr == "" {
+				fmtStr = time.RFC3339
+			}
+			s := g.EndDate.UTC().Format(fmtStr)
+			fmt.Fprintf(out, "   Free until: %s\n", s)
 		}
 		fmt.Fprintf(out, "   %s\n", g.URL)
 		fmt.Fprintln(out)

+ 2 - 1
go.mod

@@ -17,7 +17,8 @@ require (
 	github.com/spf13/pflag v1.0.9 // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
 	golang.org/x/oauth2 v0.35.0 // indirect
-	golang.org/x/sync v0.20.0 // indirect
+	golang.org/x/sync v0.24.0 // indirect
 	golang.org/x/sys v0.49.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
+	golang.org/x/tools v0.51.0 // indirect
 )

+ 4 - 4
go.sum

@@ -27,12 +27,12 @@ golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
 golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
 golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
 golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
+golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
 golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
 golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
 golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
 golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
-golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
-golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
+golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

+ 1 - 1
internal/mcpserver/mcpserver.go

@@ -217,7 +217,7 @@ type listArgs struct {
 	Mode    string `json:"mode,omitempty" jsonschema:"what to list: free (current giveaways, default), upcoming (announced future Epic giveaways) or deals (discounted games)"`
 	Store   string `json:"store,omitempty" jsonschema:"which store: epic, steam, gog or all (default all)"`
 	Country string `json:"country,omitempty" jsonschema:"ISO country code for the Epic store region, e.g. DE (default DE)"`
-	Locale  string `json:"locale,omitempty" jsonschema:"store locale, e.g. de or en-US (default de)"`
+	Locale  string `json:"locale,omitempty" jsonschema:"Epic display locale (formatting only, e.g. de -> '22,39 €', en-US -> '€22.39'; currency comes from country)"`
 }
 
 // offer is the wire form of game.Game. It exists because game.Game marshals