feat(office): mail preview, infinite scroll, pane resize, and scrollbars
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 38s
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 38s
Polish the office TUI with proportional pane layout, drag resize, live filtering, HTML mail preview, paginated mail lists, overflow scrollbars, flattened navigation, and project save/status fixes; add UpdateProjectStatus. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TaskStatusLabel returns a human-readable project/CRM task status.
|
||||
func TaskStatusLabel(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
if label := taskStatusFromString(s); label != "" {
|
||||
return label
|
||||
}
|
||||
}
|
||||
n := intRawVal(map[string]any{"status": v}, "status")
|
||||
switch n {
|
||||
case 0:
|
||||
return "Not accepted"
|
||||
case 1:
|
||||
return "Open"
|
||||
case 2:
|
||||
return "Closed"
|
||||
case 3:
|
||||
return "Disabled"
|
||||
case 4:
|
||||
return "Unclassified"
|
||||
case 5:
|
||||
return "Not in milestone"
|
||||
default:
|
||||
if s := fmt.Sprint(v); s != "" && s != "<nil>" {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func taskStatusFromString(s string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "open":
|
||||
return "Open"
|
||||
case "closed":
|
||||
return "Closed"
|
||||
case "notaccept", "not accept", "not accepted":
|
||||
return "Not accepted"
|
||||
case "disable", "disabled":
|
||||
return "Disabled"
|
||||
case "unclassified":
|
||||
return "Unclassified"
|
||||
case "notinmilestone", "not in milestone":
|
||||
return "Not in milestone"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// FormatRelativeDeadline formats a deadline as "in 2 hours" or "3 days ago".
|
||||
func FormatRelativeDeadline(v any) string {
|
||||
return FormatRelativeDeadlineAt(v, time.Now())
|
||||
}
|
||||
|
||||
// FormatRelativeDeadlineAt is FormatRelativeDeadline with a fixed clock (for tests).
|
||||
func FormatRelativeDeadlineAt(v any, now time.Time) string {
|
||||
t, ok := parseDeadlineTime(v)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return relativeTimePhrase(now, t)
|
||||
}
|
||||
|
||||
func relativeTimePhrase(now, target time.Time) string {
|
||||
d := target.Sub(now)
|
||||
future := d >= 0
|
||||
if !future {
|
||||
d = -d
|
||||
}
|
||||
phrase := func(n int, unit string) string {
|
||||
if n == 1 {
|
||||
unit = strings.TrimSuffix(unit, "s")
|
||||
}
|
||||
if future {
|
||||
return fmt.Sprintf("in %d %s", n, unit)
|
||||
}
|
||||
return fmt.Sprintf("%d %s ago", n, unit)
|
||||
}
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
if future {
|
||||
return "in 1 minute"
|
||||
}
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return phrase(int(d.Round(time.Minute)/time.Minute), "minutes")
|
||||
case d < 24*time.Hour:
|
||||
return phrase(int(d.Round(time.Hour)/time.Hour), "hours")
|
||||
default:
|
||||
return phrase(int(d.Round(24*time.Hour)/(24*time.Hour)), "days")
|
||||
}
|
||||
}
|
||||
|
||||
func parseDeadlineTime(v any) (time.Time, bool) {
|
||||
if v == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case time.Time:
|
||||
return x, true
|
||||
case string:
|
||||
s := strings.TrimSpace(x)
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
formats := []string{
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02T15:04:05.0000000-07:00",
|
||||
"2006-01-02T15:04:05-07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range formats {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
case float64:
|
||||
return time.Unix(int64(x), 0), true
|
||||
case int64:
|
||||
return time.Unix(x, 0), true
|
||||
case int:
|
||||
return time.Unix(int64(x), 0), true
|
||||
default:
|
||||
return parseDeadlineTime(fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
|
||||
// TaskResponsibleLabel returns the assignee display name when present.
|
||||
func TaskResponsibleLabel(raw map[string]any) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
if name := personName(raw["responsible"]); name != "" {
|
||||
return name
|
||||
}
|
||||
if list, ok := raw["responsibles"].([]any); ok {
|
||||
names := make([]string, 0, len(list))
|
||||
for _, entry := range list {
|
||||
if name := personName(entry); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
}
|
||||
if s := strRaw(raw, "responsible"); s != "" && !looksLikeID(s) {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func personName(v any) string {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if name := strRaw(m, "displayName"); name != "" {
|
||||
return name
|
||||
}
|
||||
first := strRaw(m, "firstName")
|
||||
last := strRaw(m, "lastName")
|
||||
return strings.TrimSpace(first + " " + last)
|
||||
}
|
||||
|
||||
func looksLikeID(s string) bool {
|
||||
if len(s) == 36 && strings.Count(s, "-") == 4 {
|
||||
return true
|
||||
}
|
||||
_, err := strconv.Atoi(s)
|
||||
return err == nil
|
||||
}
|
||||
Reference in New Issue
Block a user