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 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"))