diff --git a/CHANGELOG.md b/CHANGELOG.md index b29bc69..27de1bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [Unreleased] + +- `calendar list` selects `lastModifiedDateTime`, and takes `--select` to override the `$select` + list as `mail list` does. +- `meetings list --with-transcripts` adds `transcripts` to each `--json` item: the id and + `createdDateTime` of every transcript, so a consumer can pick the one nearest an occurrence of + a recurring meeting. `transcriptIds` is unchanged. +- Graph timestamps whose seconds fraction has 1, 2, 4 or 5 digits (Graph trims trailing zeros, as + in `2026-10-22T15:53:38.4Z`) parse. They used to count as undatable on Python 3.10, so + `chats list --since` kept old chats and paged on past the window edge, the other date windows + kept such items, and an event time with such a fraction stopped the command with a + `ValueError`. + ## [0.4.1] — 2026-11-05 - `mail list --from` and `--to` match any of their values. Several values, repeated or diff --git a/skills/mgraphctl/reference/commands/calendar.md b/skills/mgraphctl/reference/commands/calendar.md index ed9bb84..7098abd 100644 --- a/skills/mgraphctl/reference/commands/calendar.md +++ b/skills/mgraphctl/reference/commands/calendar.md @@ -12,17 +12,22 @@ are in [`../commands.md`](../commands.md). | `--days N` | 7 | Days from `--start`, when `--end` is not given. | | `--calendar NAME\|ID` | the default calendar | Which calendar to read. | | `--search KW` | — | Client-side match on subject, organizer and attendees. | +| `--select a,b` | — | `$select` override. | | `--limit N` | 50 | Maximum items. | | `--all` | off | Fetch every page, cap 200. | | `--json` | off | Print JSON instead of text. | - **Graph:** `GET /me/calendarView` (or `/me/calendars/{id}/calendarView`) with `startDateTime`, `endDateTime`, - `$select=id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink`, - `$orderby=start/dateTime`, `$top=50`, and `Prefer: outlook.timezone`. + `$select=id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink,lastModifiedDateTime` + (or the `--select` list), `$orderby=start/dateTime`, `$top=50`, and `Prefer: outlook.timezone`. - **Scopes:** `Calendars.Read` - **Notes:** `calendarView` expands recurring series into occurrences. Columns: start, end, markers - (`T` Teams meeting, `X` cancelled), subject, organizer, location, id. + (`T` Teams meeting, `X` cancelled), subject, organizer, location, id. `lastModifiedDateTime` + changes whenever the event does, so it serves as a change fingerprint. A `--select` list must + keep the fields the text columns read (`start`, `end`, `subject`, …) or they show `N/A`; + `--json` prints whatever the list names. `--search` matches only on the fields `--select` + returns, so a list without `subject`, `organizer` and `attendees` matches nothing. ### `calendar calendars` diff --git a/skills/mgraphctl/reference/commands/meetings.md b/skills/mgraphctl/reference/commands/meetings.md index 5484434..ce80da7 100644 --- a/skills/mgraphctl/reference/commands/meetings.md +++ b/skills/mgraphctl/reference/commands/meetings.md @@ -14,7 +14,7 @@ All `meetings` verbs except `list` select the meeting three ways: a positional o | `--end DT` | end of today | End of the window. | | `--subject KW` | — | Case-insensitive subject substring. | | `--resolve` | off | Resolve each event to its online-meeting id. | -| `--with-transcripts` | off | Also list transcript ids (implies `--resolve`). | +| `--with-transcripts` | off | Also list each transcript's id and creation time (implies `--resolve`). | | `--limit N` | 50 | Maximum items. | | `--json` | off | Print JSON instead of text. | @@ -25,6 +25,10 @@ All `meetings` verbs except `list` select the meeting three ways: a positional o - **Scopes:** `Calendars.Read`; `--resolve` additionally checks `OnlineMeetings.Read`, and `--with-transcripts` `OnlineMeetingTranscript.Read.All`. - **Notes:** columns: start, subject, event id, meeting id (when resolved), transcript ids. + In `--json`, each item carries `transcriptIds` and `transcripts`, a list of + `{id, createdDateTime}` as Graph returns them. Occurrences of a recurring meeting share one + online meeting, so every occurrence lists the same transcripts; `createdDateTime` tells which + one belongs to the occurrence. ### `meetings get [MEETING]` diff --git a/src/mgraphctl/commands/calendar.py b/src/mgraphctl/commands/calendar.py index c571b67..07c185a 100644 --- a/src/mgraphctl/commands/calendar.py +++ b/src/mgraphctl/commands/calendar.py @@ -81,6 +81,9 @@ def list_( search: Annotated[ str | None, typer.Option("--search", help="Client-side match on subject/attendees.") ] = None, + select: Annotated[ + str | None, typer.Option("--select", help="Comma-separated $select override.") + ] = None, limit: LimitOpt = None, all_: AllFlag = False, json_: JsonFlag = False, @@ -101,7 +104,7 @@ def list_( start=start_dt, end=end_dt, calendar_id=calendar_id, - select=None, + select=select, limit=limit, all_=all_, tz=tz, diff --git a/src/mgraphctl/commands/meetings.py b/src/mgraphctl/commands/meetings.py index 1d41d6b..115cce0 100644 --- a/src/mgraphctl/commands/meetings.py +++ b/src/mgraphctl/commands/meetings.py @@ -72,7 +72,10 @@ def list_( ] = False, with_transcripts: Annotated[ bool, - typer.Option("--with-transcripts", help="Also list transcript ids (implies --resolve)."), + typer.Option( + "--with-transcripts", + help="Also list each transcript's id and creation time (implies --resolve).", + ), ] = False, limit: LimitOpt = None, json_: JsonFlag = False, @@ -99,6 +102,7 @@ def list_( for event in events: meeting = resolved.get(event["id"]) meeting_id = meeting.get("id") if meeting else None + found = transcripts.get(meeting_id, []) if with_transcripts and meeting_id else [] items.append( { "eventId": event.get("id"), @@ -107,11 +111,10 @@ def list_( "end": event.get("end"), "organizer": event.get("organizer"), "meetingId": meeting_id if do_resolve else None, - "transcriptIds": ( - [t.get("id") for t in transcripts.get(meeting_id, [])] - if with_transcripts and meeting_id - else [] - ), + "transcriptIds": [t.get("id") for t in found], + "transcripts": [ + {"id": t.get("id"), "createdDateTime": t.get("createdDateTime")} for t in found + ], } ) return ListResult( diff --git a/src/mgraphctl/graph/calendar.py b/src/mgraphctl/graph/calendar.py index 0272910..ad561e4 100644 --- a/src/mgraphctl/graph/calendar.py +++ b/src/mgraphctl/graph/calendar.py @@ -18,7 +18,8 @@ LIST_SELECT = ( "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting," - "isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink" + "isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink," + "lastModifiedDateTime" ) PAGE_LIST, CAP_LIST = 50, 200 diff --git a/src/mgraphctl/render.py b/src/mgraphctl/render.py index be2120e..ce279ba 100644 --- a/src/mgraphctl/render.py +++ b/src/mgraphctl/render.py @@ -261,7 +261,7 @@ class FileResult: Result = ListResult | ObjectResult | TextResult | WriteResult | DryRunResult | FileResult -_FRACTIONAL_RE = re.compile(r"(\.\d{6})\d+") +_FRACTIONAL_RE = re.compile(r"(?<=\d\d:\d\d:\d\d)\.(\d+)") _RELATIVE_RE = re.compile(r"^([+-])(\d+)([dh])$") _DATE_ONLY_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") _SIMPLE_DURATION_RE = re.compile(r"^(\d+)([mhd])$") @@ -333,11 +333,20 @@ def local_tz() -> str: return "UTC" +def _six_digit_fraction(value: str) -> str: + """Pad or cut the seconds fraction to six digits. + + Graph trims trailing zeros (`.4Z`) or sends seven digits, and Python 3.10's + `datetime.fromisoformat` accepts only three or six. + """ + return _FRACTIONAL_RE.sub(lambda m: "." + m.group(1).ljust(6, "0")[:6], value) + + def parse_graph_dt(value: str | None) -> datetime | None: """A Graph timestamp as an aware datetime (bare ones are UTC), or None when unusable.""" if not value: return None - text = _FRACTIONAL_RE.sub(r"\1", value) + text = _six_digit_fraction(value) if text.endswith("Z"): text = text[:-1] + "+00:00" try: @@ -369,7 +378,7 @@ def fmt_dtz(obj: dict | None, tz: str) -> str: if date_time is None: return "N/A" if zone == "UTC" or is_iana(zone): - v = _FRACTIONAL_RE.sub(r"\1", date_time) + v = _six_digit_fraction(date_time) dt = datetime.fromisoformat(v).replace(tzinfo=ZoneInfo(zone)) return dt.astimezone(ZoneInfo(tz)).isoformat(timespec="minutes") return f"{date_time} ({zone})" @@ -377,9 +386,10 @@ def fmt_dtz(obj: dict | None, tz: str) -> str: def fmt_event_time(event: dict, key: str, tz: str) -> str: """Render an event's start/end: all-day events as `YYYY-MM-DD (all day)`.""" - if event.get("isAllDay"): - return f"{event[key]['dateTime'][:10]} (all day)" - return fmt_dtz(event[key], tz) + value = event.get(key) + if event.get("isAllDay") and value and value.get("dateTime"): + return f"{value['dateTime'][:10]} (all day)" + return fmt_dtz(value, tz) def fmt_date(value: str | None) -> str: diff --git a/tests/fixtures/calendar/list.json b/tests/fixtures/calendar/list.json index 796527c..1c5ea98 100644 --- a/tests/fixtures/calendar/list.json +++ b/tests/fixtures/calendar/list.json @@ -5,7 +5,7 @@ "query": { "startDateTime": "2026-09-02T09:00:00+02:00", "endDateTime": "2026-09-09T09:00:00+02:00", - "$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink", + "$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink,lastModifiedDateTime", "$orderby": "start/dateTime", "$top": "50" }, diff --git a/tests/fixtures/calendar/list_all_cap.json b/tests/fixtures/calendar/list_all_cap.json index e72d7c5..6566e13 100644 --- a/tests/fixtures/calendar/list_all_cap.json +++ b/tests/fixtures/calendar/list_all_cap.json @@ -5,7 +5,7 @@ "query": { "startDateTime": "2026-09-01T00:00:00+02:00", "endDateTime": "2026-09-08T23:59:59+02:00", - "$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink", + "$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink,lastModifiedDateTime", "$orderby": "start/dateTime", "$top": "50" }, diff --git a/tests/fixtures/calendar/list_by_name.json b/tests/fixtures/calendar/list_by_name.json index 2ae7a78..fb0a70e 100644 --- a/tests/fixtures/calendar/list_by_name.json +++ b/tests/fixtures/calendar/list_by_name.json @@ -20,7 +20,7 @@ "query": { "startDateTime": "2026-09-02T09:00:00+02:00", "endDateTime": "2026-09-09T09:00:00+02:00", - "$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink", + "$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink,lastModifiedDateTime", "$orderby": "start/dateTime", "$top": "50" }, diff --git a/tests/fixtures/calendar/list_cancelled.json b/tests/fixtures/calendar/list_cancelled.json index 9a1d70b..68ee0e1 100644 --- a/tests/fixtures/calendar/list_cancelled.json +++ b/tests/fixtures/calendar/list_cancelled.json @@ -5,7 +5,7 @@ "query": { "startDateTime": "2026-09-02T09:00:00+02:00", "endDateTime": "2026-09-09T09:00:00+02:00", - "$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink", + "$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink,lastModifiedDateTime", "$orderby": "start/dateTime", "$top": "50" }, diff --git a/tests/test_cli_calendar.py b/tests/test_cli_calendar.py index 8400340..7fd2149 100644 --- a/tests/test_cli_calendar.py +++ b/tests/test_cli_calendar.py @@ -34,7 +34,7 @@ def test_calendar_list_window_and_columns(invoke, graph, monkeypatch): "endDateTime": "2026-09-09T09:00:00+02:00", "$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting," "onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview," - "webLink", + "webLink,lastModifiedDateTime", "$orderby": "start/dateTime", "$top": "50", } @@ -55,6 +55,36 @@ def test_calendar_list_window_and_columns(invoke, graph, monkeypatch): assert "2026-09-03 (all day)" in r.stdout +@covers("calendar list") +def test_calendar_list_select_overrides_the_default(invoke, graph, monkeypatch): + _fix_now(monkeypatch) + route = graph.get(f"{GRAPH}/v1.0/me/calendarView").mock( + return_value=httpx.Response( + 200, + json={ + "value": [ + { + "id": "ev-1", + "subject": "Standup", + "lastModifiedDateTime": "2026-09-01T08:00:00.4Z", + } + ] + }, + ) + ) + r = invoke("calendar", "list", "--select", "id,subject,lastModifiedDateTime", "--json") + assert r.exit_code == 0, r.stderr + assert json.loads(r.stdout)["items"][0]["lastModifiedDateTime"] == "2026-09-01T08:00:00.4Z" + params = route.calls.last.request.url.params + assert params["$select"] == "id,subject,lastModifiedDateTime" + assert params["$orderby"] == "start/dateTime" + # Text mode renders the columns --select left out as N/A. + text = invoke("calendar", "list", "--select", "id,subject,lastModifiedDateTime") + assert text.exit_code == 0, text.stderr + row = text.stdout.splitlines()[1].split() + assert row[:2] == ["N/A", "N/A"] and "Standup" in row and row[-1] == "ev-1" + + @covers("calendar list") def test_calendar_list_start_end_days_and_aliases(invoke, graph, monkeypatch): _fix_now(monkeypatch) diff --git a/tests/test_cli_chats.py b/tests/test_cli_chats.py index 31e6103..dafbf26 100644 --- a/tests/test_cli_chats.py +++ b/tests/test_cli_chats.py @@ -155,6 +155,42 @@ def test_chats_list_since_keeps_a_chat_with_no_preview_timestamp(invoke, graph): ] +@covers("chats list") +def test_chats_list_since_drops_and_stops_at_a_short_fraction_timestamp(invoke, graph): + """A one-digit fraction (`.4Z`) still dates the chat, so it is dropped and stops the paging.""" + second_page = graph.get(f"{GRAPH}/v1.0/me/chats-page-2").mock( + return_value=httpx.Response(200, json={"value": []}) + ) + graph.get(f"{GRAPH}/v1.0/me/chats").mock( + return_value=httpx.Response( + 200, + json={ + "value": [ + { + "id": "19:new@thread.v2", + "chatType": "group", + "topic": "Recent", + "lastMessagePreview": {"createdDateTime": "2026-10-27T09:00:00.12Z"}, + }, + { + "id": "19:old@thread.v2", + "chatType": "group", + "topic": "Old", + "lastMessagePreview": {"createdDateTime": "2026-10-22T15:53:38.4Z"}, + }, + ], + "@odata.nextLink": f"{GRAPH}/v1.0/me/chats-page-2", + }, + ) + ) + r = invoke("chats", "list", "--since", "2026-10-26", "--all", "--json") + assert r.exit_code == 0, r.stderr + doc = json.loads(r.stdout) + assert [c["id"] for c in doc["items"]] == ["19:new@thread.v2"] + assert doc["truncated"] is False + assert not second_page.called + + @covers("chats list") def test_chats_list_since_keeps_a_chat_exactly_on_the_bound(invoke, graph): graph.get(f"{GRAPH}/v1.0/me/chats").mock( diff --git a/tests/test_cli_meetings.py b/tests/test_cli_meetings.py index 12ae3b0..983289c 100644 --- a/tests/test_cli_meetings.py +++ b/tests/test_cli_meetings.py @@ -33,6 +33,9 @@ def test_meetings_list_default_window_resolve_and_transcripts(invoke, graph, mon doc = json.loads(r.stdout) assert doc["count"] == 2 and doc["items"][0]["meetingId"] == "MSpk-1" assert doc["items"][0]["transcriptIds"] == ["tr-1"] + assert doc["items"][0]["transcripts"] == [ + {"id": "tr-1", "createdDateTime": "2026-08-28T10:05:00Z"} + ] text = invoke("meetings", "list", "--resolve", "--with-transcripts").stdout.splitlines() assert text[0].split() == ["start", "subject", "event_id", "meeting_id", "transcripts"] diff --git a/tests/test_render.py b/tests/test_render.py index 633c0fd..2386d73 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -29,6 +29,54 @@ def test_parse_graph_dt_treats_a_naive_timestamp_as_utc(): ) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("2026-10-22T15:53:38.4Z", datetime(2026, 10, 22, 15, 53, 38, 400000, tzinfo=timezone.utc)), + ( + "2026-10-22T15:53:38.45Z", + datetime(2026, 10, 22, 15, 53, 38, 450000, tzinfo=timezone.utc), + ), + ( + "2026-10-22T15:53:38.4567Z", + datetime(2026, 10, 22, 15, 53, 38, 456700, tzinfo=timezone.utc), + ), + ( + "2026-10-22T15:53:38.45678Z", + datetime(2026, 10, 22, 15, 53, 38, 456780, tzinfo=timezone.utc), + ), + ( + "2026-10-22T15:53:38.1234567Z", + datetime(2026, 10, 22, 15, 53, 38, 123456, tzinfo=timezone.utc), + ), + ("2026-10-22T15:53:38Z", datetime(2026, 10, 22, 15, 53, 38, tzinfo=timezone.utc)), + ( + "2026-10-22T15:53:38.4+03:00", + datetime(2026, 10, 22, 12, 53, 38, 400000, tzinfo=timezone.utc), + ), + ("2026-10-22T15:53:38.4", datetime(2026, 10, 22, 15, 53, 38, 400000, tzinfo=timezone.utc)), + ], +) +def test_parse_graph_dt_accepts_any_fraction_length(value, expected): + """Graph trims trailing zeros from the fraction; any length from one digit up parses.""" + assert render.parse_graph_dt(value) == expected + + +def test_fraction_outside_the_seconds_is_left_alone(): + assert render._six_digit_fraction("2026-10-22T15:53.5Z") == "2026-10-22T15:53.5Z" + assert ( + render._six_digit_fraction("v1.2 2026-10-22T15:53:38.4Z") + == "v1.2 2026-10-22T15:53:38.400000Z" + ) + + +def test_fmt_dtz_accepts_a_short_fraction(): + assert ( + render.fmt_dtz({"dateTime": "2026-08-31T08:15:00.5", "timeZone": "UTC"}, "UTC") + == "2026-08-31T08:15+00:00" + ) + + def test_parse_graph_dt_returns_none_for_empty_and_unparsable(): assert render.parse_graph_dt(None) is None assert render.parse_graph_dt("") is None diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 0eea024..bbc165d 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -19,7 +19,8 @@ ) EVENT_SELECT = ( "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting," - "isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink" + "isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink," + "lastModifiedDateTime" )