Skip to content

Commit b84f500

Browse files
authored
Merge pull request #8 from corruptmem/feat/tz-flag
feat: add --tz flag to agenda and mail commands
2 parents b86e1d4 + 01c4ce7 commit b84f500

4 files changed

Lines changed: 465 additions & 30 deletions

File tree

cmd/msx/main.go

Lines changed: 285 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"flag"
77
"fmt"
88
"io"
9+
"net/http"
910
"net/url"
1011
"os"
1112
"path/filepath"
@@ -33,25 +34,29 @@ var (
3334
const usageText = `usage: msx [--profile name] [--format text|json] <command> [flags]
3435
3536
commands:
36-
login Start device-code login and save tokens locally
37-
import-op Import an existing Microsoft account from 1Password
38-
state-export Export one or all local profiles as JSON backup
39-
state-import Import profile state from a JSON backup
40-
version Show CLI version/build provenance
41-
profiles List configured profiles
42-
whoami Show the current Graph account
43-
mail List mail with optional filters
44-
mail-get Fetch one mail message by id
45-
agenda List calendar events in a time range
46-
event-get Fetch one calendar event by id
47-
files List or search OneDrive files
48-
file-get Fetch one OneDrive item by id
49-
contacts List or search contacts
50-
contact-get Fetch one contact by id
51-
sites Search SharePoint / org sites
52-
site-get Fetch one site by id
53-
next Continue from a returned @odata.nextLink URL
54-
help Show this message
37+
login Start device-code login and save tokens locally
38+
import-op Import an existing Microsoft account from 1Password
39+
state-export Export one or all local profiles as JSON backup
40+
state-import Import profile state from a JSON backup
41+
version Show CLI version/build provenance
42+
profiles List configured profiles
43+
whoami Show the current Graph account
44+
mail List mail with optional filters
45+
mail-get Fetch one mail message by id
46+
agenda List calendar events in a time range
47+
event-get Fetch one calendar event by id
48+
files List or search OneDrive files
49+
file-get Fetch one OneDrive item by id
50+
folder-list List contents of a OneDrive folder by item ID
51+
file-download Download a OneDrive file to a local path
52+
file-move Move a OneDrive item to a new parent folder
53+
folder-create Create a folder in OneDrive
54+
contacts List or search contacts
55+
contact-get Fetch one contact by id
56+
sites Search SharePoint / org sites
57+
site-get Fetch one site by id
58+
next Continue from a returned @odata.nextLink URL
59+
help Show this message
5560
`
5661

5762
func main() {
@@ -111,6 +116,14 @@ func run(args []string) error {
111116
return cmdFiles(s, g, rest)
112117
case "file-get":
113118
return cmdFileGet(s, g, rest)
119+
case "folder-list":
120+
return cmdFolderList(s, g, rest)
121+
case "file-download":
122+
return cmdFileDownload(s, g, rest)
123+
case "file-move":
124+
return cmdFileMove(s, g, rest)
125+
case "folder-create":
126+
return cmdFolderCreate(s, g, rest)
114127
case "contacts":
115128
return cmdContacts(s, g, rest)
116129
case "contact-get":
@@ -344,6 +357,7 @@ func cmdMail(s *store.Store, g globalFlags, args []string) error {
344357
folder := fs.String("folder", "inbox", "well-known mail folder or folder id")
345358
unread := fs.Bool("unread", false, "only include unread messages")
346359
nextLink := fs.String("next-link", "", "continue from a returned @odata.nextLink URL")
360+
tzName := fs.String("tz", "UTC", "IANA timezone for receivedDateTime output (e.g. Europe/London)")
347361
if err := fs.Parse(args); err != nil {
348362
return err
349363
}
@@ -391,6 +405,11 @@ func cmdMail(s *store.Store, g globalFlags, args []string) error {
391405
if *subject != "" {
392406
data["value"] = filterMailBySubject(data["value"], *subject)
393407
}
408+
loc, err := parseLocation(*tzName)
409+
if err != nil {
410+
return err
411+
}
412+
convertMailTZ(data["value"], loc)
394413
return emit(g, "mail", data)
395414
}
396415

@@ -421,6 +440,7 @@ func cmdAgenda(s *store.Store, g globalFlags, args []string) error {
421440
end := fs.String("end", time.Now().UTC().Add(7*24*time.Hour).Format(time.RFC3339), "range end RFC3339")
422441
query := fs.String("query", "", "search text applied client-side to subject/location/organizer")
423442
nextLink := fs.String("next-link", "", "continue from a returned @odata.nextLink URL")
443+
tzName := fs.String("tz", "UTC", "IANA timezone for start/end dateTime output (e.g. Europe/London). Note: Graph returns event times in the calendar's own timezone; --tz converts what is stored in the dateTime field.")
424444
if err := fs.Parse(args); err != nil {
425445
return err
426446
}
@@ -462,6 +482,11 @@ func cmdAgenda(s *store.Store, g globalFlags, args []string) error {
462482
if *query != "" {
463483
data["value"] = filterEvents(data["value"], *query)
464484
}
485+
loc, err := parseLocation(*tzName)
486+
if err != nil {
487+
return err
488+
}
489+
convertAgendaTZ(data["value"], loc)
465490
return emit(g, "agenda", data)
466491
}
467492

@@ -544,6 +569,184 @@ func cmdFileGet(s *store.Store, g globalFlags, args []string) error {
544569
return emit(g, "file-get", data)
545570
}
546571

572+
func cmdFolderList(s *store.Store, g globalFlags, args []string) error {
573+
fs := flag.NewFlagSet("folder-list", flag.ContinueOnError)
574+
top := fs.Int("top", 200, "maximum number of items")
575+
nextLink := fs.String("next-link", "", "continue from a returned @odata.nextLink URL")
576+
if err := fs.Parse(args); err != nil {
577+
return err
578+
}
579+
if fs.NArg() != 1 && *nextLink == "" {
580+
return fmt.Errorf("usage: msx folder-list [--top N] <drive-item-id>")
581+
}
582+
var (
583+
data map[string]any
584+
err error
585+
)
586+
if *nextLink != "" {
587+
if err := validateNextLink(*nextLink); err != nil {
588+
return err
589+
}
590+
data, err = newGraphClient(s, g.profile).RequestURL("GET", *nextLink)
591+
} else {
592+
folderID := fs.Arg(0)
593+
data, err = newGraphClient(s, g.profile).Request("GET",
594+
"/me/drive/items/"+url.PathEscape(folderID)+"/children",
595+
map[string]string{
596+
"$top": fmt.Sprint(*top),
597+
"$select": "id,name,webUrl,file,folder,size,lastModifiedDateTime,parentReference",
598+
})
599+
}
600+
if err != nil {
601+
return err
602+
}
603+
return emit(g, "files", data)
604+
}
605+
606+
func cmdFileDownload(s *store.Store, g globalFlags, args []string) error {
607+
fs := flag.NewFlagSet("file-download", flag.ContinueOnError)
608+
out := fs.String("out", "", "local output path (default: filename in current directory)")
609+
if err := fs.Parse(args); err != nil {
610+
return err
611+
}
612+
if fs.NArg() != 1 {
613+
return fmt.Errorf("usage: msx file-download [--out path] <drive-item-id>")
614+
}
615+
itemID := fs.Arg(0)
616+
617+
// First fetch item metadata to get the name
618+
meta, err := newGraphClient(s, g.profile).Request("GET", "/me/drive/items/"+url.PathEscape(itemID),
619+
map[string]string{"$select": "id,name,size"})
620+
if err != nil {
621+
return err
622+
}
623+
name, _ := meta["name"].(string)
624+
if name == "" {
625+
name = itemID
626+
}
627+
size, _ := meta["size"].(float64)
628+
629+
outPath := *out
630+
if outPath == "" {
631+
outPath = name
632+
}
633+
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
634+
return err
635+
}
636+
637+
// Use the /content endpoint which redirects to a download URL — follow redirect
638+
// Get a fresh token using the already-open store
639+
token, err := auth.RefreshIfNeeded(s, g.profile, 5*time.Minute)
640+
if err != nil {
641+
return err
642+
}
643+
644+
contentURL := "https://graph.microsoft.com/v1.0/me/drive/items/" + url.PathEscape(itemID) + "/content"
645+
req, err := http.NewRequest("GET", contentURL, nil)
646+
if err != nil {
647+
return err
648+
}
649+
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
650+
req.Header.Set("User-Agent", "msx/0")
651+
652+
httpClient := &http.Client{Timeout: 10 * time.Minute}
653+
resp, err := httpClient.Do(req)
654+
if err != nil {
655+
return err
656+
}
657+
defer resp.Body.Close()
658+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
659+
body, _ := io.ReadAll(resp.Body)
660+
return fmt.Errorf("download failed %d: %s", resp.StatusCode, string(body))
661+
}
662+
663+
f, err := os.Create(outPath)
664+
if err != nil {
665+
return err
666+
}
667+
defer f.Close()
668+
written, err := io.Copy(f, resp.Body)
669+
if err != nil {
670+
return err
671+
}
672+
return emit(g, "file-download", map[string]any{
673+
"ok": true,
674+
"item_id": itemID,
675+
"name": name,
676+
"path": outPath,
677+
"bytes": written,
678+
"size": int64(size),
679+
})
680+
}
681+
682+
func cmdFileMove(s *store.Store, g globalFlags, args []string) error {
683+
fs := flag.NewFlagSet("file-move", flag.ContinueOnError)
684+
parentID := fs.String("parent-id", "", "destination folder drive-item ID")
685+
newName := fs.String("name", "", "rename item (optional)")
686+
if err := fs.Parse(args); err != nil {
687+
return err
688+
}
689+
if fs.NArg() != 1 {
690+
return fmt.Errorf("usage: msx file-move --parent-id <folder-id> [--name new-name] <drive-item-id>")
691+
}
692+
if *parentID == "" {
693+
return fmt.Errorf("--parent-id is required")
694+
}
695+
itemID := fs.Arg(0)
696+
body := map[string]any{
697+
"parentReference": map[string]string{"id": *parentID},
698+
}
699+
if *newName != "" {
700+
body["name"] = *newName
701+
}
702+
bodyJSON, err := json.Marshal(body)
703+
if err != nil {
704+
return err
705+
}
706+
data, err := newGraphClient(s, g.profile).RequestWithBody("PATCH", "/me/drive/items/"+url.PathEscape(itemID), nil, bodyJSON)
707+
if err != nil {
708+
return err
709+
}
710+
return emit(g, "file-move", data)
711+
}
712+
713+
func cmdFolderCreate(s *store.Store, g globalFlags, args []string) error {
714+
fs := flag.NewFlagSet("folder-create", flag.ContinueOnError)
715+
parentID := fs.String("parent-id", "", "parent folder drive-item ID (default: drive root)")
716+
parentPath := fs.String("parent-path", "", "parent folder path e.g. 'Documents/Personal Admin'")
717+
conflict := fs.String("conflict", "fail", "behaviour if folder exists: fail|rename|replace")
718+
if err := fs.Parse(args); err != nil {
719+
return err
720+
}
721+
if fs.NArg() != 1 {
722+
return fmt.Errorf("usage: msx folder-create [--parent-id id | --parent-path path] [--conflict fail|rename|replace] <folder-name>")
723+
}
724+
folderName := fs.Arg(0)
725+
var endpoint string
726+
switch {
727+
case *parentID != "":
728+
endpoint = "/me/drive/items/" + url.PathEscape(*parentID) + "/children"
729+
case *parentPath != "":
730+
endpoint = "/me/drive/root:/" + strings.TrimPrefix(*parentPath, "/") + ":/children"
731+
default:
732+
endpoint = "/me/drive/root/children"
733+
}
734+
body := map[string]any{
735+
"name": folderName,
736+
"folder": map[string]any{},
737+
"@microsoft.graph.conflictBehavior": *conflict,
738+
}
739+
bodyJSON, err := json.Marshal(body)
740+
if err != nil {
741+
return err
742+
}
743+
data, err := newGraphClient(s, g.profile).RequestWithBody("POST", endpoint, nil, bodyJSON)
744+
if err != nil {
745+
return err
746+
}
747+
return emit(g, "folder-create", data)
748+
}
749+
547750
func cmdContacts(s *store.Store, g globalFlags, args []string) error {
548751
fs := flag.NewFlagSet("contacts", flag.ContinueOnError)
549752
top := fs.Int("top", 20, "maximum number of contacts")
@@ -988,6 +1191,69 @@ func filterRows(v any, keep func(map[string]any) bool) []map[string]any {
9881191
return out
9891192
}
9901193

1194+
// parseLocation loads an IANA timezone by name. "UTC" is the zero-value default.
1195+
func parseLocation(name string) (*time.Location, error) {
1196+
if name == "" || name == "UTC" {
1197+
return time.UTC, nil
1198+
}
1199+
loc, err := time.LoadLocation(name)
1200+
if err != nil {
1201+
return nil, fmt.Errorf("unknown timezone %q: %w", name, err)
1202+
}
1203+
return loc, nil
1204+
}
1205+
1206+
// convertTZ parses a datetime string (RFC3339 or Graph's truncated RFC3339)
1207+
// and returns it formatted in loc with an explicit numeric offset.
1208+
func convertTZ(loc *time.Location, s string) string {
1209+
if s == "" || loc == time.UTC {
1210+
return s
1211+
}
1212+
// Graph sometimes omits the trailing Z or offset; try a few layouts.
1213+
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.9999999", "2006-01-02T15:04:05"} {
1214+
if t, err := time.Parse(layout, s); err == nil {
1215+
return t.In(loc).Format(time.RFC3339)
1216+
}
1217+
}
1218+
return s // leave unchanged if unparseable
1219+
}
1220+
1221+
// convertMailTZ rewrites receivedDateTime fields in-place.
1222+
func convertMailTZ(v any, loc *time.Location) {
1223+
if loc == time.UTC {
1224+
return
1225+
}
1226+
items, ok := v.([]map[string]any)
1227+
if !ok {
1228+
return
1229+
}
1230+
for _, item := range items {
1231+
if s, ok := item["receivedDateTime"].(string); ok {
1232+
item["receivedDateTime"] = convertTZ(loc, s)
1233+
}
1234+
}
1235+
}
1236+
1237+
// convertAgendaTZ rewrites start.dateTime and end.dateTime in-place.
1238+
func convertAgendaTZ(v any, loc *time.Location) {
1239+
if loc == time.UTC {
1240+
return
1241+
}
1242+
items, ok := v.([]map[string]any)
1243+
if !ok {
1244+
return
1245+
}
1246+
for _, item := range items {
1247+
for _, key := range []string{"start", "end"} {
1248+
if nested, ok := item[key].(map[string]any); ok {
1249+
if s, ok := nested["dateTime"].(string); ok {
1250+
nested["dateTime"] = convertTZ(loc, s)
1251+
}
1252+
}
1253+
}
1254+
}
1255+
}
1256+
9911257
func requirePositive(name string, v int) error {
9921258
if v <= 0 {
9931259
return fmt.Errorf("%s must be > 0", name)

0 commit comments

Comments
 (0)