Add spreadsheet-style list, split detail pane with forms and document preview, and Alt+1/2/3 to show/hide panes with even width distribution. Co-authored-by: Cursor <cursoragent@cursor.com>
288 lines
5.8 KiB
Go
288 lines
5.8 KiB
Go
package model
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// TableColumn is one column in the center data table.
|
|
type TableColumn struct {
|
|
Key string
|
|
Title string
|
|
Width int
|
|
}
|
|
|
|
var subjectExtraKeys = map[Subject][]string{
|
|
SubjectTasks: {"status", "deadline", "responsible"},
|
|
SubjectCalendars: {"description"},
|
|
SubjectEvents: {"start", "end"},
|
|
SubjectContacts: {"primaryEmail", "displayName"},
|
|
SubjectPersons: {"primaryEmail", "displayName"},
|
|
SubjectCompanies: {"primaryEmail", "displayName"},
|
|
SubjectOpportunities: {"stage", "bidValue", "bidCurrency"},
|
|
SubjectCases: {"status"},
|
|
SubjectCRMTasks: {"status", "deadline"},
|
|
SubjectMailInbox: {"from", "date"},
|
|
SubjectMailSent: {"to", "date"},
|
|
SubjectMailDrafts: {"to", "date"},
|
|
SubjectMailTrash: {"from", "date"},
|
|
SubjectMailSpam: {"from", "date"},
|
|
SubjectUsers: {"email", "displayName"},
|
|
}
|
|
|
|
// BuildColumns derives table columns from the list subject and item payloads.
|
|
func BuildColumns(subject Subject, items []Item) []TableColumn {
|
|
if subject == SubjectProjects {
|
|
return buildProjectColumns(items)
|
|
}
|
|
cols := []TableColumn{
|
|
{Key: "_sel", Title: "✓", Width: 3},
|
|
{Key: "id", Title: "ID", Width: 10},
|
|
{Key: "title", Title: "Title", Width: 28},
|
|
}
|
|
if hasSubtitle(items) {
|
|
cols = append(cols, TableColumn{Key: "subtitle", Title: "Subtitle", Width: 22})
|
|
}
|
|
|
|
seen := map[string]bool{"_sel": true, "id": true, "title": true, "subtitle": true}
|
|
for _, key := range subjectExtraKeys[subject] {
|
|
if seen[key] || !columnHasData(items, key) {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
|
|
}
|
|
|
|
discovered := discoverRawKeys(items, seen, 8)
|
|
for _, key := range discovered {
|
|
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
|
|
}
|
|
|
|
sizeColumns(cols, items)
|
|
return cols
|
|
}
|
|
|
|
func buildProjectColumns(items []Item) []TableColumn {
|
|
cols := []TableColumn{
|
|
{Key: "_sel", Title: "✓", Width: 3},
|
|
{Key: "id", Title: "ID", Width: 8},
|
|
{Key: "title", Title: "Title", Width: 28},
|
|
{Key: "tasks", Title: "Tasks (open/closed)", Width: 18},
|
|
{Key: "documents", Title: "Documents", Width: 11},
|
|
{Key: "users", Title: "Users", Width: 8},
|
|
}
|
|
sizeColumns(cols, items)
|
|
return cols
|
|
}
|
|
|
|
// CellText returns the display string for one table cell.
|
|
func CellText(it Item, key string) string {
|
|
switch key {
|
|
case "_sel":
|
|
if it.Selected {
|
|
return "●"
|
|
}
|
|
return "○"
|
|
case "id":
|
|
return it.ID
|
|
case "title":
|
|
return it.Title
|
|
case "subtitle":
|
|
return it.Subtitle
|
|
case "tasks":
|
|
return formatProjectTasks(it.Raw)
|
|
case "documents":
|
|
return intRaw(it.Raw, "documentsCount")
|
|
case "users":
|
|
return intRaw(it.Raw, "participantCount")
|
|
case "kind":
|
|
return string(it.Kind)
|
|
default:
|
|
if it.Raw == nil {
|
|
return ""
|
|
}
|
|
return formatAny(it.Raw[key])
|
|
}
|
|
}
|
|
|
|
func formatProjectTasks(raw map[string]any) string {
|
|
if raw == nil {
|
|
return "0/0"
|
|
}
|
|
open := intRawVal(raw, "taskCount")
|
|
total := intRawVal(raw, "taskCountTotal")
|
|
closed := total - open
|
|
if closed < 0 {
|
|
closed = 0
|
|
}
|
|
return fmt.Sprintf("%d/%d", open, closed)
|
|
}
|
|
|
|
func intRaw(raw map[string]any, key string) string {
|
|
if raw == nil {
|
|
return ""
|
|
}
|
|
v := intRawVal(raw, key)
|
|
if v == 0 {
|
|
if _, ok := raw[key]; !ok {
|
|
return ""
|
|
}
|
|
}
|
|
return fmt.Sprintf("%d", v)
|
|
}
|
|
|
|
func intRawVal(raw map[string]any, key string) int {
|
|
if raw == nil {
|
|
return 0
|
|
}
|
|
switch v := raw[key].(type) {
|
|
case int:
|
|
return v
|
|
case int64:
|
|
return int(v)
|
|
case float64:
|
|
return int(v)
|
|
case string:
|
|
n, _ := strconv.Atoi(v)
|
|
return n
|
|
default:
|
|
if raw[key] == nil {
|
|
return 0
|
|
}
|
|
n, _ := strconv.Atoi(fmt.Sprint(raw[key]))
|
|
return n
|
|
}
|
|
}
|
|
|
|
func hasSubtitle(items []Item) bool {
|
|
for _, it := range items {
|
|
if it.Subtitle != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func columnHasData(items []Item, key string) bool {
|
|
for _, it := range items {
|
|
if CellText(it, key) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func discoverRawKeys(items []Item, seen map[string]bool, limit int) []string {
|
|
counts := map[string]int{}
|
|
for _, it := range items {
|
|
if it.Raw == nil {
|
|
continue
|
|
}
|
|
for k := range it.Raw {
|
|
if seen[k] || k == "id" || k == "title" {
|
|
continue
|
|
}
|
|
counts[k]++
|
|
}
|
|
}
|
|
type kv struct {
|
|
k string
|
|
n int
|
|
}
|
|
var ranked []kv
|
|
for k, n := range counts {
|
|
ranked = append(ranked, kv{k, n})
|
|
}
|
|
sort.Slice(ranked, func(i, j int) bool {
|
|
if ranked[i].n == ranked[j].n {
|
|
return ranked[i].k < ranked[j].k
|
|
}
|
|
return ranked[i].n > ranked[j].n
|
|
})
|
|
out := make([]string, 0, limit)
|
|
for _, r := range ranked {
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
out = append(out, r.k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func titleLabel(key string) string {
|
|
if key == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(key, "_")
|
|
for i, p := range parts {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func defaultWidth(key string) int {
|
|
switch key {
|
|
case "id", "status", "stage":
|
|
return 10
|
|
case "start", "end", "deadline", "date":
|
|
return 18
|
|
case "from", "to", "email", "primaryEmail":
|
|
return 22
|
|
case "description", "displayName":
|
|
return 24
|
|
default:
|
|
return 14
|
|
}
|
|
}
|
|
|
|
func sizeColumns(cols []TableColumn, items []Item) {
|
|
for i := range cols {
|
|
maxW := runeLen(cols[i].Title)
|
|
for _, it := range items {
|
|
n := runeLen(CellText(it, cols[i].Key))
|
|
if n > maxW {
|
|
maxW = n
|
|
}
|
|
}
|
|
maxW += 2
|
|
if maxW < 4 {
|
|
maxW = 4
|
|
}
|
|
if maxW > 36 {
|
|
maxW = 36
|
|
}
|
|
cols[i].Width = maxW
|
|
}
|
|
}
|
|
|
|
func runeLen(s string) int {
|
|
return len([]rune(s))
|
|
}
|
|
|
|
func formatAny(v any) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
switch x := v.(type) {
|
|
case string:
|
|
return x
|
|
case bool:
|
|
if x {
|
|
return "yes"
|
|
}
|
|
return "no"
|
|
case float64:
|
|
if x == float64(int64(x)) {
|
|
return fmt.Sprintf("%d", int64(x))
|
|
}
|
|
return fmt.Sprintf("%g", x)
|
|
default:
|
|
return fmt.Sprint(v)
|
|
}
|
|
}
|