Files
m3ta-chironandClaude Fable 5.1 85fa64cfe6 refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:15:35 +02:00

110 lines
3.2 KiB
Go

package queue
import (
"fmt"
"strconv"
"strings"
"time"
)
// Allowed values for quiet_mode (public config value).
const (
QuietModeDiscard = "discard"
QuietModeToast = "toast"
)
// clockTime is a time of day at minute resolution (local time, 24 h).
type clockTime struct{ hour, minute int }
// minutes returns minutes since midnight.
func (c clockTime) minutes() int { return c.hour*60 + c.minute }
// String renders the time as "HH:MM".
func (c clockTime) String() string { return fmt.Sprintf("%02d:%02d", c.hour, c.minute) }
// QuietHours is a quiet window in local time, for example "18:00-08:00"
// (spanning midnight). The zero value (Start == End) is disabled; silence
// around the clock is mute, not quiet hours.
type QuietHours struct {
Start, End clockTime
}
// String renders the window as "HH:MM-HH:MM" (zero value: "").
func (q QuietHours) String() string {
if q.Start == q.End {
return ""
}
return q.Start.String() + "-" + q.End.String()
}
// ParseQuietHours parses a quiet window "HH:MM-HH:MM" (local time, 24 h;
// hours with one or two digits). Windows across midnight (Start > End) are
// allowed; Start == End disables the window. The empty string is the
// disabled window (no error).
func ParseQuietHours(s string) (QuietHours, error) {
if s == "" {
return QuietHours{}, nil
}
startS, endS, ok := strings.Cut(s, "-")
if !ok {
return QuietHours{}, fmt.Errorf("quiet_hours: %q is not HH:MM-HH:MM", s)
}
start, err := parseClock(startS)
if err != nil {
return QuietHours{}, fmt.Errorf("quiet_hours: %v", err)
}
end, err := parseClock(endS)
if err != nil {
return QuietHours{}, fmt.Errorf("quiet_hours: %v", err)
}
return QuietHours{Start: start, End: end}, nil
}
// parseClock parses "H[H]:MM" into its parts.
func parseClock(s string) (clockTime, error) {
hourS, minuteS, ok := strings.Cut(s, ":")
if !ok || len(hourS) < 1 || len(hourS) > 2 || len(minuteS) != 2 {
return clockTime{}, fmt.Errorf("%q is not HH:MM", s)
}
hour, err := strconv.Atoi(hourS)
if err != nil {
return clockTime{}, fmt.Errorf("%q is not HH:MM", s)
}
minute, err := strconv.Atoi(minuteS)
if err != nil {
return clockTime{}, fmt.Errorf("%q is not HH:MM", s)
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return clockTime{}, fmt.Errorf("%q is not a valid time of day (24 h)", s)
}
return clockTime{hour: hour, minute: minute}, nil
}
// Active reports whether the local time t lies inside the window. Day
// windows (Start < End) run from Start up to but excluding End; night
// windows (Start > End) span midnight.
func (q QuietHours) Active(t time.Time) bool {
if q.Start == q.End {
return false
}
m := t.Hour()*60 + t.Minute()
s, e := q.Start.minutes(), q.End.minutes()
if s < e {
return m >= s && m < e
}
return m >= s || m < e
}
// Until returns the local end of the currently active window; the zero time
// when no window is active. Basis for GET /status.
func (q QuietHours) Until(t time.Time) time.Time {
if !q.Active(t) {
return time.Time{}
}
end := time.Date(t.Year(), t.Month(), t.Day(), q.End.hour, q.End.minute, 0, 0, t.Location())
if q.Start.minutes() > q.End.minutes() && t.Hour()*60+t.Minute() >= q.Start.minutes() {
end = end.AddDate(0, 0, 1) // night window: the end is tomorrow
}
return end
}