parro-api-client 0.5.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.
parro/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Read-only client for the Parro REST API (talk.parro.com), shared by the CLI and the MCP server.
2
+
3
+ Everything that talks to Parro or to the ParnasSys login server lives in this package. The two entry
4
+ points only translate between their own interface (command line, MCP JSON-RPC) and this core.
5
+
6
+ This package must never:
7
+ * send anything other than GET to the Parro API (POST goes only to the IdP's token, revoke and
8
+ login-form endpoints);
9
+ * write tokens, passwords or full attachment URLs to a log;
10
+ * start a password login on its own after a password was rejected (see authenticator.py).
11
+
12
+ Inspired by fobatai/parro-mcp; independent implementation based on openapi/parro.openapi.json.
13
+ """
14
+
15
+ # The one place the version lives. pyproject.toml repeats it; a test keeps the two equal, and the
16
+ # release workflow checks that the git tag matches.
17
+ __version__ = "0.5.0"
parro/api_client.py ADDED
@@ -0,0 +1,413 @@
1
+ """ParroClient: read-only access to the Parro REST API (https://rest-v2.parro.com/rest/v2).
2
+
3
+ get() is the one low-level call; every endpoint in the spec has a small method on top of it
4
+ (get_account, list_groups, list_events, get_event, ...). They return the API's own JSON, untouched;
5
+ summaries.py shapes it for people.
6
+
7
+ Rules for this module:
8
+ * Only GET. There is no method that could change anything at Parro.
9
+ * Requests only go to the configured API host, so a token can never be sent elsewhere.
10
+ * A 401 leads to one refresh and one retry, never to a password login (PLAN-api-client §4, fix 3).
11
+ A second 401 raises TokenRejected, which explains the `dtype` pitfall of GET /event/{id}.
12
+ """
13
+
14
+ import logging
15
+ import urllib.parse
16
+ from dataclasses import dataclass
17
+ from typing import Any, Literal
18
+
19
+ from .authenticator import Authenticator
20
+ from .cache import Cache
21
+ from .errors import ApiError, ConfigurationError, TokenRejected
22
+ from .geon import (
23
+ build_range_header,
24
+ get_collection_items,
25
+ get_object_id,
26
+ parse_total_from_content_range,
27
+ )
28
+ from .http import HttpRequest, HttpResponse, HttpTransport, UrllibTransport, add_query_parameters
29
+ from .settings import Settings
30
+ from .token_store import TokenStore
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+ # dtype values that select what an endpoint returns (spec: GET /event, GET /event/{id}, GET /group).
35
+ ANNOUNCEMENT_LIST_DTYPE = "event.RAnnouncementEventPrimer"
36
+ CALENDAR_LIST_DTYPE = "event.RCalendarItemEventPrimer"
37
+ # RAnnouncementEvent answers 401 (not 406) on GET /event/{id}; the Primer type works (fix 2).
38
+ ANNOUNCEMENT_DTYPE = "event.RAnnouncementEventPrimer"
39
+ CALENDAR_ITEM_DTYPE = "event.RCalendarItemEvent"
40
+ CURRENT_GROUPS_DTYPE = "identity.RHomeGroup"
41
+ PREVIOUS_GROUPS_SCOPE = "PREVIOUS_ACTIVE"
42
+ # The groups request of the official client asks for 50; enough for any family.
43
+ GROUPS_PAGE_SIZE = 50
44
+ PRIVACY_CHILD_DTYPE = "identity.RPrivacySettingChild"
45
+
46
+ EventKind = Literal["announcement", "calendar"]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ApiResponse:
51
+ """A parsed answer: the JSON payload and, for collections, the Content-Range header."""
52
+
53
+ payload: Any
54
+ content_range: str | None
55
+
56
+ @property
57
+ def items(self) -> list[Any]:
58
+ return get_collection_items(self.payload)
59
+
60
+ @property
61
+ def total(self) -> int | None:
62
+ return parse_total_from_content_range(self.content_range)
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class FeedEvent:
67
+ """One event from a group's feed, with the name of the group it was listed under."""
68
+
69
+ event: dict[str, Any]
70
+ group_name: str | None
71
+
72
+
73
+ class ParroClient:
74
+ """Talks to the Parro API on behalf of one logged-in account."""
75
+
76
+ def __init__(
77
+ self,
78
+ settings: Settings,
79
+ transport: HttpTransport | None = None,
80
+ token_store: TokenStore | None = None,
81
+ authenticator: Authenticator | None = None,
82
+ ) -> None:
83
+ self.settings = settings
84
+ self.transport = transport or UrllibTransport(settings.http_timeout_seconds)
85
+ self.token_store = token_store or TokenStore(
86
+ settings.token_file, bootstrap_access_token=settings.bootstrap_access_token
87
+ )
88
+ self.authenticator = authenticator or Authenticator(
89
+ settings, self.transport, self.token_store
90
+ )
91
+ # Attachments and calendar details only (PLAN-readable-output §3b); lists never.
92
+ self.cache = Cache(
93
+ settings.data_dir / "cache", settings.cache_max_bytes, enabled=settings.cache_enabled
94
+ )
95
+ self._account: dict[str, Any] | None = None
96
+ self._role_header: str | None = None
97
+
98
+ @classmethod
99
+ def from_environment(cls) -> "ParroClient":
100
+ return cls(Settings.from_environment())
101
+
102
+ # ------------------------------------------------------------------------- low level
103
+
104
+ def get(
105
+ self,
106
+ path: str,
107
+ params: dict[str, Any] | None = None,
108
+ limit: int | None = None,
109
+ offset: int = 0,
110
+ count_total: bool = False,
111
+ send_role: bool = True,
112
+ allow_password_login: bool = True,
113
+ refresh_on_unauthorized: bool = True,
114
+ ) -> ApiResponse:
115
+ """GET an API path such as '/child' or '/event?dtype=...&group=1'.
116
+
117
+ `limit` sends a Range header (and `count_total` asks for an exact total). `send_role`
118
+ False leaves out parro-authorization-role, which GET /account/me must be called without.
119
+ `refresh_on_unauthorized` False reports a 401 at once (for probing a dtype).
120
+ """
121
+ url = add_query_parameters(self.build_api_url(path), params or {})
122
+ label = f"GET {urllib.parse.urlsplit(url).path}"
123
+ headers = self._build_headers(limit, offset, count_total, send_role, allow_password_login)
124
+ response = self._send_with_one_refresh(url, headers, label, refresh_on_unauthorized)
125
+ if response.status not in (200, 206):
126
+ raise _api_error(label, response)
127
+ return ApiResponse(response.json(), response.header("Content-Range"))
128
+
129
+ def download_from_api_host(self, url: str) -> HttpResponse:
130
+ """GET a file that lives on the API host itself (not the CDN), with the Parro headers."""
131
+ if not url.startswith(self.settings.api_base_uri + "/"):
132
+ raise ConfigurationError("Only URLs on the configured Parro API host get a token.")
133
+ headers = self._build_headers(None, 0, False, True, True)
134
+ headers["Accept"] = "*/*"
135
+ response = self._send_with_one_refresh(url, headers, "GET file on the API host", True)
136
+ if response.status not in (200, 206):
137
+ raise _api_error("GET file on the API host", response)
138
+ return response
139
+
140
+ def _send_with_one_refresh(
141
+ self, url: str, headers: dict[str, str], label: str, refresh_on_unauthorized: bool
142
+ ) -> HttpResponse:
143
+ """Send; on a 401 refresh once and resend once (fix 3). A 401 never leads to a login."""
144
+ response = self._send(url, headers, label)
145
+ if response.status == 401 and refresh_on_unauthorized:
146
+ new_access_token = self.authenticator.refresh_after_unauthorized()
147
+ if new_access_token is not None:
148
+ headers["Authorization"] = f"Bearer {new_access_token}"
149
+ response = self._send(url, headers, label)
150
+ if response.status == 401:
151
+ raise TokenRejected(
152
+ f"{label} answered 401 Unauthorized. Either the login is no longer valid (log "
153
+ "in again), or this endpoint refuses the requested `dtype` (GET /event/{id} "
154
+ "answers 401 for dtype=event.RAnnouncementEvent).",
155
+ status=401,
156
+ )
157
+ return response
158
+
159
+ def build_api_url(self, path: str) -> str:
160
+ """Turn '/child' (or '/rest/v2/child') into a full URL on the configured API host."""
161
+ cleaned = path.strip()
162
+ if "://" in cleaned or cleaned.startswith("//"):
163
+ raise ConfigurationError(
164
+ "Give an API path such as /accountsettings, not a full URL: the token is only "
165
+ "ever sent to the configured Parro API host."
166
+ )
167
+ if not cleaned.startswith("/"):
168
+ cleaned = "/" + cleaned
169
+ if cleaned.startswith("/rest/"):
170
+ return self.settings.api_base_uri + cleaned
171
+ return self.settings.api_root + cleaned
172
+
173
+ def _build_headers(
174
+ self,
175
+ limit: int | None,
176
+ offset: int,
177
+ count_total: bool,
178
+ send_role: bool,
179
+ allow_password_login: bool,
180
+ ) -> dict[str, str]:
181
+ access_token = self.authenticator.get_access_token(allow_password_login)
182
+ # The web client sends the geon media type as Accept *and* Content-Type, even on GET.
183
+ headers = {
184
+ "Authorization": f"Bearer {access_token}",
185
+ "Accept": self.settings.geon_media_type,
186
+ "Content-Type": self.settings.geon_media_type,
187
+ "parro-app-version": self.settings.app_version,
188
+ }
189
+ if send_role:
190
+ role = self.get_role_header(allow_password_login)
191
+ if role:
192
+ headers["parro-authorization-role"] = role
193
+ if limit is not None:
194
+ headers["Range"] = build_range_header(offset, limit)
195
+ if count_total:
196
+ headers["topicus-Range-Count"] = "Exact"
197
+ return headers
198
+
199
+ def _send(self, url: str, headers: dict[str, str], label: str) -> HttpResponse:
200
+ return self.transport.send(HttpRequest("GET", url, dict(headers), log_label=label))
201
+
202
+ # ---------------------------------------------------------------------- account and role
203
+
204
+ def get_account(self, allow_password_login: bool = True) -> dict[str, Any]:
205
+ """GET /account/me (cached). Called without the role header, which is derived from it."""
206
+ if self._account is None:
207
+ response = self.get(
208
+ "/account/me", send_role=False, allow_password_login=allow_password_login
209
+ )
210
+ self._account = response.payload if isinstance(response.payload, dict) else {}
211
+ return self._account
212
+
213
+ def get_role_header(self, allow_password_login: bool = True) -> str | None:
214
+ """'GUARDIAN:<id>' for parro-authorization-role: PARRO_ROLE, else derived and cached.
215
+
216
+ The id is that of the first guardian (or teacher, or child) identity of the account
217
+ (spec: Conventions → Client headers).
218
+ """
219
+ if self.settings.role_override:
220
+ return self.settings.role_override
221
+ if self._role_header is None:
222
+ self._role_header = derive_role_header(self.get_account(allow_password_login))
223
+ return self._role_header
224
+
225
+ # -------------------------------------------------------------------- simple endpoints
226
+
227
+ def get_account_settings(self) -> ApiResponse:
228
+ return self.get("/accountsettings")
229
+
230
+ def get_organisation(self, organisation_id: int) -> ApiResponse:
231
+ return self.get(f"/organisation/{int(organisation_id)}")
232
+
233
+ def list_children(self, limit: int = 20) -> ApiResponse:
234
+ return self.get("/child", limit=limit, count_total=True)
235
+
236
+ def get_unread_counts(self) -> ApiResponse:
237
+ return self.get("/identity/unreadcounts")
238
+
239
+ def list_groups(
240
+ self, previous_year: bool = False, limit: int = GROUPS_PAGE_SIZE
241
+ ) -> ApiResponse:
242
+ """Current groups (dtype=identity.RHomeGroup) or last year's (scope=PREVIOUS_ACTIVE)."""
243
+ params = (
244
+ {"scope": PREVIOUS_GROUPS_SCOPE} if previous_year else {"dtype": CURRENT_GROUPS_DTYPE}
245
+ )
246
+ return self.get("/group", params, limit=limit, count_total=True)
247
+
248
+ def list_system_news(self, limit: int = 20) -> ApiResponse:
249
+ return self.get("/systemnews", limit=limit, count_total=True)
250
+
251
+ def get_calendar_link(self) -> str | None:
252
+ """GET /calendar/sync: the personal iCalendar subscription link, or None if there is none.
253
+
254
+ The link is a secret (anyone holding it can read the calendar), so it is never logged.
255
+ """
256
+ payload = self.get("/calendar/sync").payload
257
+ strings = payload.get("strings") if isinstance(payload, dict) else None
258
+ links = [
259
+ link for link in strings or [] if isinstance(link, str) and link.startswith("http")
260
+ ]
261
+ return links[0] if links else None
262
+
263
+ def list_children_privacy(self) -> ApiResponse:
264
+ """GET /child, privacy variant: per child, the state of the privacy choices (spec)."""
265
+ params = {"currentSchoolYear": "true", "dtype": PRIVACY_CHILD_DTYPE}
266
+ return self.get("/child", params, count_total=True)
267
+
268
+ def get_privacy_link(self, child_id: int) -> str | None:
269
+ """GET /child/{id}/privacy: the signed link to the page where the choices are set.
270
+
271
+ Like the calendar link it is a secret (it opens the page without a login), so it is never
272
+ logged, cached or archived.
273
+ """
274
+ payload = self.get(f"/child/{int(child_id)}/privacy").payload
275
+ link = payload.get("url") if isinstance(payload, dict) else None
276
+ return link if isinstance(link, str) and link.startswith("https://") else None
277
+
278
+ def get_absence_settings(self) -> ApiResponse:
279
+ return self.get("/absence/setting")
280
+
281
+ def list_chatrooms(self, limit: int = 25, offset: int = 0) -> ApiResponse:
282
+ return self.get("/chatroom", limit=limit, offset=offset, count_total=True)
283
+
284
+ def list_chat_messages(self, chatroom_id: int, limit: int = 30, offset: int = 0) -> ApiResponse:
285
+ """Messages of one chat, newest first (spec)."""
286
+ return self.get(
287
+ f"/chatroom/{int(chatroom_id)}/chatmessage",
288
+ limit=limit,
289
+ offset=offset,
290
+ count_total=True,
291
+ )
292
+
293
+ # ---------------------------------------------------------------------------- events
294
+
295
+ def list_events(
296
+ self,
297
+ group_id: int,
298
+ event_dtype: str,
299
+ limit: int = 20,
300
+ offset: int = 0,
301
+ count_total: bool = False,
302
+ ) -> ApiResponse:
303
+ """GET /event for one group. `event_dtype` picks announcements or calendar items.
304
+
305
+ Both come newest first. For calendar items that means the furthest future first: the
306
+ coming weeks can be several pages in (see operations.collect_calendar).
307
+ """
308
+ return self.get(
309
+ "/event",
310
+ {"dtype": event_dtype, "group": int(group_id)},
311
+ limit=limit,
312
+ offset=offset,
313
+ count_total=count_total,
314
+ )
315
+
316
+ def get_event(self, event_id: int, event_dtype: str) -> dict[str, Any]:
317
+ """GET /event/{id}. The dtype is required: without it the API answers 406."""
318
+ payload = self.get(f"/event/{int(event_id)}", {"dtype": event_dtype}).payload
319
+ return payload if isinstance(payload, dict) else {}
320
+
321
+ def get_announcement(self, announcement_id: int) -> dict[str, Any]:
322
+ return self.get_event(announcement_id, ANNOUNCEMENT_DTYPE)
323
+
324
+ def get_calendar_item(self, event_id: int) -> dict[str, Any]:
325
+ return self.get_event(event_id, CALENDAR_ITEM_DTYPE)
326
+
327
+ def get_announcement_or_calendar_item(self, event_id: int) -> dict[str, Any]:
328
+ """The event behind an id when the caller does not know its type (e.g. `attachment`).
329
+
330
+ Tries the announcement type first without the usual refresh-on-401, because the API may
331
+ answer a wrong dtype with 401 (spec) and that is not a reason to touch the tokens. Then
332
+ tries the calendar-item type the normal way.
333
+ """
334
+ try:
335
+ payload = self.get(
336
+ f"/event/{int(event_id)}",
337
+ {"dtype": ANNOUNCEMENT_DTYPE},
338
+ refresh_on_unauthorized=False,
339
+ ).payload
340
+ except ApiError as first_failure:
341
+ log.info(
342
+ "event is not an announcement (%s); trying calendar item", first_failure.status
343
+ )
344
+ return self.get_calendar_item(event_id)
345
+ return payload if isinstance(payload, dict) else {}
346
+
347
+ def list_current_group_names(self) -> dict[int, str | None]:
348
+ """{group id: group name} for this school year's groups, in the API's order."""
349
+ names: dict[int, str | None] = {}
350
+ for group in self.get(
351
+ "/group", {"dtype": CURRENT_GROUPS_DTYPE}, limit=GROUPS_PAGE_SIZE
352
+ ).items:
353
+ group_id = get_object_id(group)
354
+ if group_id is not None:
355
+ names[group_id] = group.get("name")
356
+ return names
357
+
358
+ def collect_feed(
359
+ self,
360
+ event_dtype: str,
361
+ group_id: int | None = None,
362
+ limit_per_group: int = 20,
363
+ groups: dict[int, str | None] | None = None,
364
+ ) -> list[FeedEvent]:
365
+ """Events of one group, or of every current group (the spec: query each group separately).
366
+
367
+ Events carry the group's name only when several groups were merged. Not sorted: the caller
368
+ decides the order. `groups` ({id: name}) saves the group request when the caller already
369
+ has them, e.g. the overview, which reads two feeds.
370
+ """
371
+ if group_id is not None:
372
+ chosen: dict[int, str | None] = {int(group_id): None}
373
+ else:
374
+ chosen = groups if groups is not None else self.list_current_group_names()
375
+ collected: list[FeedEvent] = []
376
+ for one_group_id, group_name in chosen.items():
377
+ page = self.list_events(one_group_id, event_dtype, limit=limit_per_group)
378
+ collected.extend(FeedEvent(event, group_name) for event in page.items)
379
+ return collected
380
+
381
+
382
+ def derive_role_header(account: dict[str, Any]) -> str | None:
383
+ """'<ACCOUNTTYPE>:<id>' from GET /account/me, or None when the account has no identities."""
384
+ identity = account.get("identity") or {}
385
+ account_type = str(account.get("accountType") or identity.get("role") or "GUARDIAN").upper()
386
+ for identity_list in ("guardians", "teachers", "children"):
387
+ for linked_identity in identity.get(identity_list) or []:
388
+ identity_id = get_object_id(linked_identity)
389
+ if identity_id is not None:
390
+ return f"{account_type}:{identity_id}"
391
+ log.warning("could not derive the role header from /account/me")
392
+ return None
393
+
394
+
395
+ def _api_error(label: str, response: HttpResponse) -> ApiError:
396
+ """An ApiError with the geon error details (applicationError, message) when there are any."""
397
+ try:
398
+ body = response.json()
399
+ except ValueError:
400
+ body = None
401
+ application_error = None
402
+ detail = ""
403
+ if isinstance(body, dict):
404
+ application_error = body.get("applicationError")
405
+ parts = [str(part) for part in (application_error, body.get("message")) if part]
406
+ detail = f": {' - '.join(parts)}" if parts else ""
407
+ elif response.body:
408
+ detail = f": {response.text()[:200]}"
409
+ return ApiError(
410
+ f"{label} answered HTTP {response.status}{detail}",
411
+ status=response.status,
412
+ application_error=application_error,
413
+ )
parro/archive.py ADDED
@@ -0,0 +1,235 @@
1
+ """The local archive written by the `sync` command (PLAN-readable-output §3b, release 0.3.0).
2
+
3
+ <archive>/
4
+ announcements/2026-10-22-nieuwsbrief-nr-3-5000000101.md (+ .json)
5
+ announcements/attachments/5000000101-6000000101-Nieuwsbrief-3.pdf
6
+ calendar/2026-11-11-sint-maartenviering-7000000101.md (+ .json, + attachments/)
7
+ chats/rijden-naar-het-natuurmuseum-8000000001.md (+ .json)
8
+ index.md (everything, newest first)
9
+ .sync-state.json (what is archived, for the next run)
10
+
11
+ This module only writes files; operations.sync_archive() decides what to fetch.
12
+
13
+ Rules:
14
+ * Incremental: an item whose version (its `lastModifiedAt`) is unchanged is not rewritten, and
15
+ a saved attachment is never downloaded again.
16
+ * File names: date + slug of the title + id. When a title changes, the files are renamed, so
17
+ there is never a stale copy next to the new one.
18
+ * Nothing is ever deleted. An item that is no longer in Parro stays, and index.md marks it.
19
+ * Folders 0700, files 0600 (children's names, photos and school mail); no download links in
20
+ any file.
21
+ """
22
+
23
+ import json
24
+ import re
25
+ import unicodedata
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from .cache import remove_urls, write_private_file
32
+
33
+ STATE_FILE = ".sync-state.json"
34
+ INDEX_FILE = "index.md"
35
+ SECTIONS = {"announcement": "announcements", "calendar": "calendar", "chat": "chats"}
36
+ SECTION_TITLES = {"announcement": "Announcements", "calendar": "Calendar", "chat": "Chats"}
37
+ MAX_SLUG_LENGTH = 50
38
+
39
+
40
+ @dataclass
41
+ class SyncCounts:
42
+ """What one run did, per kind of item."""
43
+
44
+ written: int = 0
45
+ unchanged: int = 0
46
+ renamed: int = 0
47
+ no_longer_in_parro: int = 0
48
+
49
+
50
+ @dataclass
51
+ class ArchiveEntry:
52
+ """One archived item, as remembered in the state file."""
53
+
54
+ kind: str
55
+ item_id: int
56
+ version: str
57
+ title: str
58
+ date: str
59
+ path: str # relative to the archive root, e.g. "announcements/2026-...-5000000101.md"
60
+ scope: str # "current", "previous" or "chats": which run is responsible for it
61
+ present: bool = True
62
+ groups: list[str] = field(default_factory=list)
63
+
64
+
65
+ class Archive:
66
+ """The archive folder: its files and its memory of what was written before."""
67
+
68
+ def __init__(self, root: Path) -> None:
69
+ self.root = root
70
+ self.entries: dict[str, ArchiveEntry] = load_state(root / STATE_FILE)
71
+ self.counts: dict[str, SyncCounts] = {kind: SyncCounts() for kind in SECTIONS}
72
+ self._seen: set[str] = set()
73
+
74
+ # ------------------------------------------------------------------------ items
75
+
76
+ def is_current(self, kind: str, item_id: int, version: str) -> bool:
77
+ """True when this version is already archived (and its file still exists)."""
78
+ entry = self.entries.get(entry_key(kind, item_id))
79
+ return entry is not None and entry.version == version and (self.root / entry.path).exists()
80
+
81
+ def keep(self, kind: str, item_id: int) -> None:
82
+ """Note an unchanged item as still present in Parro."""
83
+ key = entry_key(kind, item_id)
84
+ self._seen.add(key)
85
+ self.entries[key].present = True
86
+ self.counts[kind].unchanged += 1
87
+
88
+ def save(self, entry: ArchiveEntry, markdown: str, summary: Any) -> None:
89
+ """Write (or rewrite) one item: its .md with a header, and its .json without links."""
90
+ key = entry_key(entry.kind, entry.item_id)
91
+ self._seen.add(key)
92
+ previous = self.entries.get(key)
93
+ name_date = "" if entry.kind == "chat" else entry.date # chats: no date in the name
94
+ stem = file_stem(name_date, entry.title, entry.item_id)
95
+ entry.path = f"{SECTIONS[entry.kind]}/{stem}.md"
96
+ if previous is not None and previous.path != entry.path:
97
+ self._rename(previous.path, entry.path)
98
+ self.counts[entry.kind].renamed += 1
99
+ header = front_matter(entry)
100
+ self._write(entry.path, (header + markdown).encode("utf-8"))
101
+ self._write(json_path(entry.path), json_text(remove_urls(summary)).encode("utf-8"))
102
+ self.entries[key] = entry
103
+ self.counts[entry.kind].written += 1
104
+
105
+ # ------------------------------------------------------------------ attachments
106
+
107
+ def has_file(self, relative_path: str) -> bool:
108
+ """True when the file is saved. Files are written in one step, so it is complete."""
109
+ return (self.root / relative_path).exists()
110
+
111
+ def save_file(self, relative_path: str, data: bytes) -> None:
112
+ self._write(relative_path, data)
113
+
114
+ # ----------------------------------------------------------------------- finish
115
+
116
+ def finish(self, scopes: set[str], synced_at: datetime) -> None:
117
+ """Mark what the synced scopes no longer contain, then write index.md and the state."""
118
+ for key, entry in self.entries.items():
119
+ if entry.scope in scopes and key not in self._seen and entry.present:
120
+ entry.present = False
121
+ self.counts[entry.kind].no_longer_in_parro += 1
122
+ self._write(INDEX_FILE, build_index(list(self.entries.values()), synced_at).encode("utf-8"))
123
+ self._write(STATE_FILE, json_text(save_state(self.entries)).encode("utf-8"))
124
+
125
+ # ---------------------------------------------------------------------- helpers
126
+
127
+ def _write(self, relative_path: str, data: bytes) -> None:
128
+ write_private_file(self.root / relative_path, data)
129
+
130
+ def _rename(self, old: str, new: str) -> None:
131
+ for source, target in ((old, new), (json_path(old), json_path(new))):
132
+ old_file, new_file = self.root / source, self.root / target
133
+ if old_file.exists():
134
+ new_file.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
135
+ old_file.replace(new_file)
136
+
137
+
138
+ # ----------------------------------------------------------------------- names and text
139
+
140
+
141
+ def entry_key(kind: str, item_id: int) -> str:
142
+ return f"{kind}:{item_id}"
143
+
144
+
145
+ def slugify(title: str) -> str:
146
+ """'Foto's herfstwandeling!' → 'fotos-herfstwandeling' (ASCII, lowercase, at most 50)."""
147
+ ascii_title = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
148
+ words = re.sub(r"[^a-z0-9]+", "-", ascii_title.lower().replace("'", "")).strip("-")
149
+ return words[:MAX_SLUG_LENGTH].rstrip("-") or "untitled"
150
+
151
+
152
+ def file_stem(date: str, title: str, item_id: int) -> str:
153
+ """'2026-10-22-nieuwsbrief-nr-3-5000000101'; chats have no date prefix."""
154
+ prefix = f"{date[:10]}-" if re.match(r"^\d{4}-\d{2}-\d{2}", date or "") else ""
155
+ return f"{prefix}{slugify(title)}-{item_id}"
156
+
157
+
158
+ def attachment_path(kind: str, item_id: int, attachment_id: Any, filename: str | None) -> str:
159
+ """'announcements/attachments/5000000101-6000000101-Nieuwsbrief-3.pdf' (a safe file name).
160
+
161
+ Attachments live next to their item's section, so the link from the item's .md file is
162
+ simply 'attachments/<name>'.
163
+ """
164
+ name = Path(filename or "attachment").name
165
+ safe = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-.") or "attachment"
166
+ return f"{SECTIONS[kind]}/attachments/{item_id}-{attachment_id}-{safe}"
167
+
168
+
169
+ def json_path(markdown_path: str) -> str:
170
+ return markdown_path[: -len(".md")] + ".json"
171
+
172
+
173
+ def json_text(value: Any) -> str:
174
+ return json.dumps(value, ensure_ascii=False, indent=2, default=str) + "\n"
175
+
176
+
177
+ def front_matter(entry: ArchiveEntry) -> str:
178
+ """A YAML header (strings written as JSON strings, which YAML reads as they are)."""
179
+ lines = [
180
+ "---",
181
+ f"id: {entry.item_id}",
182
+ f"type: {entry.kind}",
183
+ f"title: {json.dumps(entry.title, ensure_ascii=False)}",
184
+ f"date: {json.dumps(entry.date)}",
185
+ f"groups: {json.dumps(entry.groups, ensure_ascii=False)}",
186
+ f"version: {json.dumps(entry.version)}",
187
+ "source: Parro, archived with parro-api-client (sync)",
188
+ "---",
189
+ "",
190
+ ]
191
+ return "\n".join(lines) + "\n"
192
+
193
+
194
+ def build_index(entries: list[ArchiveEntry], synced_at: datetime) -> str:
195
+ """index.md: every archived item per kind, newest first, with a mark for removed ones."""
196
+ blocks = [
197
+ "# Parro archive",
198
+ f"Last sync: {synced_at.strftime('%Y-%m-%d %H:%M %Z').strip()}. Items no longer in Parro "
199
+ "stay here and are marked *(no longer in Parro)*.",
200
+ ]
201
+ for kind, title in SECTION_TITLES.items():
202
+ items = sorted((e for e in entries if e.kind == kind), key=lambda e: e.date, reverse=True)
203
+ lines = [index_line(entry) for entry in items]
204
+ blocks.append(f"## {title} ({len(items)})\n" + ("\n".join(lines) if lines else "None yet."))
205
+ return "\n\n".join(blocks) + "\n"
206
+
207
+
208
+ def index_line(entry: ArchiveEntry) -> str:
209
+ when = f"{entry.date[:10]} " if entry.date[:4].isdigit() else ""
210
+ groups = f" · {', '.join(entry.groups)}" if entry.groups else ""
211
+ gone = " *(no longer in Parro)*" if not entry.present else ""
212
+ link_text = entry.title.replace("[", "(").replace("]", ")") or "(no title)"
213
+ return f"- {when}[{link_text}]({entry.path}){groups}{gone}"
214
+
215
+
216
+ # ------------------------------------------------------------------------- state file
217
+
218
+
219
+ def load_state(path: Path) -> dict[str, ArchiveEntry]:
220
+ """The entries of the previous run; an unreadable state file starts a fresh archive."""
221
+ try:
222
+ raw = json.loads(path.read_text(encoding="utf-8"))
223
+ except (OSError, ValueError):
224
+ return {}
225
+ entries = {}
226
+ for key, value in (raw.get("entries") or {}).items():
227
+ try:
228
+ entries[key] = ArchiveEntry(**value)
229
+ except TypeError:
230
+ continue
231
+ return entries
232
+
233
+
234
+ def save_state(entries: dict[str, ArchiveEntry]) -> dict[str, Any]:
235
+ return {"format": 1, "entries": {key: vars(entry) for key, entry in sorted(entries.items())}}