package main import ( "bufio" "bytes" "embed" "encoding/json" "flag" "fmt" "iter" "log" "log/slog" "maps" "os" "path/filepath" "regexp" "slices" "strconv" "strings" "sync" "text/template" "time" ) var ( fl_project = flag.String("pr", "project.json", "project-file") fl_timelogs = flag.String("lt", "", "directory to search for timelogs") fl_plandir = flag.String("pl", "", "directory to search for plans") fl_templates = flag.String("tm", "", "directory to search for the templates") fl_report = flag.String("rp", "report.md", "default report template") fl_today = flag.String("today", time.Now().Format(time.DateOnly), "what is the date today") ) type Project struct { Name string Grant string Start Date End Date Beneficiary string Users map[string]User Timelogs string Planning string Workpackages map[string]Workpackage Holidays map[Date]string Blocked map[Date]string Timeline Timeline Planned Timeline Today Date } type Timeline map[Date]Entries type Entries []Entry func (en Entries) Total() Amount { a := Amount(0.0) for _, e := range en { a += e.Amount } return a } func (en Entries) WPs() string { wps := []string{} for _, e := range en { wps = append(wps, e.WP) } wps = slices.Compact(wps) return strings.Join(wps, ", ") } func (en Entries) FilterWP(name string) Entries { r := Entries{} for _, e := range en { if name == e.WP { r = append(r, e) } } return r } func (en Entries) FilterTask(task string) Entries { r := Entries{} for _, e := range en { if task == e.Task { r = append(r, e) } } return r } func (en Entries) FilterWPTask(wp, task string) Entries { r := Entries{} for _, e := range en { if wp == e.WP && task == e.Task { r = append(r, e) } } return r } func (en Entries) Totals(ymt ...int) Amount { var a Amount for _, e := range en { a += e.Amount } return a } func (t Timeline) FilterDates(rang ...Date) Entries { r := Entries{} for d, e := range t { // TODO: sorted!? if len(rang) > 0 && d.Before(rang[0]) { continue } if len(rang) > 1 && d.After(rang[1]) { continue } r = append(r, e...) } return r } func (t Timeline) FilterWPTask(wp, task string) Entries { r := Entries{} for _, e := range t { r = append(r, e.FilterWPTask(wp, task)...) } return r } func (t Timeline) FilterWP(name string) Entries { r := Entries{} for _, entries := range t { r = append(r, entries.FilterWP(name)...) } return r } type User struct { Name string Type string Blocked map[Date]string } type Workpackage struct { Title string Short string StartMonth uint EndMonth uint TotalTime Amount Tasks map[string]Task } func (wp *Workpackage) TotalBudget() Amount { var a Amount for _, t := range wp.Tasks { a += t.Budget } return a } type Date time.Time func (d Date) IsWorkday() bool { wd := (time.Time(d)).Weekday() return wd > time.Sunday && wd < time.Saturday } func (d *Date) UnmarshalText(b []byte) error { t, e := time.Parse(time.DateOnly, string(b)) if e != nil { return e } *d = Date(t) return e } func (d Date) String() string { return (time.Time(d)).Format(time.DateOnly) } func (d Date) MarshalJSON() ([]byte, error) { return []byte(d.String()), nil } func (d Date) Year() int { return (time.Time(d)).Year() } func (d Date) Month() int { return int((time.Time(d)).Month()) } func (d Date) Day() int { return (time.Time(d)).Day() } func (a Date) After(b Date) bool { return (time.Time(a)).After(time.Time(b)) } func (a Date) Before(b Date) bool { return (time.Time(a)).Before(time.Time(b)) } type Amount float64 const ( DaysInYear = 216 DaysInMonth = DaysInYear / 12 HoursInDay = 8 ) func (u Amount) String() string { if u*u < 1 { return "0" } r := "" d := int(u / HoursInDay) m := d / DaysInMonth h := int(u) - d*HoursInDay if m != 0 { r = fmt.Sprintf("%dM", m) d -= DaysInMonth * m if d < 0 { d = -d } if h < 0 { h = -h } } if d != 0 { r += fmt.Sprintf("%dd", d) if h < 0 { h = -h } } if h != 0 { r += fmt.Sprintf("%dh", h) } return r } func (b Amount) AsHour() string { return fmt.Sprintf("%5.2fh", b) } func (b Amount) AsDay() string { return fmt.Sprintf("%5.2fd", b/HoursInDay) } func (b Amount) AsMonth() string { return fmt.Sprintf("%5.2fm", b/HoursInDay/DaysInMonth) } func (u *Amount) UnmarshalText(b []byte) error { return u.parseString(string(b)) } func (u *Amount) parseString(s string) error { f, e := strconv.ParseFloat(s[:len(s)-1], 64) if e != nil { return fmt.Errorf("not a float: %v", s) } *u = Amount(f) switch s[len(s)-1] { case 'm', 'M': *u *= DaysInMonth fallthrough case 'd', 'D': *u *= HoursInDay fallthrough case 'h', 'H': default: return fmt.Errorf("unknown unit in: %v", s) } return nil } type Task struct { ID string Title string Budget Amount } type Entry struct { User string WP string Task string Amount Amount Comment string } type Deliverable struct { ID string Title string Description string } func main() { flag.Parse() today, e := time.Parse(time.DateOnly, *fl_today) if e != nil { log.Fatalf("couldn't parse today parameter: %v", e) } p, e := loadProject(*fl_project) if e != nil { log.Fatalf("error loading project: %v", e) } if p.Start.After(p.End) { log.Fatalf("start '%s' after end '%s'", p.Start, p.End) } p.Today = Date(today) if p.Timeline == nil { p.Timeline = make(Timeline) } if p.Planned == nil { p.Planned = make(Timeline) } e = p.readPlans(*fl_plandir) if e != nil { log.Fatalf("error loading plans: %v", e) } e = p.readTimelogs(*fl_timelogs) if e != nil { log.Fatalf("error loading timelog: %v", e) } p.printReport(*fl_report) } var min2w = regexp.MustCompile("[\t ]+") func loadProject(f string) (p *Project, e error) { fd, e := os.Open(f) if e != nil { return nil, e } defer fd.Close() p = &Project{} d := json.NewDecoder(fd) d.DisallowUnknownFields() e = d.Decode(p) return p, e } func (p *Project) readPlans(dir string) error { if len(dir) == 0 { dir = p.Planning } glob := filepath.Join(dir, "*.plan") matches, e := filepath.Glob(glob) if e != nil { return fmt.Errorf("filepath.Glob(%q): %w", glob, e) } if len(matches) == 0 { slog.Warn("no plans found:", "glob", glob) } for _, name := range matches { user := strings.Split(filepath.Base(name), ".")[0] if _, ok := p.Users[user]; !ok { return fmt.Errorf("file %q doesn't belong to a user name", name) } f, e := os.Open(name) if e != nil { return fmt.Errorf("os.Open(%q) failed: %w", name, e) } defer f.Close() scanner := bufio.NewScanner(f) nr := 0 last := time.Time{} for scanner.Scan() { nr += 1 // # Ignore line // # Tab-separated fields // 2024-02 wp2:short 1m [comment] // 2024-02-11 wp2:short 1d [comment] // 2024-02-11..2024-02-23 wp2:short *8h [comment] // 2024-02-11..2024-02-23 wp2:short 3d [comment] var line = scanner.Text() if strings.HasPrefix(line, "#") || "" == strings.TrimSpace(line) { continue } parts := min2w.Split(scanner.Text(), 4) var ismonth bool var isrange bool var r [2]time.Time var e error r[0], e = time.Parse(time.DateOnly, parts[0]) if e == nil { r[1] = r[0].AddDate(0, 0, 1) } else { r[0], e = time.Parse("2006-01", parts[0]) if e == nil { slog.Debug("Month found", "date", parts[0]) r[1] = r[0].AddDate(0, 1, 0) ismonth = true } else { // try xxxx-yy-zz..xxxx-yy-zz ts := strings.Split(parts[0], "..") if len(ts) != 2 { return fmt.Errorf("file %s:%d: couldn't parse date: %w", name, nr, e) } for i := range r { r[i], e = time.Parse(time.DateOnly, ts[i]) if e != nil { return fmt.Errorf("file %s:%d: invalid range: %s", name, nr, parts[0]) } } if r[0].Equal(r[1]) || r[0].After(r[1]) { return fmt.Errorf("file %s:%d: invalid range: %s", name, nr, parts[0]) } // r[1] shall mark the first day _after_ the range r[1] = r[1].AddDate(0, 0, 1) slog.Debug("Found range", "start", r[0], "end", r[1]) isrange = true } } if last.After(r[0]) || last.After(r[1]) { return fmt.Errorf("file %s:%d: dates not monotonic: %s before %s", name, nr, parts[0], Date(last)) } if !isrange && !ismonth { if last.After(r[0]) { return fmt.Errorf("file %s:%d: dates not monotonic, %s older", name, nr, Date(r[0])) } if !Date(r[0]).IsWorkday() { return fmt.Errorf("file %s:%d: date is a weekend: %v", name, nr, r[0]) } if h, found := p.Holidays[Date(r[0])]; found { return fmt.Errorf("file %s:%d: date %v is a holiday: %s", name, nr, Date(r[0]), h) } } else { // TODO: check range soundness } // Remember the last beginning date. last = r[0] if len(parts) < 3 { // Acceptible date entry, but no further content? Fine, just ignore and move on. continue } wps := strings.Split(parts[1], ":") if len(wps) < 2 { return fmt.Errorf("file %s:%d: no such workpackage:task %q", name, nr, parts[1]) } if wp, ok := p.Workpackages[wps[0]]; !ok { return fmt.Errorf("file %s:%d: no such workpackage %q", name, nr, wps[0]) } else if _, ok := wp.Tasks[wps[1]]; !ok { return fmt.Errorf("file %s:%d: no such task %q in %q", name, nr, wps[1], wps[0]) } var amount Amount var days Amount e = (&amount).parseString(parts[2]) // TODO: parse "*8h" etc. if e != nil { return fmt.Errorf("file %s:%d: not acceptible budget: %q", name, nr, parts[2]) } for d := r[0]; !d.Equal(r[1]); d = d.AddDate(0, 0, 1) { if !Date(d).IsWorkday() || p.IsHoliday(Date(d)) { continue } days += HoursInDay a := amount if amount-HoursInDay > 0 { a = HoursInDay amount -= a } else { a = amount amount = 0 } comment := "" if len(parts) > 3 { comment = parts[3] } if a > 0 { entry := Entry{ User: user, WP: wps[0], Task: wps[1], Amount: a, Comment: comment} entries := p.Planned[Date(d)] entries = append(entries, entry) p.Planned[Date(d)] = entries } } if amount != 0 { slog.Info("oops", "amount", amount) return fmt.Errorf("file %s:%d: too many hours allocated (%q), maximum %s possible, rest: %5.2f", name, nr, parts[2], days, amount) } } } return nil } func (p *Project) readTimelogs(dir string) error { if len(dir) == 0 { dir = p.Timelogs } glob := filepath.Join(dir, "*.timelog") matches, e := filepath.Glob(glob) if e != nil { return fmt.Errorf("filepath.Glob(%q): %w", glob, e) } if len(matches) == 0 { slog.Warn("no timelogs found:", "glob", glob) } for _, name := range matches { var year uint16 var user string { s := strings.Replace(filepath.Base(name), ".", " ", 2) n, e := fmt.Sscanf(s, "%d %s timelog", &year, &user) if e != nil { return fmt.Errorf("fmt.Sscanf(%q): %w", s, e) } else if n != 2 { return fmt.Errorf("file %q not in the format ..timelog", name) } } if _, ok := p.Users[user]; !ok { return fmt.Errorf("Unknown user: %v", user) } if int(year) < p.Start.Year() || p.End.Year() < int(year) { return fmt.Errorf("year out of range: %d (not in %d-%d)", year, p.Start.Year(), p.End.Year()) } f, e := os.Open(name) if e != nil { return fmt.Errorf("os.Open(%q) failed: %w", name, e) } defer f.Close() scanner := bufio.NewScanner(f) nr := 0 last := time.Time{} for scanner.Scan() { nr += 1 // # Ignore line // # Tab-separated fields // 2024-12-11 wp2:short 8h [comment] var line = scanner.Text() if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" { continue } parts := min2w.Split(scanner.Text(), 4) d, e := time.Parse(time.DateOnly, parts[0]) if e != nil { return fmt.Errorf("file %s:%d: couldn't parse date: %w", name, nr, e) } if d.Year() != int(year) { return fmt.Errorf("file %s:%d: wrong year: %d", name, nr, d.Year()) } if last.After(d) { return fmt.Errorf("file %s:%d: dates not monotonic, %s older", name, nr, Date(d)) } last = d if !Date(d).IsWorkday() { return fmt.Errorf("file %s:%d: date is a weekend: %v", name, nr, d) } if h, found := p.Holidays[Date(d)]; found { return fmt.Errorf("file %s:%d: date %v is a holiday: %s", name, nr, Date(d), h) } if len(parts) < 3 { // Acceptible date entry, but no further content? Fine, just ignore. continue } wps := strings.Split(parts[1], ":") if len(wps) < 2 { return fmt.Errorf("file %s:%d: no such workpackage:task %q", name, nr, parts[1]) } if wp, ok := p.Workpackages[wps[0]]; !ok { return fmt.Errorf("file %s:%d: no such workpackage %q", name, nr, wps[0]) } else if _, ok := wp.Tasks[wps[1]]; !ok { return fmt.Errorf("file %s:%d: no such task %q in %q", name, nr, wps[1], wps[0]) } var amount Amount e = (&amount).parseString(parts[2]) if e != nil { return fmt.Errorf("file %s:%d: not acceptible budget: %q", name, nr, parts[2]) } entry := Entry{ User: user, WP: wps[0], Task: wps[1], Amount: amount} entries := p.Timeline[Date(d)] entries = append(entries, entry) p.Timeline[Date(d)] = entries } } return nil } //go:embed templates/*.md var embedFS embed.FS var once sync.Once var templates *template.Template var funcs = template.FuncMap{ "ParseDate": func(in string) (Date, error) { d, e := time.Parse(time.DateOnly, in) return Date(d), e }, } func (p *Project) printReport(name string) { var e error once.Do(func() { templates = template.New("templates/").Funcs(funcs) if *fl_templates != "" { templates, e = templates.ParseGlob(*fl_templates + "/*.md") } else { templates, e = templates.ParseFS(embedFS, "templates/*.md") } if e != nil { log.Fatalf("Couldn't parse templates: %v\n", e) } }) if nil == templates.Lookup(name) { log.Fatalf("Couldn't find template %q\n", name) } e = templates.ExecuteTemplate(os.Stdout, name, p) if e != nil { log.Fatalf("Couldn't execute template %q: %v\n", name, e) } } type FullDate struct { Date Planned Entries Worked Entries IsWorkday bool Holiday string Blocked string } func (fd *FullDate) IsWeekend() bool { return !fd.IsWorkday } func (p *Project) IsHoliday(d Date) bool { _, ok := p.Holidays[d] return ok } func (p *Project) IsBlocked(d Date) bool { _, ok := p.Blocked[d] return ok } func (p *Project) IsBlockedForUser(d Date, u string) bool { user, ok := p.Users[u] if !ok { return false } _, ok = user.Blocked[d] return ok } func (p *Project) IsBlockedForAnyUser(d Date) bool { for _, user := range p.Users { if _, ok := user.Blocked[d]; ok { return true } } return false } func (f *FullDate) Difference() Amount { return f.Planned.Total() - f.Worked.Total() } func (p *Project) Days(ymd ...int) []FullDate { return p.days(false, ymd...) } func (p *Project) Workdays(ymd ...int) []FullDate { return p.days(true, ymd...) } func (p *Project) AsDaysAmount(i int) Amount { return Amount(i * HoursInDay) } func (p *Project) days(workonly bool, ymd ...int) []FullDate { dates := []FullDate{} start := time.Time(p.Start) end := time.Time(p.End) if len(ymd) > 0 { year := ymd[0] month := -1 day := -1 if len(ymd) > 1 { month = ymd[1] } if len(ymd) > 2 { day = ymd[2] } if month < 0 && day < 0 { s := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC) e := s.AddDate(1, 0, 0) if s.Before(start) { slog.Error("year out of range", "year", year) return nil } start = s if e.Before(end) { end = e } } else if day < 0 { s := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) e := s.AddDate(0, 1, 0) if s.Before(start) { slog.Error("year and month out of range", "year", year, "month", month, "start", start, "s", s) return nil } start = s if e.Before(end) { end = e } } else { s := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) if s.Before(start) { slog.Error("year, month, day out of range", "year", year, "month", month, "day", day) return nil } start = s } } for d := start; !d.Equal(end); d = d.AddDate(0, 0, 1) { d := Date(d) if workonly { if !d.IsWorkday() { continue } if _, ok := p.Holidays[d]; ok { continue } if _, ok := p.Blocked[d]; ok { continue } } dates = append(dates, FullDate{ Date: d, Planned: p.Planned[d], Worked: p.Timeline[d], IsWorkday: d.IsWorkday(), Holiday: p.Holidays[d], Blocked: p.Blocked[d], }) } return dates } type FullDates []FullDate func (f *FullDates) FilterUser(user string) *FullDates { // TODO return f } func (p *Project) monthCal(year, month int, user ...string) []string { /* 2 9 16 23 30 3 10 17 24 31 4 11 18 25 5 12 19 26 6 13 20 27 7 14 21 28 1 8 15 22 29 */ const cw = 4 const wd = 6 * cw const sp = " " userOK := func(en Entries) bool { if len(user) == 0 { return true } for _, u := range user { for _, e := range en { if e.User == u { return true } } } return false } lines := [7]string{} m := time.Month(month) date := time.Date(year, m, 1, 0, 0, 0, 0, time.UTC) skip := 0 for i := 0; i < 42; i++ { d := int((date.Weekday() + 6) % 7) if i < d { lines[i] += sp skip += 1 continue } if date.Month() != m { lines[d] += sp } else { l := 2 if i-skip > 6 { l = 3 } str := fmt.Sprintf("%[1]*d", l, i+1-skip) suf := " " if _, ok := p.Holidays[Date(date)]; ok { str = fmt.Sprintf("%[1]*s", l, "☺") } else if _, ok := p.Blocked[Date(date)]; ok { suf = "¬" } else if ev, ok := p.Timeline[Date(date)]; ok && userOK(ev) { suf = "•" } else if ev, ok := p.Planned[Date(date)]; ok && userOK(ev) { suf = "¤" //"¶" } lines[d] += str + suf } date = date.AddDate(0, 0, 1) } return lines[:] } func (p *Project) MonthCalendar(year, month int) string { lines := []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} c := p.monthCal(year, month) h := fmt.Sprintf(" %-[1]*s %4d\n", len(bytes.Runes([]byte(c[0])))-9, time.Month(month), year) for i, pre := range lines { lines[i] = pre + " " + c[i] } return h + strings.Join(lines, "\n") } func (p *Project) QuaterYearCalendar(year, quarter int, user ...string) string { return "not implemented" } func (p *Project) YearCalendar(year int, user ...string) string { t := [12][]string{} for i := range 12 { t[i] = p.monthCal(year, i+1, user...) } wd := len(bytes.Runes([]byte(t[0][0]))) lines := []string{} pre := []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} for q := range 4 { header := "" for m := range 3 { header += fmt.Sprintf(" %-[1]*s", wd-2, time.Month(q*3+m+1)) } lines = append(lines, header) for w, line := range pre { for m := range 3 { line += " " + t[q*3+m][w] } lines = append(lines, line) } lines = append(lines, "") } s := fmt.Sprintf("%[1]*d\n", 3*wd/2+2, year) return s + strings.Join(lines, "\n") } func (p *Project) Calendars(user ...string) map[int]string { r := make(map[int]string) for y := p.Start.Year(); y <= p.End.Year(); y++ { r[y] = p.YearCalendar(y, user...) } return r } type Totals struct { Budgeted Amount Planned Amount Done Amount } type WPTotals struct { Totals Tasks map[string]*Totals } func (t *Totals) Unaccounted() Amount { d := t.Budgeted - t.Planned // TODO: rounding OK!? if d*d < 1 { return 0 } return d } func (t *Totals) Open() Amount { d := t.Budgeted - t.Done // TODO: rounding OK!? if d*d < 1 { return 0 } return d } func (t *Totals) Percent() float64 { if t.Done < 1 && t.Planned < 1 { return 0 } if t.Planned < 1 { t.Planned = 1 } return float64(t.Done/t.Planned) * 100 } func (p *Project) TotalWPTask(wp, task string, rang ...Date) (to *Totals, e error) { w, ok := p.Workpackages[wp] if !ok { return nil, fmt.Errorf("no such workpackage handle: %q", wp) } t, ok := w.Tasks[task] if !ok { return nil, fmt.Errorf("no such task in workpackage %q: %q", wp, task) } to = &Totals{ Budgeted: t.Budget, Planned: p.Planned.FilterDates(rang...).FilterWPTask(wp, task).Total(), Done: p.Timeline.FilterDates(rang...).FilterWPTask(wp, task).Total(), } return to, nil } func (p *Project) TotalWP(name string, rang ...Date) (tot *WPTotals, e error) { wp, ok := p.Workpackages[name] if !ok { return nil, fmt.Errorf("no such workpackage handle: %q", name) } tot = &WPTotals{ Totals: Totals{ Budgeted: wp.TotalBudget(), Planned: p.Planned.FilterDates(rang...).FilterWP(name).Total(), Done: p.Timeline.FilterDates(rang...).FilterWP(name).Total(), }, Tasks: make(map[string]*Totals), } for tn, t := range wp.SortedTasks() { tot.Tasks[tn] = &Totals{ Budgeted: t.Budget, Planned: p.Planned.FilterWPTask(name, tn).Total(), Done: p.Timeline.FilterWPTask(name, tn).Total(), } } return tot, nil } type AllTotals struct { Totals WP map[string]*WPTotals } func (p *Project) Totals(rang ...Date) (at *AllTotals) { at = &AllTotals{ WP: make(map[string]*WPTotals), } for n := range p.Workpackages { t, _ := p.TotalWP(n, rang...) at.WP[n] = t at.Totals.Budgeted += t.Budgeted at.Totals.Planned += t.Planned at.Totals.Done += t.Done } return at } func (t *AllTotals) SortedWP() iter.Seq2[string, *WPTotals] { return func(yield func(k string, v *WPTotals) bool) { for _, k := range slices.Sorted(maps.Keys(t.WP)) { v := t.WP[k] if !yield(k, v) { break } } } } func renderParallel(content ...[]string) []string { maxh := 0 maxw := make([]int, len(content)) for i, c := range content { h := len(c) if maxh < h { maxh = h } for _, l := range c { w := len(l) if maxw[i] < w { maxw[i] = w } } } output := []string{} for r := range maxh { parts := []string{} for c := range len(content) { var snip string if r < len(content[c]) { snip = content[c][r] } parts = append(parts, fmt.Sprintf("%-[1]*s", maxw[c], snip)) } output = append(output, strings.Join(parts, " | ")) } return output } func (p *Project) ParallelWPTotals(tmpl ...string) ([]string, error) { tm := "wp-total.md" if len(tmpl) > 0 { tm = tmpl[0] } if nil == templates.Lookup(tm) { return nil, fmt.Errorf("template %q not found", tm) } totals := p.Totals() content := [][]string{} for _, name := range slices.Sorted(maps.Keys(totals.WP)) { wpt := totals.WP[name] buf := &bytes.Buffer{} data := struct { WP string *WPTotals }{name, wpt} e := templates.ExecuteTemplate(buf, tm, data) if e != nil { return nil, fmt.Errorf("while rendering totals for %s with template %s: %w", name, tm, e) } content = append(content, strings.Split(buf.String(), "\n")) } return renderParallel(content...), nil } func (p *Project) DaysRemaining() []FullDate { return p.Workdays(p.Today.Year(), p.Today.Month(), p.Today.Day()) } func (p *Project) SortedWorkpackages() iter.Seq2[string, Workpackage] { return func(yield func(k string, v Workpackage) bool) { for _, k := range slices.Sorted(maps.Keys(p.Workpackages)) { v := p.Workpackages[k] if !yield(k, v) { break } } } } func (wp *Workpackage) SortedTasks() iter.Seq2[string, Task] { return func(yield func(k string, v Task) bool) { for _, k := range slices.Sorted(maps.Keys(wp.Tasks)) { v := wp.Tasks[k] if !yield(k, v) { break } } } }