feat(gmail,contacts): land label style and contact gender
Co-authored-by: iskkw9973 <ishikawam.dev@gmail.com> Co-authored-by: klodr <klodr@users.noreply.github.com>
This commit is contained in:
parent
dbeca138c3
commit
64ab3772bf
@ -8,6 +8,8 @@
|
||||
- Slides: add `slides thumbnail` / `slides thumb` to fetch rendered slide thumbnail URLs or download PNG/JPEG images. (#498) — thanks @gianpaj.
|
||||
- Calendar: add `calendar create-calendar` / `new-calendar` to create secondary calendars with description, timezone, and location. (#455) — thanks @alexknowshtml.
|
||||
- Gmail: add `gmail forward` / `gmail fwd` to forward a message with optional note, verified send-as alias, and original attachments. (#482) — thanks @spencer-c-reed.
|
||||
- Gmail: add `gmail labels style` to update user label colors and list/message visibility. (#457) — thanks @iskw9973.
|
||||
- Contacts: add `--gender` to `contacts create` and `contacts update`, and include gender in `contacts get` text output. (#438) — thanks @klodr.
|
||||
- Gmail: add `gmail autoreply` to reply once to matching messages, label the thread for dedupe, and optionally archive/mark read. Includes docs and regression coverage for skip/reply flows.
|
||||
- Gmail: add `gmail messages search --full` to print complete message bodies instead of truncating text output. (#447) — thanks @GodsBoy.
|
||||
- Drive: allow `drive share --role commenter` for comment-only sharing. (#443) — thanks @pavelzak.
|
||||
|
||||
@ -674,6 +674,7 @@ gog gmail labels list
|
||||
gog gmail labels get INBOX --json # Includes message counts
|
||||
gog gmail labels create "My Label"
|
||||
gog gmail labels rename "Old Label" "New Label"
|
||||
gog gmail labels style "My Label" --text-color "#ffffff" --background-color "#4285f4"
|
||||
gog gmail labels modify <threadId> --add STARRED --remove INBOX
|
||||
gog gmail labels delete <labelIdOrName> # Deletes user label (guards system labels; confirm)
|
||||
|
||||
@ -1022,6 +1023,7 @@ gog contacts create \
|
||||
--email "john@example.com" \
|
||||
--phone "+1234567890" \
|
||||
--address "12 St James's Square, London" \
|
||||
--gender "male" \
|
||||
--relation "spouse=Jane Doe"
|
||||
|
||||
gog contacts update people/<resourceName> \
|
||||
@ -1029,6 +1031,7 @@ gog contacts update people/<resourceName> \
|
||||
--email "jane@example.com" \
|
||||
--address "1 Infinite Loop, Cupertino" \
|
||||
--birthday "1990-05-12" \
|
||||
--gender "female" \
|
||||
--notes "Met at WWDC" \
|
||||
--relation "friend=Bob"
|
||||
|
||||
|
||||
@ -277,6 +277,7 @@ Generated from `gog schema --json`.
|
||||
- `gog gmail (mail,email) labels (label) list (ls)` - List labels
|
||||
- `gog gmail (mail,email) labels (label) modify (update,edit,set) <threadId> ... [flags]` - Modify labels on threads
|
||||
- `gog gmail (mail,email) labels (label) rename (mv) <labelIdOrName> <newName>` - Rename a label
|
||||
- `gog gmail (mail,email) labels (label) style (color,colour) <labelIdOrName> [flags]` - Change a user label color or visibility
|
||||
- `gog gmail (mail,email) mark-read (read-messages) [<messageId> ...] [flags]` - Mark messages as read
|
||||
- `gog gmail (mail,email) messages (message,msg,msgs) <command>` - Message operations
|
||||
- `gog gmail (mail,email) messages (message,msg,msgs) modify (update,edit,set) <messageId> [flags]` - Modify labels on a single message
|
||||
|
||||
@ -166,6 +166,26 @@ func formatPartialDate(d *people.Date) string {
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
func primaryGender(p *people.Person) string {
|
||||
if p == nil || len(p.Genders) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, g := range p.Genders {
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
if g.Metadata != nil && g.Metadata.Primary {
|
||||
return firstNonEmpty(g.FormattedValue, g.Value)
|
||||
}
|
||||
}
|
||||
for _, g := range p.Genders {
|
||||
if g != nil {
|
||||
return firstNonEmpty(g.FormattedValue, g.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeTab(s string) string {
|
||||
return strings.ReplaceAll(s, "\t", " ")
|
||||
}
|
||||
|
||||
@ -17,8 +17,8 @@ import (
|
||||
|
||||
const (
|
||||
contactsReadMask = "names,emailAddresses,phoneNumbers,organizations,urls"
|
||||
contactsGetReadMask = contactsReadMask + ",birthdays,biographies,addresses,userDefined,relations,metadata"
|
||||
contactsUpdateReadMask = contactsReadMask + ",birthdays,biographies,addresses,userDefined,relations,metadata"
|
||||
contactsGetReadMask = contactsReadMask + ",birthdays,biographies,addresses,genders,userDefined,relations,metadata"
|
||||
contactsUpdateReadMask = contactsReadMask + ",birthdays,biographies,addresses,genders,userDefined,relations,metadata"
|
||||
)
|
||||
|
||||
type ContactsListCmd struct {
|
||||
@ -165,6 +165,9 @@ func (c *ContactsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if bd := primaryBirthday(p); bd != "" {
|
||||
u.Out().Printf("birthday\t%s", bd)
|
||||
}
|
||||
if gender := primaryGender(p); gender != "" {
|
||||
u.Out().Printf("gender\t%s", gender)
|
||||
}
|
||||
if org, title := primaryOrganization(p); org != "" || title != "" {
|
||||
switch {
|
||||
case org != "" && title != "":
|
||||
@ -214,6 +217,7 @@ type ContactsCreateCmd struct {
|
||||
URL []string `name:"url" help:"URL (can be repeated for multiple URLs)"`
|
||||
Note string `name:"note" help:"Note/biography"`
|
||||
Address []string `name:"address" sep:";" help:"Postal address (can be repeated for multiple addresses)"`
|
||||
Gender string `name:"gender" help:"Gender value"`
|
||||
Custom []string `name:"custom" help:"Custom field as key=value (can be repeated)"`
|
||||
Relation []string `name:"relation" help:"Relation as type=person (can be repeated)"`
|
||||
}
|
||||
@ -290,6 +294,17 @@ func contactsAddresses(values []string) []*people.Address {
|
||||
return out
|
||||
}
|
||||
|
||||
func contactsGenders(value string) []*people.Gender {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return []*people.Gender{{
|
||||
Value: value,
|
||||
Metadata: &people.FieldMetadata{Primary: true},
|
||||
}}
|
||||
}
|
||||
|
||||
func contactsApplyPersonName(person *people.Person, givenSet bool, given string, familySet bool, family string) {
|
||||
curGiven := ""
|
||||
curFamily := ""
|
||||
@ -372,6 +387,9 @@ func (c *ContactsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
p.Addresses = addrs
|
||||
}
|
||||
}
|
||||
if genders := contactsGenders(c.Gender); len(genders) > 0 {
|
||||
p.Genders = genders
|
||||
}
|
||||
if len(c.Custom) > 0 {
|
||||
userDefined, _, parseErr := parseCustomUserDefined(c.Custom, false)
|
||||
if parseErr != nil {
|
||||
@ -413,6 +431,7 @@ type ContactsUpdateCmd struct {
|
||||
URL []string `name:"url" help:"URL (can be repeated; empty clears all)"`
|
||||
Note string `name:"note" help:"Note/biography (empty clears)"`
|
||||
Address []string `name:"address" sep:";" help:"Postal address (can be repeated; empty clears all)"`
|
||||
Gender string `name:"gender" help:"Gender value (empty clears)"`
|
||||
Custom []string `name:"custom" help:"Custom field as key=value (can be repeated; empty clears all)"`
|
||||
Relation []string `name:"relation" help:"Relation as type=person (can be repeated; empty clears all)"`
|
||||
FromFile string `name:"from-file" help:"Update from contact JSON file (use - for stdin)"`
|
||||
@ -442,7 +461,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
|
||||
if strings.TrimSpace(c.FromFile) != "" {
|
||||
if flagProvided(kctx, "given") || flagProvided(kctx, "family") || flagProvided(kctx, "email") || flagProvided(kctx, "phone") ||
|
||||
flagProvided(kctx, "org") || flagProvided(kctx, "title") || flagProvided(kctx, "url") ||
|
||||
flagProvided(kctx, "note") || flagProvided(kctx, "address") || flagProvided(kctx, "custom") ||
|
||||
flagProvided(kctx, "note") || flagProvided(kctx, "address") || flagProvided(kctx, "gender") || flagProvided(kctx, "custom") ||
|
||||
flagProvided(kctx, "birthday") || flagProvided(kctx, "notes") || flagProvided(kctx, "relation") {
|
||||
return usage("can't combine --from-file with other update flags")
|
||||
}
|
||||
@ -465,6 +484,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
|
||||
wantURL := flagProvided(kctx, "url")
|
||||
wantNote := flagProvided(kctx, "note")
|
||||
wantAddress := flagProvided(kctx, "address")
|
||||
wantGender := flagProvided(kctx, "gender")
|
||||
wantBirthday := flagProvided(kctx, "birthday")
|
||||
wantNotes := flagProvided(kctx, "notes")
|
||||
wantCustom := flagProvided(kctx, "custom")
|
||||
@ -520,6 +540,15 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
|
||||
}
|
||||
updateFields = append(updateFields, "addresses")
|
||||
}
|
||||
if wantGender {
|
||||
genders := contactsGenders(c.Gender)
|
||||
if len(genders) == 0 {
|
||||
existing.Genders = nil // will be forced to [] for patch
|
||||
} else {
|
||||
existing.Genders = genders
|
||||
}
|
||||
updateFields = append(updateFields, "genders")
|
||||
}
|
||||
if wantCustom {
|
||||
userDefined, clearAll, parseErr := parseCustomUserDefined(c.Custom, true)
|
||||
if parseErr != nil {
|
||||
|
||||
@ -461,6 +461,114 @@ func TestContactsCreate_Address_Set(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactsCreate_Gender_Set(t *testing.T) {
|
||||
var gotGenders []map[string]any
|
||||
|
||||
svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, ":createContact") && r.Method == http.MethodPost:
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if genders, ok := body["genders"].([]any); ok {
|
||||
for _, gender := range genders {
|
||||
if m, ok := gender.(map[string]any); ok {
|
||||
gotGenders = append(gotGenders, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"resourceName": "people/c1"})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(closeSrv)
|
||||
stubPeopleServices(t, svc)
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := ui.WithUI(context.Background(), u)
|
||||
|
||||
if err := runKong(t, &ContactsCreateCmd{}, []string{"--given", "Ada", "--gender", "female"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("runKong: %v", err)
|
||||
}
|
||||
|
||||
if len(gotGenders) != 1 || gotGenders[0]["value"] != "female" {
|
||||
t.Fatalf("unexpected genders: %#v", gotGenders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactsUpdate_Gender_SetAndClear(t *testing.T) {
|
||||
var gotGetFields string
|
||||
var gotUpdateFields []string
|
||||
var gotGender string
|
||||
var sawClear bool
|
||||
|
||||
svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "people/c1") && r.Method == http.MethodGet && !strings.Contains(r.URL.Path, ":"):
|
||||
gotGetFields = r.URL.Query().Get("personFields")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"resourceName": "people/c1",
|
||||
"names": []map[string]any{{"givenName": "Ada", "familyName": "Lovelace"}},
|
||||
"genders": []map[string]any{{"value": "female"}},
|
||||
})
|
||||
return
|
||||
case strings.Contains(r.URL.Path, ":updateContact") && (r.Method == http.MethodPatch || r.Method == http.MethodPost):
|
||||
gotUpdateFields = append(gotUpdateFields, r.URL.Query().Get("updatePersonFields"))
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
genders, _ := body["genders"].([]any)
|
||||
if len(genders) == 0 {
|
||||
sawClear = true
|
||||
} else if first, ok := genders[0].(map[string]any); ok {
|
||||
gotGender = primaryValue(first, "value")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"resourceName": "people/c1"})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(closeSrv)
|
||||
stubPeopleServices(t, svc)
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := ui.WithUI(context.Background(), u)
|
||||
|
||||
if err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--gender", "male"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("runKong set: %v", err)
|
||||
}
|
||||
if err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--gender", ""}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("runKong clear: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(gotGetFields, "genders") {
|
||||
t.Fatalf("missing genders in people.get fields: %q", gotGetFields)
|
||||
}
|
||||
if len(gotUpdateFields) != 2 || !strings.Contains(gotUpdateFields[0], "genders") || !strings.Contains(gotUpdateFields[1], "genders") {
|
||||
t.Fatalf("missing gender update fields: %#v", gotUpdateFields)
|
||||
}
|
||||
if gotGender != "male" {
|
||||
t.Fatalf("unexpected gender payload: %q", gotGender)
|
||||
}
|
||||
if !sawClear {
|
||||
t.Fatal("expected empty genders payload for clear")
|
||||
}
|
||||
}
|
||||
|
||||
func leftPad2(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 2 {
|
||||
|
||||
@ -17,6 +17,7 @@ type GmailLabelsCmd struct {
|
||||
Get GmailLabelsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get label details (including counts)"`
|
||||
Create GmailLabelsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a new label"`
|
||||
Rename GmailLabelsRenameCmd `cmd:"" name:"rename" aliases:"mv" help:"Rename a label"`
|
||||
Style GmailLabelsStyleCmd `cmd:"" name:"style" aliases:"color,colour" help:"Change a user label color or visibility"`
|
||||
Modify GmailLabelsModifyCmd `cmd:"" name:"modify" aliases:"update,edit,set" help:"Modify labels on threads"`
|
||||
Delete GmailLabelsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del" help:"Delete a label"`
|
||||
}
|
||||
@ -139,27 +140,9 @@ func (c *GmailLabelsRenameCmd) Run(ctx context.Context, flags *RootFlags) error
|
||||
return err
|
||||
}
|
||||
|
||||
// For destructive-ish rename operations, try exact ID first before name lookup.
|
||||
label, err := svc.Users.Labels.Get("me", oldRaw).Context(ctx).Do()
|
||||
label, err := resolveMutableGmailLabel(ctx, svc, oldRaw)
|
||||
if err != nil {
|
||||
if !isNotFoundAPIError(err) {
|
||||
return err
|
||||
}
|
||||
if looksLikeCustomLabelID(oldRaw) {
|
||||
return fmt.Errorf("label not found: %s", oldRaw)
|
||||
}
|
||||
idMap, mapErr := fetchLabelNameOnlyToID(svc)
|
||||
if mapErr != nil {
|
||||
return mapErr
|
||||
}
|
||||
id, ok := idMap[strings.ToLower(oldRaw)]
|
||||
if !ok {
|
||||
return fmt.Errorf("label not found: %s", oldRaw)
|
||||
}
|
||||
label, err = svc.Users.Labels.Get("me", id).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if label.Type == "system" {
|
||||
@ -192,6 +175,106 @@ func (c *GmailLabelsRenameCmd) Run(ctx context.Context, flags *RootFlags) error
|
||||
return nil
|
||||
}
|
||||
|
||||
type GmailLabelsStyleCmd struct {
|
||||
Label string `arg:"" name:"labelIdOrName" help:"User label ID or name"`
|
||||
TextColor string `name:"text-color" help:"Text color as #RRGGBB"`
|
||||
BackgroundColor string `name:"background-color" help:"Background color as #RRGGBB"`
|
||||
LabelListVisibility string `name:"label-list-visibility" help:"Label-list visibility: labelShow|labelShowIfUnread|labelHide"`
|
||||
MessageListVisibility string `name:"message-list-visibility" help:"Message-list visibility: show|hide"`
|
||||
}
|
||||
|
||||
func (c *GmailLabelsStyleCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := newGmailService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
label, err := resolveMutableGmailLabel(ctx, svc, c.Label)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if label.Type == "system" {
|
||||
return fmt.Errorf("cannot style system label %q", label.Name)
|
||||
}
|
||||
|
||||
textColor, err := normalizeGmailLabelHexColor(c.TextColor, "--text-color")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backgroundColor, err := normalizeGmailLabelHexColor(c.BackgroundColor, "--background-color")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if textColor == "" && backgroundColor == "" && c.LabelListVisibility == "" && c.MessageListVisibility == "" {
|
||||
return usage("specify at least one style field")
|
||||
}
|
||||
if err := validateGmailLabelVisibility(c.LabelListVisibility, "--label-list-visibility", "labelShow", "labelShowIfUnread", "labelHide"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateGmailLabelVisibility(c.MessageListVisibility, "--message-list-visibility", "show", "hide"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
patch := &gmail.Label{
|
||||
LabelListVisibility: strings.TrimSpace(c.LabelListVisibility),
|
||||
MessageListVisibility: strings.TrimSpace(c.MessageListVisibility),
|
||||
}
|
||||
if textColor != "" || backgroundColor != "" {
|
||||
color := &gmail.LabelColor{}
|
||||
if label.Color != nil {
|
||||
color.TextColor = label.Color.TextColor
|
||||
color.BackgroundColor = label.Color.BackgroundColor
|
||||
}
|
||||
if textColor != "" {
|
||||
color.TextColor = textColor
|
||||
}
|
||||
if backgroundColor != "" {
|
||||
color.BackgroundColor = backgroundColor
|
||||
}
|
||||
patch.Color = color
|
||||
}
|
||||
|
||||
if exit := dryRunExit(ctx, flags, "gmail.labels.style", map[string]any{
|
||||
"id": label.Id,
|
||||
"name": label.Name,
|
||||
"textColor": textColor,
|
||||
"backgroundColor": backgroundColor,
|
||||
"labelListVisibility": c.LabelListVisibility,
|
||||
"messageListVisibility": c.MessageListVisibility,
|
||||
}); exit != nil {
|
||||
return exit
|
||||
}
|
||||
|
||||
updated, err := svc.Users.Labels.Patch("me", label.Id, patch).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"label": updated})
|
||||
}
|
||||
u.Out().Printf("Styled label: %s (id: %s)", updated.Name, updated.Id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGmailLabelVisibility(value, field string, allowed ...string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if value == candidate {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return usagef("%s must be one of: %s", field, strings.Join(allowed, ", "))
|
||||
}
|
||||
|
||||
type GmailLabelsListCmd struct{}
|
||||
|
||||
func (c *GmailLabelsListCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
@ -338,30 +421,9 @@ func (c *GmailLabelsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error
|
||||
return err
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(c.Label)
|
||||
if raw == "" {
|
||||
return usage("empty label")
|
||||
}
|
||||
|
||||
// For destructive operations, try exact ID match first before name lookup.
|
||||
label, err := svc.Users.Labels.Get("me", raw).Context(ctx).Do()
|
||||
label, err := resolveMutableGmailLabel(ctx, svc, c.Label)
|
||||
if err != nil {
|
||||
if !isNotFoundAPIError(err) {
|
||||
return err
|
||||
}
|
||||
// Exact ID not found; resolve by label name only.
|
||||
idMap, mapErr := fetchLabelNameOnlyToID(svc)
|
||||
if mapErr != nil {
|
||||
return mapErr
|
||||
}
|
||||
id, ok := idMap[strings.ToLower(raw)]
|
||||
if !ok {
|
||||
return fmt.Errorf("label not found: %s", raw)
|
||||
}
|
||||
label, err = svc.Users.Labels.Get("me", id).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// System labels cannot be deleted
|
||||
|
||||
@ -520,6 +520,128 @@ func TestGmailLabelsCreateCmd_DuplicateName_APIError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailLabelsStyleCmd_PatchColorAndVisibility(t *testing.T) {
|
||||
var gotPatch bool
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && (strings.HasSuffix(r.URL.Path, "/users/me/labels") || strings.HasSuffix(r.URL.Path, "/gmail/v1/users/me/labels")):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"labels": []map[string]any{{"id": "Label_1", "name": "Custom", "type": "user"}},
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/labels/Custom"):
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/labels/Label_1"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "Label_1",
|
||||
"name": "Custom",
|
||||
"type": "user",
|
||||
"color": map[string]any{
|
||||
"textColor": "#000000",
|
||||
"backgroundColor": "#ffffff",
|
||||
},
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/labels/Label_1"):
|
||||
gotPatch = true
|
||||
var body struct {
|
||||
Color struct {
|
||||
TextColor string `json:"textColor"`
|
||||
BackgroundColor string `json:"backgroundColor"`
|
||||
} `json:"color"`
|
||||
LabelListVisibility string `json:"labelListVisibility"`
|
||||
MessageListVisibility string `json:"messageListVisibility"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode patch: %v", err)
|
||||
}
|
||||
if body.Color.TextColor != "#112233" || body.Color.BackgroundColor != "#ffffff" {
|
||||
t.Fatalf("unexpected color patch: %#v", body.Color)
|
||||
}
|
||||
if body.LabelListVisibility != "labelShowIfUnread" || body.MessageListVisibility != "hide" {
|
||||
t.Fatalf("unexpected visibility patch: %#v", body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "Label_1",
|
||||
"name": "Custom",
|
||||
"type": "user",
|
||||
"labelListVisibility": body.LabelListVisibility,
|
||||
"messageListVisibility": body.MessageListVisibility,
|
||||
"color": map[string]any{
|
||||
"textColor": body.Color.TextColor,
|
||||
"backgroundColor": body.Color.BackgroundColor,
|
||||
},
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
stubGmailService(t, srv)
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
||||
if uiErr != nil {
|
||||
t.Fatalf("ui.New: %v", uiErr)
|
||||
}
|
||||
ctx := ui.WithUI(context.Background(), u)
|
||||
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
|
||||
|
||||
cmd := &GmailLabelsStyleCmd{}
|
||||
err := runKong(t, cmd, []string{
|
||||
"Custom",
|
||||
"--text-color", "#112233",
|
||||
"--label-list-visibility", "labelShowIfUnread",
|
||||
"--message-list-visibility", "hide",
|
||||
}, ctx, &RootFlags{Account: "a@b.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
})
|
||||
if !gotPatch {
|
||||
t.Fatal("expected patch call")
|
||||
}
|
||||
if !strings.Contains(out, `"textColor": "#112233"`) {
|
||||
t.Fatalf("missing color in output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailLabelsStyleCmd_RejectsSystemLabel(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/labels/INBOX") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "INBOX",
|
||||
"name": "INBOX",
|
||||
"type": "system",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
stubGmailService(t, srv)
|
||||
|
||||
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
||||
if uiErr != nil {
|
||||
t.Fatalf("ui.New: %v", uiErr)
|
||||
}
|
||||
ctx := ui.WithUI(context.Background(), u)
|
||||
|
||||
cmd := &GmailLabelsStyleCmd{}
|
||||
err := runKong(t, cmd, []string{"INBOX", "--background-color", "#112233"}, ctx, &RootFlags{Account: "a@b.com"})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot style system label") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchLabelIDToName(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !(strings.HasSuffix(r.URL.Path, "/users/me/labels") || strings.HasSuffix(r.URL.Path, "/gmail/v1/users/me/labels")) {
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -40,6 +42,50 @@ func resolveModifyLabelIDs(svc *gmail.Service, addLabels, removeLabels []string)
|
||||
return resolveLabelIDs(addLabels, idMap), resolveLabelIDs(removeLabels, idMap), nil
|
||||
}
|
||||
|
||||
func resolveMutableGmailLabel(ctx context.Context, svc *gmail.Service, raw string) (*gmail.Label, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, usage("label is required")
|
||||
}
|
||||
|
||||
label, err := svc.Users.Labels.Get("me", raw).Context(ctx).Do()
|
||||
if err == nil {
|
||||
return label, nil
|
||||
}
|
||||
if !isNotFoundAPIError(err) {
|
||||
return nil, err
|
||||
}
|
||||
if looksLikeCustomLabelID(raw) {
|
||||
return nil, fmt.Errorf("label not found: %s", raw)
|
||||
}
|
||||
|
||||
idMap, mapErr := fetchLabelNameOnlyToID(svc)
|
||||
if mapErr != nil {
|
||||
return nil, mapErr
|
||||
}
|
||||
id, ok := idMap[strings.ToLower(raw)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("label not found: %s", raw)
|
||||
}
|
||||
return svc.Users.Labels.Get("me", id).Context(ctx).Do()
|
||||
}
|
||||
|
||||
func normalizeGmailLabelHexColor(raw, field string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(raw) != 7 || raw[0] != '#' {
|
||||
return "", usagef("%s must be a #RRGGBB hex color", field)
|
||||
}
|
||||
for _, r := range raw[1:] {
|
||||
if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
|
||||
return "", usagef("%s must be a #RRGGBB hex color", field)
|
||||
}
|
||||
}
|
||||
return strings.ToLower(raw), nil
|
||||
}
|
||||
|
||||
func looksLikeCustomLabelID(raw string) bool {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(strings.ToLower(trimmed), "label_") {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user