vdirsyncer-graph 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphpim/__init__.py +13 -0
- graphpim/_datetime.py +68 -0
- graphpim/_ical.py +62 -0
- graphpim/contacts.py +176 -0
- graphpim/events.py +200 -0
- graphpim/recurrence.py +176 -0
- graphpim/todos.py +166 -0
- graphpim/vcard.py +124 -0
- vdirsyncer_graph/__init__.py +19 -0
- vdirsyncer_graph/cli.py +27 -0
- vdirsyncer_graph/client.py +348 -0
- vdirsyncer_graph/login.py +61 -0
- vdirsyncer_graph/storage.py +402 -0
- vdirsyncer_graph-0.1.0.dist-info/METADATA +181 -0
- vdirsyncer_graph-0.1.0.dist-info/RECORD +18 -0
- vdirsyncer_graph-0.1.0.dist-info/WHEEL +4 -0
- vdirsyncer_graph-0.1.0.dist-info/entry_points.txt +2 -0
- vdirsyncer_graph-0.1.0.dist-info/licenses/LICENSE +480 -0
graphpim/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""graphpim: Microsoft Graph JSON <-> iCalendar / vCard mapping.
|
|
2
|
+
|
|
3
|
+
Pure functions, no network. The semantics mirror the Microsoft 365 (Graph)
|
|
4
|
+
resource in kdepim-runtime (resources/graph) and are pinned by the fixtures in
|
|
5
|
+
``fixtures/`` so that other implementations can verify against the same cases.
|
|
6
|
+
|
|
7
|
+
SPDX-FileCopyrightText: 2026 Malte Zilinski <malte@zilinski.eu>
|
|
8
|
+
SPDX-License-Identifier: LGPL-2.0-or-later
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from graphpim import contacts, events, todos
|
|
12
|
+
|
|
13
|
+
__all__ = ["contacts", "events", "todos"]
|
graphpim/_datetime.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Graph ``dateTimeTimeZone`` <-> Python datetime helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import date, datetime, timezone, tzinfo
|
|
7
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
GRAPH_FORMAT = "%Y-%m-%dT%H:%M:%S.0000000"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _zone(name: str | None) -> tzinfo:
|
|
15
|
+
"""Resolve a Graph timeZone value (IANA or Windows name) to a tzinfo."""
|
|
16
|
+
if not name or name.upper() == "UTC":
|
|
17
|
+
return timezone.utc
|
|
18
|
+
try:
|
|
19
|
+
return ZoneInfo(name)
|
|
20
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
21
|
+
pass
|
|
22
|
+
try: # Windows names such as "W. Europe Standard Time"
|
|
23
|
+
from tzlocal.windows_tz import win_tz
|
|
24
|
+
|
|
25
|
+
iana = win_tz.get(name)
|
|
26
|
+
if iana:
|
|
27
|
+
return ZoneInfo(iana)
|
|
28
|
+
except ImportError: # pragma: no cover
|
|
29
|
+
pass
|
|
30
|
+
log.warning("unknown Graph time zone %r, falling back to UTC", name)
|
|
31
|
+
return timezone.utc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_graph_datetime(dtz: dict | None, *, all_day: bool = False) -> datetime | date | None:
|
|
35
|
+
"""Graph ``{"dateTime": "...", "timeZone": "..."}`` -> aware datetime (or date).
|
|
36
|
+
|
|
37
|
+
Graph uses 7 fractional digits, which ``datetime.fromisoformat`` rejects on
|
|
38
|
+
older Pythons; the fraction is dropped. All-day values are taken verbatim as a
|
|
39
|
+
date (converting the midnight to another zone would shift the day).
|
|
40
|
+
"""
|
|
41
|
+
if not dtz:
|
|
42
|
+
return None
|
|
43
|
+
raw = dtz.get("dateTime") or ""
|
|
44
|
+
if not raw:
|
|
45
|
+
return None
|
|
46
|
+
raw = raw.split(".", 1)[0]
|
|
47
|
+
try:
|
|
48
|
+
naive = datetime.strptime(raw, "%Y-%m-%dT%H:%M:%S")
|
|
49
|
+
except ValueError:
|
|
50
|
+
return None
|
|
51
|
+
if all_day:
|
|
52
|
+
return naive.date()
|
|
53
|
+
return naive.replace(tzinfo=_zone(dtz.get("timeZone")))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def to_graph_datetime(value: datetime | date) -> dict:
|
|
57
|
+
"""datetime/date -> Graph ``dateTimeTimeZone`` in UTC.
|
|
58
|
+
|
|
59
|
+
Dates (all-day) are written verbatim at midnight; aware datetimes are
|
|
60
|
+
converted to UTC, naive ones are assumed to be UTC.
|
|
61
|
+
"""
|
|
62
|
+
if isinstance(value, datetime):
|
|
63
|
+
if value.tzinfo is None:
|
|
64
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
65
|
+
text = value.astimezone(timezone.utc).strftime(GRAPH_FORMAT)
|
|
66
|
+
else:
|
|
67
|
+
text = f"{value.isoformat()}T00:00:00.0000000"
|
|
68
|
+
return {"dateTime": text, "timeZone": "UTC"}
|
graphpim/_ical.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Shared iCalendar helpers (icalendar package)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, timezone
|
|
6
|
+
|
|
7
|
+
from icalendar import Calendar
|
|
8
|
+
|
|
9
|
+
PRODID = "-//graphpim//Microsoft Graph mapping//EN"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def new_calendar() -> Calendar:
|
|
13
|
+
cal = Calendar()
|
|
14
|
+
cal.add("prodid", PRODID)
|
|
15
|
+
cal.add("version", "2.0")
|
|
16
|
+
return cal
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def first_component(raw: str, name: str):
|
|
20
|
+
"""Parse an iCalendar text and return the first component of ``name``."""
|
|
21
|
+
cal = Calendar.from_ical(raw)
|
|
22
|
+
if cal.name == name:
|
|
23
|
+
return cal
|
|
24
|
+
for comp in cal.walk(name):
|
|
25
|
+
return comp
|
|
26
|
+
raise ValueError(f"no {name} component in item")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def categories(comp) -> list[str]:
|
|
30
|
+
cats: list[str] = []
|
|
31
|
+
for prop in comp.get("categories", []) if isinstance(comp.get("categories"), list) else [comp.get("categories")]:
|
|
32
|
+
if prop is None:
|
|
33
|
+
continue
|
|
34
|
+
if hasattr(prop, "cats"):
|
|
35
|
+
cats.extend(str(c) for c in prop.cats)
|
|
36
|
+
else:
|
|
37
|
+
cats.extend(s.strip() for s in str(prop).split(",") if s.strip())
|
|
38
|
+
return cats
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def text(comp, key: str) -> str:
|
|
42
|
+
v = comp.get(key)
|
|
43
|
+
return "" if v is None else str(v)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def decoded(comp, key: str):
|
|
47
|
+
"""Decoded DTSTART/DTEND/DUE/COMPLETED or None."""
|
|
48
|
+
if comp.get(key) is None:
|
|
49
|
+
return None
|
|
50
|
+
return comp.decoded(key)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def as_utc(value: datetime | date) -> datetime | date:
|
|
54
|
+
if isinstance(value, datetime):
|
|
55
|
+
if value.tzinfo is None:
|
|
56
|
+
return value.replace(tzinfo=timezone.utc)
|
|
57
|
+
return value.astimezone(timezone.utc)
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stamp() -> datetime:
|
|
62
|
+
return datetime.now(timezone.utc).replace(microsecond=0)
|
graphpim/contacts.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Graph ``contact`` <-> vCard 3.0.
|
|
2
|
+
|
|
3
|
+
Semantics (mirroring kdepim-runtime resources/graph/contact/graphcontacthandler.cpp):
|
|
4
|
+
|
|
5
|
+
* N = surname;givenName;middleName;title(prefix); FN = displayName.
|
|
6
|
+
* First e-mail address is TYPE=PREF; order is preserved.
|
|
7
|
+
* businessPhones -> TEL;TYPE=WORK, homePhones -> TEL;TYPE=HOME,
|
|
8
|
+
mobilePhone -> TEL;TYPE=CELL (one mobile number only).
|
|
9
|
+
* businessAddress -> ADR;TYPE=WORK, homeAddress -> ADR;TYPE=HOME
|
|
10
|
+
(street;city;state;postalCode;countryOrRegion). Addresses without a
|
|
11
|
+
``street`` key are ignored on read, as in the KDE handler.
|
|
12
|
+
* birthday is read into BDAY but **never written back**: Exchange reacts to a
|
|
13
|
+
birthday write by creating a recurring event in the Birthdays calendar.
|
|
14
|
+
* The photo is not part of the contact JSON. Callers fetch
|
|
15
|
+
``/me/contacts/{id}/photo/$value`` separately and pass ``photo=(bytes, mime)``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
|
|
23
|
+
from graphpim.vcard import Property, escape, parse, serialize, split_structured
|
|
24
|
+
|
|
25
|
+
_ADDR_KEYS = ("street", "city", "state", "postalCode", "countryOrRegion")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _adr(json: dict, kind: str) -> Property:
|
|
29
|
+
parts = ["", "", json.get("street") or "", json.get("city") or "", json.get("state") or "",
|
|
30
|
+
json.get("postalCode") or "", json.get("countryOrRegion") or ""]
|
|
31
|
+
return Property("ADR", ";".join(escape(p) for p in parts), {"TYPE": [kind]})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def graph_to_vcard(json: dict, photo: tuple[bytes, str] | None = None) -> str:
|
|
35
|
+
props: list[Property] = []
|
|
36
|
+
if json.get("id"):
|
|
37
|
+
props.append(Property("UID", escape(json["id"])))
|
|
38
|
+
n = [json.get("surname") or "", json.get("givenName") or "", json.get("middleName") or "",
|
|
39
|
+
json.get("title") or "", ""]
|
|
40
|
+
props.append(Property("N", ";".join(escape(p) for p in n)))
|
|
41
|
+
fn = json.get("displayName") or " ".join(p for p in (json.get("givenName"), json.get("surname")) if p)
|
|
42
|
+
props.append(Property("FN", escape(fn)))
|
|
43
|
+
if json.get("nickName"):
|
|
44
|
+
props.append(Property("NICKNAME", escape(json["nickName"])))
|
|
45
|
+
|
|
46
|
+
for i, e in enumerate(json.get("emailAddresses") or []):
|
|
47
|
+
addr = (e or {}).get("address")
|
|
48
|
+
if addr:
|
|
49
|
+
params = {"TYPE": ["INTERNET", "PREF"]} if i == 0 else {"TYPE": ["INTERNET"]}
|
|
50
|
+
props.append(Property("EMAIL", escape(addr), params))
|
|
51
|
+
|
|
52
|
+
for num in json.get("businessPhones") or []:
|
|
53
|
+
if num:
|
|
54
|
+
props.append(Property("TEL", escape(num), {"TYPE": ["WORK", "VOICE"]}))
|
|
55
|
+
for num in json.get("homePhones") or []:
|
|
56
|
+
if num:
|
|
57
|
+
props.append(Property("TEL", escape(num), {"TYPE": ["HOME", "VOICE"]}))
|
|
58
|
+
if json.get("mobilePhone"):
|
|
59
|
+
props.append(Property("TEL", escape(json["mobilePhone"]), {"TYPE": ["CELL"]}))
|
|
60
|
+
|
|
61
|
+
if json.get("jobTitle"):
|
|
62
|
+
props.append(Property("TITLE", escape(json["jobTitle"])))
|
|
63
|
+
org = json.get("companyName") or ""
|
|
64
|
+
dept = json.get("department") or ""
|
|
65
|
+
if org or dept:
|
|
66
|
+
props.append(Property("ORG", f"{escape(org)};{escape(dept)}" if dept else escape(org)))
|
|
67
|
+
if json.get("personalNotes"):
|
|
68
|
+
props.append(Property("NOTE", escape(json["personalNotes"])))
|
|
69
|
+
|
|
70
|
+
business = json.get("businessAddress") or {}
|
|
71
|
+
if business and "street" in business:
|
|
72
|
+
props.append(_adr(business, "WORK"))
|
|
73
|
+
home = json.get("homeAddress") or {}
|
|
74
|
+
if home and "street" in home:
|
|
75
|
+
props.append(_adr(home, "HOME"))
|
|
76
|
+
|
|
77
|
+
birthday = json.get("birthday")
|
|
78
|
+
if birthday:
|
|
79
|
+
try:
|
|
80
|
+
props.append(Property("BDAY", datetime.fromisoformat(birthday.replace("Z", "+00:00")).date().isoformat()))
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
cats = [c for c in json.get("categories") or [] if c]
|
|
85
|
+
if cats:
|
|
86
|
+
props.append(Property("CATEGORIES", ",".join(escape(c) for c in cats)))
|
|
87
|
+
|
|
88
|
+
if photo and photo[0]:
|
|
89
|
+
data, mime = photo
|
|
90
|
+
sub = (mime.split("/", 1)[1] if "/" in mime else mime or "jpeg").upper()
|
|
91
|
+
props.append(Property("PHOTO", base64.b64encode(data).decode("ascii"), {"ENCODING": ["b"], "TYPE": [sub]}))
|
|
92
|
+
return serialize(props)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _types(prop: Property) -> set[str]:
|
|
96
|
+
return set(prop.param("TYPE"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def vcard_to_graph(raw: str) -> dict:
|
|
100
|
+
"""vCard text -> Graph contact JSON body for POST/PATCH (photo excluded)."""
|
|
101
|
+
props = parse(raw)
|
|
102
|
+
by_name: dict[str, list[Property]] = {}
|
|
103
|
+
for p in props:
|
|
104
|
+
by_name.setdefault(p.name, []).append(p)
|
|
105
|
+
|
|
106
|
+
def one(name: str) -> str:
|
|
107
|
+
return split_structured(by_name[name][0].value, "\x00")[0] if name in by_name else ""
|
|
108
|
+
|
|
109
|
+
json: dict = {}
|
|
110
|
+
n = split_structured(by_name["N"][0].value) if "N" in by_name else []
|
|
111
|
+
n += [""] * (5 - len(n))
|
|
112
|
+
json["surname"] = n[0]
|
|
113
|
+
json["givenName"] = n[1]
|
|
114
|
+
json["middleName"] = n[2]
|
|
115
|
+
json["title"] = n[3]
|
|
116
|
+
json["nickName"] = one("NICKNAME")
|
|
117
|
+
json["displayName"] = one("FN")
|
|
118
|
+
json["jobTitle"] = one("TITLE")
|
|
119
|
+
org = split_structured(by_name["ORG"][0].value) if "ORG" in by_name else []
|
|
120
|
+
json["companyName"] = org[0] if org else ""
|
|
121
|
+
json["department"] = org[1] if len(org) > 1 else ""
|
|
122
|
+
json["personalNotes"] = one("NOTE")
|
|
123
|
+
|
|
124
|
+
emails = [p for p in by_name.get("EMAIL", [])]
|
|
125
|
+
emails.sort(key=lambda p: 0 if "PREF" in _types(p) or "PREF" in p.params else 1)
|
|
126
|
+
json["emailAddresses"] = [{"address": split_structured(p.value, "\x00")[0]} for p in emails if p.value]
|
|
127
|
+
|
|
128
|
+
business, home, mobile = [], [], ""
|
|
129
|
+
for p in by_name.get("TEL", []):
|
|
130
|
+
num = split_structured(p.value, "\x00")[0]
|
|
131
|
+
if not num:
|
|
132
|
+
continue
|
|
133
|
+
t = _types(p)
|
|
134
|
+
if "CELL" in t:
|
|
135
|
+
mobile = mobile or num
|
|
136
|
+
elif "HOME" in t:
|
|
137
|
+
home.append(num)
|
|
138
|
+
else:
|
|
139
|
+
business.append(num)
|
|
140
|
+
json["businessPhones"] = business
|
|
141
|
+
json["homePhones"] = home
|
|
142
|
+
if mobile:
|
|
143
|
+
json["mobilePhone"] = mobile
|
|
144
|
+
|
|
145
|
+
for p in by_name.get("ADR", []):
|
|
146
|
+
parts = split_structured(p.value)
|
|
147
|
+
parts += [""] * (7 - len(parts))
|
|
148
|
+
addr = dict(zip(_ADDR_KEYS, parts[2:7]))
|
|
149
|
+
if not any(addr.values()):
|
|
150
|
+
continue
|
|
151
|
+
key = "homeAddress" if "HOME" in _types(p) else "businessAddress"
|
|
152
|
+
json.setdefault(key, addr)
|
|
153
|
+
|
|
154
|
+
# BDAY deliberately not written (see module docstring).
|
|
155
|
+
cats: list[str] = []
|
|
156
|
+
for p in by_name.get("CATEGORIES", []):
|
|
157
|
+
cats.extend(c for c in split_structured(p.value, ",") if c)
|
|
158
|
+
json["categories"] = cats
|
|
159
|
+
return json
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def vcard_photo(raw: str) -> tuple[bytes, str] | None:
|
|
163
|
+
"""Extract an inline PHOTO (base64) as (bytes, mime) for the photo endpoint."""
|
|
164
|
+
for p in parse(raw):
|
|
165
|
+
if p.name != "PHOTO":
|
|
166
|
+
continue
|
|
167
|
+
enc = {v.upper() for v in p.param("ENCODING")}
|
|
168
|
+
value = p.value
|
|
169
|
+
if value.lower().startswith("data:"): # vCard 4 data URI
|
|
170
|
+
header, _, b64 = value.partition(",")
|
|
171
|
+
mime = header[5:].split(";", 1)[0] or "image/jpeg"
|
|
172
|
+
return base64.b64decode(b64), mime
|
|
173
|
+
if "B" in enc or "BASE64" in enc:
|
|
174
|
+
sub = (p.param("TYPE") or ["JPEG"])[0].lower()
|
|
175
|
+
return base64.b64decode(value), f"image/{sub}"
|
|
176
|
+
return None
|
graphpim/events.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Graph ``event`` <-> VEVENT.
|
|
2
|
+
|
|
3
|
+
Semantics (mirroring kdepim-runtime resources/graph/calendar/grapheventhandler.cpp):
|
|
4
|
+
|
|
5
|
+
* UID = ``iCalUId``, falling back to the Graph ``id``.
|
|
6
|
+
* Only ``body.contentType == "text"`` is mapped to DESCRIPTION; HTML bodies fall
|
|
7
|
+
back to ``bodyPreview``. Written bodies are always plain text.
|
|
8
|
+
* Times are written in UTC. All-day events are DATE values; Graph's all-day end
|
|
9
|
+
is exclusive, exactly like iCalendar DTEND, so no +-1 day is needed here
|
|
10
|
+
(KCalendarCore's inclusive end was a KDE-only concern).
|
|
11
|
+
* ``showAs == "free"`` -> TRANSP:TRANSPARENT, everything else busy/OPAQUE.
|
|
12
|
+
* ``sensitivity`` private/confidential -> CLASS.
|
|
13
|
+
* Attendee ``status.response`` -> PARTSTAT.
|
|
14
|
+
* Recurrence via :mod:`graphpim.recurrence`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from datetime import date, datetime
|
|
20
|
+
|
|
21
|
+
from icalendar import Event, vCalAddress, vRecur
|
|
22
|
+
|
|
23
|
+
from graphpim import _ical
|
|
24
|
+
from graphpim._datetime import parse_graph_datetime, to_graph_datetime
|
|
25
|
+
from graphpim.recurrence import graph_to_rrule, rrule_to_graph
|
|
26
|
+
|
|
27
|
+
_RESPONSE_TO_PARTSTAT = {
|
|
28
|
+
"accepted": "ACCEPTED",
|
|
29
|
+
"declined": "DECLINED",
|
|
30
|
+
"tentativelyAccepted": "TENTATIVE",
|
|
31
|
+
"notResponded": "NEEDS-ACTION",
|
|
32
|
+
"none": "NEEDS-ACTION",
|
|
33
|
+
"organizer": "ACCEPTED",
|
|
34
|
+
}
|
|
35
|
+
_PARTSTAT_TO_RESPONSE = {
|
|
36
|
+
"ACCEPTED": "accepted",
|
|
37
|
+
"DECLINED": "declined",
|
|
38
|
+
"TENTATIVE": "tentativelyAccepted",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def graph_to_event(json: dict) -> Event | None:
|
|
43
|
+
"""Graph event JSON -> icalendar.Event. None when the JSON carries no ``id``."""
|
|
44
|
+
gid = json.get("id") or ""
|
|
45
|
+
if not gid:
|
|
46
|
+
return None
|
|
47
|
+
ev = Event()
|
|
48
|
+
ev.add("uid", json.get("iCalUId") or gid)
|
|
49
|
+
ev.add("dtstamp", _ical.stamp())
|
|
50
|
+
ev.add("summary", json.get("subject") or "")
|
|
51
|
+
|
|
52
|
+
body = json.get("body") or {}
|
|
53
|
+
if body.get("contentType") == "text":
|
|
54
|
+
description = body.get("content") or ""
|
|
55
|
+
else:
|
|
56
|
+
description = json.get("bodyPreview") or ""
|
|
57
|
+
if description:
|
|
58
|
+
ev.add("description", description)
|
|
59
|
+
|
|
60
|
+
all_day = bool(json.get("isAllDay"))
|
|
61
|
+
start = parse_graph_datetime(json.get("start"), all_day=all_day)
|
|
62
|
+
end = parse_graph_datetime(json.get("end"), all_day=all_day)
|
|
63
|
+
if start is not None:
|
|
64
|
+
ev.add("dtstart", start)
|
|
65
|
+
if end is not None:
|
|
66
|
+
ev.add("dtend", end)
|
|
67
|
+
|
|
68
|
+
location = (json.get("location") or {}).get("displayName") or ""
|
|
69
|
+
if location:
|
|
70
|
+
ev.add("location", location)
|
|
71
|
+
|
|
72
|
+
organizer = (json.get("organizer") or {}).get("emailAddress") or {}
|
|
73
|
+
if organizer.get("address"):
|
|
74
|
+
org = vCalAddress(f"mailto:{organizer['address']}")
|
|
75
|
+
if organizer.get("name"):
|
|
76
|
+
org.params["CN"] = organizer["name"]
|
|
77
|
+
ev.add("organizer", org, encode=0)
|
|
78
|
+
|
|
79
|
+
for att in json.get("attendees") or []:
|
|
80
|
+
email = (att.get("emailAddress") or {})
|
|
81
|
+
if not email.get("address"):
|
|
82
|
+
continue
|
|
83
|
+
addr = vCalAddress(f"mailto:{email['address']}")
|
|
84
|
+
if email.get("name"):
|
|
85
|
+
addr.params["CN"] = email["name"]
|
|
86
|
+
response = (att.get("status") or {}).get("response")
|
|
87
|
+
if response in _RESPONSE_TO_PARTSTAT:
|
|
88
|
+
addr.params["PARTSTAT"] = _RESPONSE_TO_PARTSTAT[response]
|
|
89
|
+
att_type = att.get("type")
|
|
90
|
+
if att_type == "optional":
|
|
91
|
+
addr.params["ROLE"] = "OPT-PARTICIPANT"
|
|
92
|
+
elif att_type == "resource":
|
|
93
|
+
addr.params["CUTYPE"] = "RESOURCE"
|
|
94
|
+
ev.add("attendee", addr, encode=0)
|
|
95
|
+
|
|
96
|
+
cats = [c for c in json.get("categories") or [] if c]
|
|
97
|
+
if cats:
|
|
98
|
+
ev.add("categories", cats)
|
|
99
|
+
|
|
100
|
+
ev.add("transp", "TRANSPARENT" if json.get("showAs") == "free" else "OPAQUE")
|
|
101
|
+
sensitivity = json.get("sensitivity")
|
|
102
|
+
if sensitivity == "private":
|
|
103
|
+
ev.add("class", "PRIVATE")
|
|
104
|
+
elif sensitivity == "confidential":
|
|
105
|
+
ev.add("class", "CONFIDENTIAL")
|
|
106
|
+
|
|
107
|
+
if json.get("isCancelled"):
|
|
108
|
+
ev.add("status", "CANCELLED")
|
|
109
|
+
|
|
110
|
+
rrule = graph_to_rrule(json.get("recurrence"))
|
|
111
|
+
if rrule:
|
|
112
|
+
ev.add("rrule", vRecur(rrule))
|
|
113
|
+
return ev
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def graph_to_ical(json: dict) -> str | None:
|
|
117
|
+
"""Graph event JSON -> full VCALENDAR text (or None without ``id``)."""
|
|
118
|
+
ev = graph_to_event(json)
|
|
119
|
+
if ev is None:
|
|
120
|
+
return None
|
|
121
|
+
cal = _ical.new_calendar()
|
|
122
|
+
cal.add_component(ev)
|
|
123
|
+
return cal.to_ical().decode("utf-8")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def event_to_graph(ev: Event) -> dict:
|
|
127
|
+
"""icalendar.Event -> Graph event JSON body for POST/PATCH."""
|
|
128
|
+
json: dict = {"subject": _ical.text(ev, "summary")}
|
|
129
|
+
json["body"] = {"contentType": "text", "content": _ical.text(ev, "description")}
|
|
130
|
+
|
|
131
|
+
start = _ical.decoded(ev, "dtstart")
|
|
132
|
+
end = _ical.decoded(ev, "dtend")
|
|
133
|
+
all_day = isinstance(start, date) and not isinstance(start, datetime)
|
|
134
|
+
json["isAllDay"] = all_day
|
|
135
|
+
if start is not None:
|
|
136
|
+
if end is None:
|
|
137
|
+
duration = ev.get("duration")
|
|
138
|
+
if duration is not None:
|
|
139
|
+
end = start + duration.dt
|
|
140
|
+
elif all_day:
|
|
141
|
+
end = start + (date(2000, 1, 2) - date(2000, 1, 1))
|
|
142
|
+
else:
|
|
143
|
+
end = start
|
|
144
|
+
if all_day and isinstance(end, datetime):
|
|
145
|
+
end = end.date()
|
|
146
|
+
json["start"] = to_graph_datetime(_ical.as_utc(start))
|
|
147
|
+
json["end"] = to_graph_datetime(_ical.as_utc(end))
|
|
148
|
+
|
|
149
|
+
location = _ical.text(ev, "location")
|
|
150
|
+
if location:
|
|
151
|
+
json["location"] = {"displayName": location}
|
|
152
|
+
|
|
153
|
+
cats = _ical.categories(ev)
|
|
154
|
+
if cats:
|
|
155
|
+
json["categories"] = cats
|
|
156
|
+
|
|
157
|
+
json["showAs"] = "free" if _ical.text(ev, "transp").upper() == "TRANSPARENT" else "busy"
|
|
158
|
+
klass = _ical.text(ev, "class").upper()
|
|
159
|
+
if klass == "PRIVATE":
|
|
160
|
+
json["sensitivity"] = "private"
|
|
161
|
+
elif klass == "CONFIDENTIAL":
|
|
162
|
+
json["sensitivity"] = "confidential"
|
|
163
|
+
elif klass:
|
|
164
|
+
json["sensitivity"] = "normal"
|
|
165
|
+
|
|
166
|
+
attendees = []
|
|
167
|
+
raw_attendees = ev.get("attendee")
|
|
168
|
+
if raw_attendees is not None:
|
|
169
|
+
if not isinstance(raw_attendees, list):
|
|
170
|
+
raw_attendees = [raw_attendees]
|
|
171
|
+
for a in raw_attendees:
|
|
172
|
+
address = str(a)
|
|
173
|
+
if address.lower().startswith("mailto:"):
|
|
174
|
+
address = address[7:]
|
|
175
|
+
if not address:
|
|
176
|
+
continue
|
|
177
|
+
entry: dict = {"emailAddress": {"address": address}}
|
|
178
|
+
name = a.params.get("CN") if hasattr(a, "params") else None
|
|
179
|
+
if name:
|
|
180
|
+
entry["emailAddress"]["name"] = str(name)
|
|
181
|
+
partstat = str(a.params.get("PARTSTAT", "")).upper() if hasattr(a, "params") else ""
|
|
182
|
+
if partstat in _PARTSTAT_TO_RESPONSE:
|
|
183
|
+
entry["status"] = {"response": _PARTSTAT_TO_RESPONSE[partstat], "time": "0001-01-01T00:00:00Z"}
|
|
184
|
+
role = str(a.params.get("ROLE", "")).upper() if hasattr(a, "params") else ""
|
|
185
|
+
cutype = str(a.params.get("CUTYPE", "")).upper() if hasattr(a, "params") else ""
|
|
186
|
+
entry["type"] = "resource" if cutype == "RESOURCE" else ("optional" if role == "OPT-PARTICIPANT" else "required")
|
|
187
|
+
attendees.append(entry)
|
|
188
|
+
if attendees:
|
|
189
|
+
json["attendees"] = attendees
|
|
190
|
+
|
|
191
|
+
if start is not None:
|
|
192
|
+
recurrence = rrule_to_graph(ev.get("rrule"), start)
|
|
193
|
+
if recurrence:
|
|
194
|
+
json["recurrence"] = recurrence
|
|
195
|
+
return json
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def ical_to_graph(raw: str) -> dict:
|
|
199
|
+
"""VCALENDAR/VEVENT text -> Graph event JSON body."""
|
|
200
|
+
return event_to_graph(_ical.first_component(raw, "VEVENT"))
|