From fce35f0fc772bc298da6facb49032b942c6450db Mon Sep 17 00:00:00 2001 From: Martin Pander Date: Tue, 21 May 2024 16:52:18 +0200 Subject: [PATCH] Style forms; [WIP] Draw table --- common/styles.go | 368 ++++++++++++++++++++++++++++++++++++- pages/contextPicker.go | 14 +- pages/report.go | 67 ++++--- pages/taskEditor.go | 3 +- taskwarrior/config.go | 4 + taskwarrior/models.go | 77 ++++++++ taskwarrior/taskwarrior.go | 19 ++ test/taskchampion.sqlite3 | Bin 49152 -> 53248 bytes test/taskrc | 10 +- 9 files changed, 517 insertions(+), 45 deletions(-) diff --git a/common/styles.go b/common/styles.go index e903bdc..e2f809f 100644 --- a/common/styles.go +++ b/common/styles.go @@ -1,6 +1,13 @@ package common import ( + "errors" + "log/slog" + "math" + "strconv" + "strings" + + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "tasksquire/taskwarrior" @@ -9,17 +16,360 @@ import ( type Styles struct { Main lipgloss.Style - ActiveTask lipgloss.Style - InactiveTask lipgloss.Style - NormalTask lipgloss.Style + Form *huh.Theme + + Active lipgloss.Style + Alternate lipgloss.Style + Blocked lipgloss.Style + Blocking lipgloss.Style + BurndownDone lipgloss.Style + BurndownPending lipgloss.Style + BurndownStarted lipgloss.Style + CalendarDue lipgloss.Style + CalendarDueToday lipgloss.Style + CalendarHoliday lipgloss.Style + CalendarOverdue lipgloss.Style + CalendarScheduled lipgloss.Style + CalendarToday lipgloss.Style + CalendarWeekend lipgloss.Style + CalendarWeeknumber lipgloss.Style + Completed lipgloss.Style + Debug lipgloss.Style + Deleted lipgloss.Style + Due lipgloss.Style + DueToday lipgloss.Style + Error lipgloss.Style + Footnote lipgloss.Style + Header lipgloss.Style + HistoryAdd lipgloss.Style + HistoryDelete lipgloss.Style + HistoryDone lipgloss.Style + Label lipgloss.Style + LabelSort lipgloss.Style + Overdue lipgloss.Style + ProjectNone lipgloss.Style + Recurring lipgloss.Style + Scheduled lipgloss.Style + SummaryBackground lipgloss.Style + SummaryBar lipgloss.Style + SyncAdded lipgloss.Style + SyncChanged lipgloss.Style + SyncRejected lipgloss.Style + TagNext lipgloss.Style + TagNone lipgloss.Style + Tagged lipgloss.Style + UdaPriorityH lipgloss.Style + UdaPriorityL lipgloss.Style + UdaPriorityM lipgloss.Style + UndoAfter lipgloss.Style + UndoBefore lipgloss.Style + Until lipgloss.Style + Warning lipgloss.Style } func NewStyles(config *taskwarrior.TWConfig) *Styles { - return &Styles{ - Main: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), + styles := parseColors(config.GetConfig()) + styles.Main = lipgloss.NewStyle() - ActiveTask: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("236")), - InactiveTask: lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Background(lipgloss.Color("236")), - NormalTask: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), - } + formTheme := huh.ThemeBase() + formTheme.Focused.Card = formTheme.Focused.Card.BorderStyle(lipgloss.RoundedBorder()).BorderBottom(true).BorderTop(true) + formTheme.Focused.Title = formTheme.Focused.Title.Bold(true) + formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("> ") + formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true) + formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("> ") + formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ") + formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ") + formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ") + formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true) + formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ") + formTheme.Blurred.SelectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("✓ ") + formTheme.Blurred.UnselectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("• ") + + styles.Form = formTheme + + return styles +} + +func parseColors(config map[string]string) *Styles { + styles := Styles{} + + for key, value := range config { + if strings.HasPrefix(key, "color.") { + if value != "" { + color := strings.Split(key, ".")[1] + switch color { + case "active": + styles.Active = parseColorString(value) + case "alternate": + styles.Alternate = parseColorString(value) + case "blocked": + styles.Blocked = parseColorString(value) + case "blocking": + styles.Blocking = parseColorString(value) + case "burndown.done": + styles.BurndownDone = parseColorString(value) + case "burndown.pending": + styles.BurndownPending = parseColorString(value) + case "burndown.started": + styles.BurndownStarted = parseColorString(value) + case "calendar.due": + styles.CalendarDue = parseColorString(value) + case "calendar.due.today": + styles.CalendarDueToday = parseColorString(value) + case "calendar.holiday": + styles.CalendarHoliday = parseColorString(value) + case "calendar.overdue": + styles.CalendarOverdue = parseColorString(value) + case "calendar.scheduled": + styles.CalendarScheduled = parseColorString(value) + case "calendar.today": + styles.CalendarToday = parseColorString(value) + case "calendar.weekend": + styles.CalendarWeekend = parseColorString(value) + case "calendar.weeknumber": + styles.CalendarWeeknumber = parseColorString(value) + case "completed": + styles.Completed = parseColorString(value) + case "debug": + styles.Debug = parseColorString(value) + case "deleted": + styles.Deleted = parseColorString(value) + case "due": + styles.Due = parseColorString(value) + case "due.today": + styles.DueToday = parseColorString(value) + case "error": + styles.Error = parseColorString(value) + case "footnote": + styles.Footnote = parseColorString(value) + case "header": + styles.Header = parseColorString(value) + case "history.add": + styles.HistoryAdd = parseColorString(value) + case "history.delete": + styles.HistoryDelete = parseColorString(value) + case "history.done": + styles.HistoryDone = parseColorString(value) + case "label": + styles.Label = parseColorString(value) + case "label.sort": + styles.LabelSort = parseColorString(value) + case "overdue": + styles.Overdue = parseColorString(value) + case "project.none": + styles.ProjectNone = parseColorString(value) + case "recurring": + styles.Recurring = parseColorString(value) + case "scheduled": + styles.Scheduled = parseColorString(value) + case "summary.background": + styles.SummaryBackground = parseColorString(value) + case "summary.bar": + styles.SummaryBar = parseColorString(value) + case "sync.added": + styles.SyncAdded = parseColorString(value) + case "sync.changed": + styles.SyncChanged = parseColorString(value) + case "sync.rejected": + styles.SyncRejected = parseColorString(value) + case "tag.next": + styles.TagNext = parseColorString(value) + case "tag.none": + styles.TagNone = parseColorString(value) + case "tagged": + styles.Tagged = parseColorString(value) + case "uda.priority.H": + styles.UdaPriorityH = parseColorString(value) + case "uda.priority.L": + styles.UdaPriorityL = parseColorString(value) + case "uda.priority.M": + styles.UdaPriorityM = parseColorString(value) + case "undo.after": + styles.UndoAfter = parseColorString(value) + case "undo.before": + styles.UndoBefore = parseColorString(value) + case "until": + styles.Until = parseColorString(value) + case "warning": + styles.Warning = parseColorString(value) + } + } + } + } + + return &styles +} + +func parseColorString(color string) lipgloss.Style { + style := lipgloss.NewStyle() + if strings.Contains(color, "on") { + fgbg := strings.Split(color, "on") + fg := strings.TrimSpace(fgbg[0]) + bg := strings.TrimSpace(fgbg[1]) + if fg != "" { + style = style.Foreground(parseColor(fg)) + } + if bg != "" { + style = style.Background(parseColor(bg)) + } + } else { + style = style.Foreground(parseColor(strings.TrimSpace(color))) + } + + return style +} + +func parseColor(color string) lipgloss.Color { + if strings.HasPrefix(color, "rgb") { + rgb, err := parseRGBString(strings.TrimPrefix(color, "rgb")) + if err != nil { + slog.Error("Invalid RGB color format") + return lipgloss.Color("0") + } + return lipgloss.Color(rgbToAnsi(rgb)) + } + if strings.HasPrefix(color, "color") { + return lipgloss.Color(strings.TrimPrefix(color, "color")) + } + if strings.HasPrefix(color, "gray") { + gray, err := strconv.Atoi(strings.TrimPrefix(color, "gray")) + if err != nil { + slog.Error("Invalid gray color format") + return lipgloss.Color("0") + } + return lipgloss.Color(strconv.Itoa(gray + 232)) + } + if ansi, okcolor := colorStrings[color]; okcolor { + return lipgloss.Color(strconv.Itoa(ansi)) + } + + slog.Error("Invalid color format") + return lipgloss.Color("0") +} + +type RGB struct { + r int + g int + b int +} + +func parseRGBString(rgbString string) (RGB, error) { + var err error + rgb := RGB{} + + if len(rgbString) != 3 { + return rgb, errors.New("invalid RGB format") + } + + rgb.r, err = strconv.Atoi(string(rgbString[0])) + if err != nil { + return rgb, errors.New("invalid value for R") + } + + rgb.g, err = strconv.Atoi(string(rgbString[1])) + if err != nil { + return rgb, errors.New("invalid value for G") + } + + rgb.b, err = strconv.Atoi(string(rgbString[2])) + if err != nil { + return rgb, errors.New("invalid value for B") + } + + return rgb, nil +} + +var colorStrings = map[string]int{ + "black": 0, + "red": 1, + "green": 2, + "yellow": 3, + "blue": 4, + "magenta": 5, + "cyan": 6, + "white": 7, + "bright black": 8, + "bright red": 9, + "bright green": 10, + "bright yellow": 11, + "bright blue": 12, + "bright magenta": 13, + "bright cyan": 14, + "bright white": 15, +} + +var baseColors = []RGB{ + {0, 0, 0}, // Black + {128, 0, 0}, // Red + {0, 128, 0}, // Green + {128, 128, 0}, // Yellow + {0, 0, 128}, // Blue + {128, 0, 128}, // Magenta + {0, 128, 128}, // Cyan + {192, 192, 192}, // White +} + +var highIntensityColors = []RGB{ + {128, 128, 128}, // Bright Black (Gray) + {255, 0, 0}, // Bright Red + {0, 255, 0}, // Bright Green + {255, 255, 0}, // Bright Yellow + {0, 0, 255}, // Bright Blue + {255, 0, 255}, // Bright Magenta + {0, 255, 255}, // Bright Cyan + {255, 255, 255}, // Bright White +} + +// Calculate the Euclidean distance between two colors +func colorDistance(c1, c2 RGB) float64 { + return math.Sqrt(float64((c1.r-c2.r)*(c1.r-c2.r) + (c1.g-c2.g)*(c1.g-c2.g) + (c1.b-c2.b)*(c1.b-c2.b))) +} + +// Convert RGB to the nearest ANSI color code +func rgbToAnsi(rgb RGB) string { + // Check standard and high-intensity colors + allColors := append(baseColors, highIntensityColors...) + bestIndex := 0 + minDist := colorDistance(rgb, allColors[0]) + + for i := 1; i < len(allColors); i++ { + dist := colorDistance(rgb, allColors[i]) + if dist < minDist { + bestIndex = i + minDist = dist + } + } + + if bestIndex < 8 { + return strconv.Itoa(bestIndex) + } else if bestIndex < 16 { + return strconv.Itoa(bestIndex + 8) + } + + // Check 6x6x6 color cube + for i := 0; i < 216; i++ { + cubeColor := RGB{ + (rgb.r / 51) * 51, + (rgb.g / 51) * 51, + (rgb.b / 51) * 51, + } + dist := colorDistance(rgb, cubeColor) + if dist < minDist { + bestIndex = i + 16 + minDist = dist + } + } + + // Check grayscale colors + for i := 0; i < 24; i++ { + gray := i*10 + 8 + grayColor := RGB{gray, gray, gray} + dist := colorDistance(rgb, grayColor) + if dist < minDist { + bestIndex = i + 232 + minDist = dist + } + } + + return strconv.Itoa(bestIndex) } diff --git a/pages/contextPicker.go b/pages/contextPicker.go index de15c4d..ef07278 100644 --- a/pages/contextPicker.go +++ b/pages/contextPicker.go @@ -35,9 +35,12 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage { selected := common.TW.GetActiveContext().Name options := make([]string, 0) for _, c := range p.contexts { - options = append(options, c.Name) + if c.Name != "none" { + options = append(options, c.Name) + } } slices.Sort(options) + options = append([]string{"(none)"}, options...) p.form = huh.NewForm( huh.NewGroup( @@ -50,7 +53,8 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage { ), ). WithShowHelp(false). - WithShowErrors(true) + WithShowErrors(true). + WithTheme(p.common.Styles.Form) return p } @@ -99,7 +103,11 @@ func (p *ContextPickerPage) View() string { } func (p *ContextPickerPage) updateContextCmd() tea.Msg { - return UpdateContextMsg(p.common.TW.GetContext(p.form.GetString("context"))) + context := p.form.GetString("context") + if context == "(none)" { + context = "none" + } + return UpdateContextMsg(p.common.TW.GetContext(context)) } type UpdateContextMsg *taskwarrior.Context diff --git a/pages/report.go b/pages/report.go index ddc8ce9..743d275 100644 --- a/pages/report.go +++ b/pages/report.go @@ -1,9 +1,6 @@ package pages import ( - "strconv" - "strings" - "tasksquire/common" "tasksquire/taskwarrior" @@ -43,13 +40,12 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage { s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). + BorderForeground(com.Styles.Active.GetForeground()). BorderBottom(true). - Bold(false) + Bold(true) s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Bold(false) + Reverse(true). + Bold(true) keys := ReportKeys{ Quit: key.NewBinding( @@ -91,6 +87,9 @@ func (p ReportPage) Init() tea.Cmd { func (p ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case tea.WindowSizeMsg: + p.taskTable.SetWidth(msg.Width - 2) + p.taskTable.SetHeight(msg.Height - 4) case BackMsg: p.subpageActive = false case TaskMsg: @@ -107,9 +106,6 @@ func (p ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, p.getTasks()) case AddedTaskMsg: cmds = append(cmds, p.getTasks()) - case tea.WindowSizeMsg: - p.taskTable.SetWidth(msg.Width - 2) - p.taskTable.SetHeight(msg.Height - 4) case tea.KeyMsg: switch { case key.Matches(msg, p.common.Keymap.Quit): @@ -157,24 +153,39 @@ func (p ReportPage) View() string { } func (p *ReportPage) populateTaskTable(tasks []*taskwarrior.Task) { - columns := []table.Column{ - {Title: "ID", Width: 4}, - {Title: "Project", Width: 10}, - {Title: "Tags", Width: 10}, - {Title: "Prio", Width: 2}, - {Title: "Due", Width: 10}, - {Title: "Task", Width: 50}, + nCols := len(p.activeReport.Columns) + columns := make([]table.Column, 0) + columnSizes := make([]int, nCols) + fullRows := make([]table.Row, len(tasks)) + rows := make([]table.Row, len(tasks)) + + for i, task := range tasks { + row := table.Row{} + for i, col := range p.activeReport.Columns { + field := task.Get(col) + columnSizes[i] = max(columnSizes[i], len(field)) + row = append(row, field) + } + fullRows[i] = row } - var rows []table.Row - for _, task := range tasks { - rows = append(rows, table.Row{ - strconv.FormatInt(task.Id, 10), - task.Project, - strings.Join(task.Tags, ", "), - task.Priority, - task.Due, - task.Description, - }) + + for i, r := range fullRows { + row := table.Row{} + for j, size := range columnSizes { + if size == 0 { + continue + } + row = append(row, r[j]) + } + rows[i] = row + } + + for i, label := range p.activeReport.Labels { + if columnSizes[i] == 0 { + continue + } + + columns = append(columns, table.Column{Title: label, Width: max(columnSizes[i], len(label))}) } p.taskTable = table.New( diff --git a/pages/taskEditor.go b/pages/taskEditor.go index 6ff0480..1253724 100644 --- a/pages/taskEditor.go +++ b/pages/taskEditor.go @@ -85,7 +85,8 @@ func NewTaskEditorPage(common *common.Common, task taskwarrior.Task) *TaskEditor ), ). WithShowHelp(false). - WithShowErrors(false) + WithShowErrors(false). + WithTheme(p.common.Styles.Form) return p } diff --git a/taskwarrior/config.go b/taskwarrior/config.go index 29686d0..b20aabd 100644 --- a/taskwarrior/config.go +++ b/taskwarrior/config.go @@ -16,6 +16,10 @@ func NewConfig(config []string) *TWConfig { } } +func (tc *TWConfig) GetConfig() map[string]string { + return tc.config +} + func (tc *TWConfig) Get(key string) string { if _, ok := tc.config[key]; !ok { slog.Debug(fmt.Sprintf("Key not found in config: %s", key)) diff --git a/taskwarrior/models.go b/taskwarrior/models.go index 51cd4cf..4b8e48d 100644 --- a/taskwarrior/models.go +++ b/taskwarrior/models.go @@ -1,5 +1,13 @@ package taskwarrior +import ( + "fmt" + "log/slog" + "strconv" + "strings" + "time" +) + type Task struct { Id int64 `json:"id"` Uuid string `json:"uuid"` @@ -8,13 +16,69 @@ type Task struct { Priority string `json:"priority"` Status string `json:"status"` Tags []string `json:"tags"` + Depends []string `json:"depends"` Urgency float32 `json:"urgency"` Due string `json:"due"` Wait string `json:"wait"` Scheduled string `json:"scheduled"` + Until string `json:"until"` + Recur string `json:"recur"` + Start string `json:"start"` End string `json:"end"` Entry string `json:"entry"` Modified string `json:"modified"` + Parent string `json:"parent"` +} + +func (t *Task) Get(field string) string { + switch field { + case "id": + return strconv.FormatInt(t.Id, 10) + case "uuid": + return t.Uuid + case "description": + return t.Description + case "project": + return t.Project + case "priority": + return t.Priority + case "status": + return t.Status + case "tags": + return strings.Join(t.Tags, ", ") + case "urgency": + return fmt.Sprintf("%.2f", t.Urgency) + case "due": + return t.Due + case "wait": + return t.Wait + case "scheduled": + return t.Scheduled + case "end": + return t.End + case "entry": + return t.Entry + case "modified": + return t.Modified + // TODO: implement these fields + case "start.age": + return formatTime(t.Start) + case "depends": + return strings.Join(t.Depends, ", ") + case "entry.age": + return formatTime(t.Entry) + case "scheduled.countdown": + return formatTime(t.Scheduled) + case "until.remaining": + return formatTime(t.Until) + case "due.relative": + return formatTime(t.Due) + case "recur": + return t.Recur + default: + slog.Error(fmt.Sprintf("Field not implemented: %s", field)) + return "" + } } type Tasks []*Task @@ -39,3 +103,16 @@ type Report struct { } type Reports map[string]*Report + +func formatTime(timeStr string) string { + if timeStr == "" { + return "" + } + format := "20060102T150405Z" + t, err := time.Parse(format, timeStr) + if err != nil { + slog.Error("Failed to parse time:", err) + return timeStr + } + return t.Format("2006-01-02 15:04") +} diff --git a/taskwarrior/taskwarrior.go b/taskwarrior/taskwarrior.go index 4542faf..4006b10 100644 --- a/taskwarrior/taskwarrior.go +++ b/taskwarrior/taskwarrior.go @@ -16,6 +16,22 @@ const ( ) var ( + reportBlacklist = map[string]struct{}{ + "burndown.daily": {}, + "burndown.monthly": {}, + "burndown.weekly": {}, + "calendar": {}, + "colors": {}, + "export": {}, + "ghistory.annual": {}, + "ghistory.monthly": {}, + "history.annual": {}, + "history.monthly": {}, + "information": {}, + "summary": {}, + "timesheet": {}, + } + tagBlacklist = map[string]struct{}{ "ACTIVE": {}, "ANNOTATED": {}, @@ -330,6 +346,9 @@ func (ts *TaskSquire) extractReports() Reports { reports := make(Reports) for _, report := range availableReports { + if _, ok := reportBlacklist[report]; ok { + continue + } reports[report] = &Report{ Name: report, Description: ts.config.Get(fmt.Sprintf("report.%s.description", report)), diff --git a/test/taskchampion.sqlite3 b/test/taskchampion.sqlite3 index 209fe04973075966532196cf2ee50ba202466bae..df7e68ae1ead418c3557058980746e4d1bb76178 100644 GIT binary patch delta 3474 zcmbVOU2Icj7(Q)J*KV}uus;TDqgyGCzisDy=jS`8W)n2Tcr}7H5Sd|J+nLJNm9}Gy zG1!@KE1Ms?(#R^iz+i}@F(5bSmGKG_FVqVYxlj{BBu$83c%koiPRrV^lDaHewy%Ak z_xYap=lSO69rG)W#hrC5Vi?A|)HP^l(b~u=zm?@5!wKW%|K|VX^Zesn%hn%wC!Ba@ zN#57WK4JJLxklj^zLB>%y6uz6SSp-}PbSjL;vK=(<#X+3JnTHf-Qjqz;rVvU<%aJX zy!8S1F@D~8pPgSbMp)MZo?%0m50LI`AUf-x2&Yf`gZ^IsLH|@D6Ca1aMXZW~CJTZ9 zhv`f>GnIzZR4g);O2rdz!(l2jlZ?R`q>oQV;}JND#?p~gJW0vG2kB^h>>cwod5U!V z_H+gP=jgvvG)lzqqa&>A);D5_Xqx0(_U|!M&@W8KVyEG=3BVqU$D&q@DhZ;lDP(bY z+sNF)*fbluQXf$jF)X53@95|#>XpNCq&JMlMte0ZYgiZ^#fl`r9XH}&Gxu}p^u!C= zP#I#0Olqc-hYW|wa0<@JuIzqt?L_mAs`^D^mI6mC1uCytUA7JS&Us$6^@lFE(xM%n zwQ5l!RMjFqU+jY$a{NaF6fYnaN?5hdRTZX{a?cq<$Jx-rN%HW1AUAj7D+gXSxL4Sa zQ9l8{hH5Kdk%PC$AzA2|y}5_Oilf8BwJ_XO{tDl~J>tLThq*Cc;%{>;+&yj}*Vy&c zyKk=}WUD3DG#N;och1nSlkkl|?&n0nX2JvPZAdT-z>oxhuX1DD0M}x{_jnvks22R( zz{Rgc$1WyIT%UD!xcqfV=QqwToGx~RJ?i+#{+InX`wO-Rv&LkXjJMjjSqHP*A4)_g zk50xD8Nbngo@Evy#Mab8=2t}W&!_F}FcpfbLN}fbj~Dxyj*6x#e>*0NAdQUw9&k{LR94nl3k zP_|H>9&&A)co3B)RI#e7k|fe7Zo1+!fwSa6XB)Y+qU=j$-~yB5(Nh(dP$ay5v&7W+ zI1#V6S-g-M6x2Rhz>+8{NHcl$PgRwt6ohUcDi3Hz&)oRZ({7LQ;&gC8TeC}EGM7Crberk~G-*f-! ze%1A1-H&yubCLa>ec7>U&#xH|>~5pq*jmBQY<1mS+!Z9LD?lx~tj?2Ys|yGzSV4-_ zuR9GM!1QMgOBXv?+SXy~rEwjlaRLTok}xt$7ce-gFRo>>sUM`MA!n#hRIkQ5L%C1a zMNL+rdCg3I!wU>w1BT6kTC$Pk-Jvoxj8$1ya1qT@L{n=)E9Xv~fn;xXx{LN+E)q~z z1W}Z9WRA7rGq%u@Z$c!~454K^S!(#@C4?*z#ux_7y3$KV1MD0J6=)NgOs6)dwmWO? zwleJy%sf_AD^ZV858Az6dnJ|{Rk{bDGdJiYBA7W*w3?~iaFhIv&FEx4M9h8_DpP2> zuF4|(6#CUg`?aeRsBW$t!*A3Td#N_urJDlz@|Ld*5hGcI zR@Y6IdkiNa4%M#k;>X9z&=g%0G^J=`23zp4X6^s?`bO6)%Ooo*La;|n-h+mtV0+D* zn5t5fY%iE3tSY%rLs%098JXe?8f4A4{ZLI80ZTP(73+7O!GN~ML0kGlII;tt=!vNH zXfG~J>mg58mfH&kkAjL0>qwWu<0WjZqf3RFo9_&^>PBd{)}|E=t4veD?agT4MbiT= ztsv*d5id)M1+0r;N@iI993CLHXI{^2a4P0^Fj?^4-Z8*z|XT%?iDnI*I_GR4x&(ljY4QMcX~ z$hDrVxKKr>S}8X_B{MBEHATrv$p$kbd(M=3S0q^J@_a58U%oz8cJ-jFS@=t89Ld zr@{!<0#v#Vs3``h7^o4byq_-(s%5in-El)^0d}Lw>>Cs}Hgd5uH_GQuHk>Lx`Q3J- J&2`(GH~