Compare commits
3 Commits
main
...
temp/test-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d396bcfdd | ||
|
|
a5bef10740 | ||
|
|
12bf5a23b3 |
@ -5,6 +5,7 @@
|
|||||||
### Added
|
### Added
|
||||||
- Sheets: add `sheets insert` to insert rows/columns into a sheet. (#203) — thanks @andybergon.
|
- Sheets: add `sheets insert` to insert rows/columns into a sheet. (#203) — thanks @andybergon.
|
||||||
- Gmail: add `watch serve --history-types` filtering (`messageAdded|messageDeleted|labelAdded|labelRemoved`) and include `deletedMessageIds` in webhook payloads. (#168) — thanks @salmonumbrella.
|
- Gmail: add `watch serve --history-types` filtering (`messageAdded|messageDeleted|labelAdded|labelRemoved`) and include `deletedMessageIds` in webhook payloads. (#168) — thanks @salmonumbrella.
|
||||||
|
- Contacts: support `--org`, `--title`, `--url`, `--note`, and `--custom` on create/update; include custom fields in get output with deterministic ordering. (#199) — thanks @phuctm97.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Calendar: respond patches only attendees to avoid custom reminders validation errors. (#265) — thanks @sebasrodriguez.
|
- Calendar: respond patches only attendees to avoid custom reminders validation errors. (#265) — thanks @sebasrodriguez.
|
||||||
|
|||||||
@ -169,3 +169,50 @@ func formatPartialDate(d *people.Date) string {
|
|||||||
func sanitizeTab(s string) string {
|
func sanitizeTab(s string) string {
|
||||||
return strings.ReplaceAll(s, "\t", " ")
|
return strings.ReplaceAll(s, "\t", " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func primaryOrganization(p *people.Person) (name, title string) {
|
||||||
|
if p == nil || len(p.Organizations) == 0 || p.Organizations[0] == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return p.Organizations[0].Name, p.Organizations[0].Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func primaryURL(p *people.Person) string {
|
||||||
|
if p == nil || len(p.Urls) == 0 || p.Urls[0] == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p.Urls[0].Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func allURLs(p *people.Person) []string {
|
||||||
|
if p == nil || len(p.Urls) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
urls := make([]string, 0, len(p.Urls))
|
||||||
|
for _, u := range p.Urls {
|
||||||
|
if u != nil && u.Value != "" {
|
||||||
|
urls = append(urls, u.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
func primaryBio(p *people.Person) string {
|
||||||
|
if p == nil || len(p.Biographies) == 0 || p.Biographies[0] == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p.Biographies[0].Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func userDefinedFields(p *people.Person) map[string]string {
|
||||||
|
if p == nil || len(p.UserDefined) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fields := make(map[string]string, len(p.UserDefined))
|
||||||
|
for _, ud := range p.UserDefined {
|
||||||
|
if ud != nil && ud.Key != "" {
|
||||||
|
fields[ud.Key] = ud.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/alecthomas/kong"
|
"github.com/alecthomas/kong"
|
||||||
@ -15,10 +16,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
contactsReadMask = "names,emailAddresses,phoneNumbers"
|
contactsReadMask = "names,emailAddresses,phoneNumbers,organizations,urls"
|
||||||
// contactsGetReadMask is tuned for round-tripping `gog contacts get --json`
|
contactsGetReadMask = contactsReadMask + ",birthdays,biographies,addresses,userDefined,metadata"
|
||||||
// into `gog contacts update --from-file`.
|
|
||||||
contactsGetReadMask = contactsReadMask + ",birthdays,urls,biographies,addresses,organizations,metadata"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContactsListCmd struct {
|
type ContactsListCmd struct {
|
||||||
@ -165,14 +164,70 @@ func (c *ContactsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
|
|||||||
if bd := primaryBirthday(p); bd != "" {
|
if bd := primaryBirthday(p); bd != "" {
|
||||||
u.Out().Printf("birthday\t%s", bd)
|
u.Out().Printf("birthday\t%s", bd)
|
||||||
}
|
}
|
||||||
|
if org, title := primaryOrganization(p); org != "" || title != "" {
|
||||||
|
if org != "" && title != "" {
|
||||||
|
u.Out().Printf("organization\t%s (%s)", org, title)
|
||||||
|
} else if org != "" {
|
||||||
|
u.Out().Printf("organization\t%s", org)
|
||||||
|
} else {
|
||||||
|
u.Out().Printf("title\t%s", title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, url := range allURLs(p) {
|
||||||
|
u.Out().Printf("url\t%s", url)
|
||||||
|
}
|
||||||
|
if bio := primaryBio(p); bio != "" {
|
||||||
|
u.Out().Printf("note\t%s", bio)
|
||||||
|
}
|
||||||
|
customFields := userDefinedFields(p)
|
||||||
|
if len(customFields) > 0 {
|
||||||
|
keys := make([]string, 0, len(customFields))
|
||||||
|
for k := range customFields {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
u.Out().Printf("custom:%s\t%s", k, customFields[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContactsCreateCmd struct {
|
type ContactsCreateCmd struct {
|
||||||
Given string `name:"given" help:"Given name (required)"`
|
Given string `name:"given" help:"Given name (required)"`
|
||||||
Family string `name:"family" help:"Family name"`
|
Family string `name:"family" help:"Family name"`
|
||||||
Email string `name:"email" help:"Email address"`
|
Email string `name:"email" help:"Email address"`
|
||||||
Phone string `name:"phone" help:"Phone number"`
|
Phone string `name:"phone" help:"Phone number"`
|
||||||
|
Organization string `name:"org" help:"Organization/company name"`
|
||||||
|
Title string `name:"title" help:"Job title"`
|
||||||
|
URL []string `name:"url" help:"URL (can be repeated for multiple URLs)"`
|
||||||
|
Note string `name:"note" help:"Note/biography"`
|
||||||
|
Custom []string `name:"custom" help:"Custom field as key=value (can be repeated)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCustomUserDefined(values []string, allowEmptyClear bool) ([]*people.UserDefined, error) {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if len(values) == 1 && strings.TrimSpace(values[0]) == "" {
|
||||||
|
if allowEmptyClear {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("--custom entry cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
userDefined := make([]*people.UserDefined, 0, len(values))
|
||||||
|
for _, kv := range values {
|
||||||
|
parts := strings.SplitN(strings.TrimSpace(kv), "=", 2)
|
||||||
|
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" {
|
||||||
|
return nil, fmt.Errorf("expected key=value for --custom, got %q", kv)
|
||||||
|
}
|
||||||
|
userDefined = append(userDefined, &people.UserDefined{
|
||||||
|
Key: strings.TrimSpace(parts[0]),
|
||||||
|
Value: strings.TrimSpace(parts[1]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return userDefined, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ContactsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
|
func (c *ContactsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||||
@ -202,6 +257,35 @@ func (c *ContactsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
|
|||||||
if strings.TrimSpace(c.Phone) != "" {
|
if strings.TrimSpace(c.Phone) != "" {
|
||||||
p.PhoneNumbers = []*people.PhoneNumber{{Value: strings.TrimSpace(c.Phone)}}
|
p.PhoneNumbers = []*people.PhoneNumber{{Value: strings.TrimSpace(c.Phone)}}
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(c.Organization) != "" || strings.TrimSpace(c.Title) != "" {
|
||||||
|
p.Organizations = []*people.Organization{{
|
||||||
|
Name: strings.TrimSpace(c.Organization),
|
||||||
|
Title: strings.TrimSpace(c.Title),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if len(c.URL) > 0 {
|
||||||
|
urls := make([]*people.Url, 0, len(c.URL))
|
||||||
|
for _, u := range c.URL {
|
||||||
|
if strings.TrimSpace(u) != "" {
|
||||||
|
urls = append(urls, &people.Url{Value: strings.TrimSpace(u)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(urls) > 0 {
|
||||||
|
p.Urls = urls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.Note) != "" {
|
||||||
|
p.Biographies = []*people.Biography{{Value: strings.TrimSpace(c.Note)}}
|
||||||
|
}
|
||||||
|
if len(c.Custom) > 0 {
|
||||||
|
userDefined, parseErr := parseCustomUserDefined(c.Custom, false)
|
||||||
|
if parseErr != nil {
|
||||||
|
return usage(parseErr.Error())
|
||||||
|
}
|
||||||
|
if len(userDefined) > 0 {
|
||||||
|
p.UserDefined = userDefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
created, err := svc.People.CreateContact(p).Do()
|
created, err := svc.People.CreateContact(p).Do()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -215,13 +299,18 @@ func (c *ContactsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ContactsUpdateCmd struct {
|
type ContactsUpdateCmd struct {
|
||||||
ResourceName string `arg:"" name:"resourceName" help:"Resource name (people/...)"`
|
ResourceName string `arg:"" name:"resourceName" help:"Resource name (people/...)"`
|
||||||
Given string `name:"given" help:"Given name"`
|
Given string `name:"given" help:"Given name"`
|
||||||
Family string `name:"family" help:"Family name"`
|
Family string `name:"family" help:"Family name"`
|
||||||
Email string `name:"email" help:"Email address (empty clears)"`
|
Email string `name:"email" help:"Email address (empty clears)"`
|
||||||
Phone string `name:"phone" help:"Phone number (empty clears)"`
|
Phone string `name:"phone" help:"Phone number (empty clears)"`
|
||||||
FromFile string `name:"from-file" help:"Update from contact JSON file (use - for stdin)"`
|
Organization string `name:"org" help:"Organization/company name (empty clears)"`
|
||||||
IgnoreETag bool `name:"ignore-etag" help:"Allow updating even if the JSON etag is stale (may overwrite concurrent changes)"`
|
Title string `name:"title" help:"Job title (empty clears)"`
|
||||||
|
URL []string `name:"url" help:"URL (can be repeated; empty clears all)"`
|
||||||
|
Note string `name:"note" help:"Note/biography (empty clears)"`
|
||||||
|
Custom []string `name:"custom" help:"Custom field as key=value (can be repeated; empty clears all)"`
|
||||||
|
FromFile string `name:"from-file" help:"Update from contact JSON file (use - for stdin)"`
|
||||||
|
IgnoreETag bool `name:"ignore-etag" help:"Allow updating even if the JSON etag is stale (may overwrite concurrent changes)"`
|
||||||
|
|
||||||
// Extra People API fields (not previously exposed by gog)
|
// Extra People API fields (not previously exposed by gog)
|
||||||
Birthday string `name:"birthday" help:"Birthday in YYYY-MM-DD (empty clears)"`
|
Birthday string `name:"birthday" help:"Birthday in YYYY-MM-DD (empty clears)"`
|
||||||
@ -251,12 +340,12 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
|
|||||||
return c.updateFromJSON(ctx, svc, resourceName, u)
|
return c.updateFromJSON(ctx, svc, resourceName, u)
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, err := svc.People.Get(resourceName).PersonFields(contactsReadMask + ",birthdays,biographies,metadata").Do()
|
existing, err := svc.People.Get(resourceName).PersonFields(contactsReadMask + ",birthdays,biographies,userDefined,metadata").Do()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFields := make([]string, 0, 5)
|
updateFields := make([]string, 0, 8)
|
||||||
|
|
||||||
if flagProvided(kctx, "given") || flagProvided(kctx, "family") {
|
if flagProvided(kctx, "given") || flagProvided(kctx, "family") {
|
||||||
curGiven := ""
|
curGiven := ""
|
||||||
@ -291,6 +380,55 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
|
|||||||
}
|
}
|
||||||
updateFields = append(updateFields, "phoneNumbers")
|
updateFields = append(updateFields, "phoneNumbers")
|
||||||
}
|
}
|
||||||
|
if flagProvided(kctx, "org") || flagProvided(kctx, "title") {
|
||||||
|
curOrg := ""
|
||||||
|
curTitle := ""
|
||||||
|
if len(existing.Organizations) > 0 && existing.Organizations[0] != nil {
|
||||||
|
curOrg = existing.Organizations[0].Name
|
||||||
|
curTitle = existing.Organizations[0].Title
|
||||||
|
}
|
||||||
|
if flagProvided(kctx, "org") {
|
||||||
|
curOrg = strings.TrimSpace(c.Organization)
|
||||||
|
}
|
||||||
|
if flagProvided(kctx, "title") {
|
||||||
|
curTitle = strings.TrimSpace(c.Title)
|
||||||
|
}
|
||||||
|
if curOrg == "" && curTitle == "" {
|
||||||
|
existing.Organizations = nil
|
||||||
|
} else {
|
||||||
|
existing.Organizations = []*people.Organization{{Name: curOrg, Title: curTitle}}
|
||||||
|
}
|
||||||
|
updateFields = append(updateFields, "organizations")
|
||||||
|
}
|
||||||
|
if flagProvided(kctx, "url") {
|
||||||
|
if len(c.URL) == 0 || (len(c.URL) == 1 && strings.TrimSpace(c.URL[0]) == "") {
|
||||||
|
existing.Urls = nil
|
||||||
|
} else {
|
||||||
|
urls := make([]*people.Url, 0, len(c.URL))
|
||||||
|
for _, u := range c.URL {
|
||||||
|
if strings.TrimSpace(u) != "" {
|
||||||
|
urls = append(urls, &people.Url{Value: strings.TrimSpace(u)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existing.Urls = urls
|
||||||
|
}
|
||||||
|
updateFields = append(updateFields, "urls")
|
||||||
|
}
|
||||||
|
if flagProvided(kctx, "note") {
|
||||||
|
if strings.TrimSpace(c.Note) == "" {
|
||||||
|
existing.Biographies = nil
|
||||||
|
} else {
|
||||||
|
existing.Biographies = []*people.Biography{{Value: strings.TrimSpace(c.Note)}}
|
||||||
|
}
|
||||||
|
updateFields = append(updateFields, "biographies")
|
||||||
|
}
|
||||||
|
if flagProvided(kctx, "custom") {
|
||||||
|
userDefined, parseErr := parseCustomUserDefined(c.Custom, true)
|
||||||
|
if parseErr != nil {
|
||||||
|
return usage(parseErr.Error())
|
||||||
|
existing.UserDefined = userDefined
|
||||||
|
updateFields = append(updateFields, "userDefined")
|
||||||
|
}
|
||||||
|
|
||||||
if flagProvided(kctx, "birthday") {
|
if flagProvided(kctx, "birthday") {
|
||||||
if strings.TrimSpace(c.Birthday) == "" {
|
if strings.TrimSpace(c.Birthday) == "" {
|
||||||
|
|||||||
@ -95,3 +95,41 @@ func TestPrimaryBirthday_EdgeCases(t *testing.T) {
|
|||||||
t.Fatalf("unexpected: %q", got)
|
t.Fatalf("unexpected: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseCustomUserDefined_InvalidInput(t *testing.T) {
|
||||||
|
if _, _, err := parseCustomUserDefined([]string{"bad"}, true); err == nil {
|
||||||
|
t.Fatalf("expected error for missing '='")
|
||||||
|
}
|
||||||
|
if _, _, err := parseCustomUserDefined([]string{"=value"}, true); err == nil {
|
||||||
|
t.Fatalf("expected error for empty key")
|
||||||
|
}
|
||||||
|
if _, _, err := parseCustomUserDefined([]string{""}, false); err == nil {
|
||||||
|
t.Fatalf("expected error for empty custom value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCustomUserDefined_ValidInput(t *testing.T) {
|
||||||
|
fields, clear, err := parseCustomUserDefined([]string{"team=devops", " repo = gog"}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if clear {
|
||||||
|
t.Fatalf("did not expect clear")
|
||||||
|
}
|
||||||
|
if len(fields) != 2 || fields[0].Key != "team" || fields[0].Value != "devops" || fields[1].Key != "repo" || fields[1].Value != "gog" {
|
||||||
|
t.Fatalf("unexpected fields: %#v", fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCustomUserDefined_ClearAll(t *testing.T) {
|
||||||
|
fields, clear, err := parseCustomUserDefined([]string{""}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !clear {
|
||||||
|
t.Fatalf("expected clear")
|
||||||
|
}
|
||||||
|
if len(fields) != 0 {
|
||||||
|
t.Fatalf("expected empty fields, got %v", len(fields))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -68,7 +68,7 @@ func TestExecute_ContactsGet_ByResource_Text(t *testing.T) {
|
|||||||
t.Cleanup(func() { newPeopleContactsService = origNew })
|
t.Cleanup(func() { newPeopleContactsService = origNew })
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !(strings.Contains(r.URL.Path, "/people/c1") && r.Method == http.MethodGet) {
|
if !(strings.Contains(r.URL.Path, "/people/c1") && r.Method == http.MethodGet && !strings.Contains(r.URL.Path, ":")) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -106,6 +106,56 @@ func TestExecute_ContactsGet_ByResource_Text(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecute_ContactsGet_CustomFieldsSorted_Text(t *testing.T) {
|
||||||
|
origNew := newPeopleContactsService
|
||||||
|
t.Cleanup(func() { newPeopleContactsService = origNew })
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !(strings.Contains(r.URL.Path, "/people/c1") && r.Method == http.MethodGet) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"resourceName": "people/c1",
|
||||||
|
"userDefined": []map[string]any{
|
||||||
|
{"key": "zzz", "value": "3"},
|
||||||
|
{"key": "aaa", "value": "1"},
|
||||||
|
{"key": "mmm", "value": "2"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc, err := people.NewService(context.Background(),
|
||||||
|
option.WithoutAuthentication(),
|
||||||
|
option.WithHTTPClient(srv.Client()),
|
||||||
|
option.WithEndpoint(srv.URL+"/"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewService: %v", err)
|
||||||
|
}
|
||||||
|
newPeopleContactsService = func(context.Context, string) (*people.Service, error) { return svc, nil }
|
||||||
|
|
||||||
|
out := captureStdout(t, func() {
|
||||||
|
_ = captureStderr(t, func() {
|
||||||
|
if err := Execute([]string{"--account", "a@b.com", "contacts", "get", "people/c1"}); err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
a := strings.Index(out, "custom:aaa\t1")
|
||||||
|
b := strings.Index(out, "custom:mmm\t2")
|
||||||
|
c := strings.Index(out, "custom:zzz\t3")
|
||||||
|
if a < 0 || b < 0 || c < 0 {
|
||||||
|
t.Fatalf("missing custom fields: %q", out)
|
||||||
|
}
|
||||||
|
if !(a < b && b < c) {
|
||||||
|
t.Fatalf("custom fields not sorted: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecute_CalendarFreeBusy_Text(t *testing.T) {
|
func TestExecute_CalendarFreeBusy_Text(t *testing.T) {
|
||||||
origNew := newCalendarService
|
origNew := newCalendarService
|
||||||
t.Cleanup(func() { newCalendarService = origNew })
|
t.Cleanup(func() { newCalendarService = origNew })
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user