|
6 | 6 | "flag" |
7 | 7 | "fmt" |
8 | 8 | "io" |
| 9 | + "net/http" |
9 | 10 | "net/url" |
10 | 11 | "os" |
11 | 12 | "path/filepath" |
@@ -33,25 +34,29 @@ var ( |
33 | 34 | const usageText = `usage: msx [--profile name] [--format text|json] <command> [flags] |
34 | 35 |
|
35 | 36 | 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 |
55 | 60 | ` |
56 | 61 |
|
57 | 62 | func main() { |
@@ -111,6 +116,14 @@ func run(args []string) error { |
111 | 116 | return cmdFiles(s, g, rest) |
112 | 117 | case "file-get": |
113 | 118 | 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) |
114 | 127 | case "contacts": |
115 | 128 | return cmdContacts(s, g, rest) |
116 | 129 | case "contact-get": |
@@ -344,6 +357,7 @@ func cmdMail(s *store.Store, g globalFlags, args []string) error { |
344 | 357 | folder := fs.String("folder", "inbox", "well-known mail folder or folder id") |
345 | 358 | unread := fs.Bool("unread", false, "only include unread messages") |
346 | 359 | 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)") |
347 | 361 | if err := fs.Parse(args); err != nil { |
348 | 362 | return err |
349 | 363 | } |
@@ -391,6 +405,11 @@ func cmdMail(s *store.Store, g globalFlags, args []string) error { |
391 | 405 | if *subject != "" { |
392 | 406 | data["value"] = filterMailBySubject(data["value"], *subject) |
393 | 407 | } |
| 408 | + loc, err := parseLocation(*tzName) |
| 409 | + if err != nil { |
| 410 | + return err |
| 411 | + } |
| 412 | + convertMailTZ(data["value"], loc) |
394 | 413 | return emit(g, "mail", data) |
395 | 414 | } |
396 | 415 |
|
@@ -421,6 +440,7 @@ func cmdAgenda(s *store.Store, g globalFlags, args []string) error { |
421 | 440 | end := fs.String("end", time.Now().UTC().Add(7*24*time.Hour).Format(time.RFC3339), "range end RFC3339") |
422 | 441 | query := fs.String("query", "", "search text applied client-side to subject/location/organizer") |
423 | 442 | 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.") |
424 | 444 | if err := fs.Parse(args); err != nil { |
425 | 445 | return err |
426 | 446 | } |
@@ -462,6 +482,11 @@ func cmdAgenda(s *store.Store, g globalFlags, args []string) error { |
462 | 482 | if *query != "" { |
463 | 483 | data["value"] = filterEvents(data["value"], *query) |
464 | 484 | } |
| 485 | + loc, err := parseLocation(*tzName) |
| 486 | + if err != nil { |
| 487 | + return err |
| 488 | + } |
| 489 | + convertAgendaTZ(data["value"], loc) |
465 | 490 | return emit(g, "agenda", data) |
466 | 491 | } |
467 | 492 |
|
@@ -544,6 +569,184 @@ func cmdFileGet(s *store.Store, g globalFlags, args []string) error { |
544 | 569 | return emit(g, "file-get", data) |
545 | 570 | } |
546 | 571 |
|
| 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 | + |
547 | 750 | func cmdContacts(s *store.Store, g globalFlags, args []string) error { |
548 | 751 | fs := flag.NewFlagSet("contacts", flag.ContinueOnError) |
549 | 752 | 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 { |
988 | 1191 | return out |
989 | 1192 | } |
990 | 1193 |
|
| 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 | + |
991 | 1257 | func requirePositive(name string, v int) error { |
992 | 1258 | if v <= 0 { |
993 | 1259 | return fmt.Errorf("%s must be > 0", name) |
|
0 commit comments