Skip to content

Commit f9c8e05

Browse files
committed
feat(api): add anniversaries ICS feed endpoint
1 parent ea521e0 commit f9c8e05

2 files changed

Lines changed: 289 additions & 0 deletions

File tree

gramps_webapi/api/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
BookmarksResource,
3636
)
3737
from .resources.access_tokens import UserAccessTokenResource
38+
from .resources.anniversaries import AnniversariesIcsResource
3839
from .resources.chat import ChatResource
3940
from .resources.citations import CitationResource, CitationsResource
4041
from .resources.config import ConfigResource, ConfigsResource
@@ -549,6 +550,13 @@ def register_endpt(
549550
"metadata_researcher",
550551
tags=["Metadata"],
551552
)
553+
# Anniversaries
554+
register_endpt(
555+
AnniversariesIcsResource,
556+
"/anniversaries.ics",
557+
"anniversaries_ics",
558+
tags=["Anniversaries"],
559+
)
552560
# User
553561
register_endpt(UsersResource, "/users/", "users", tags=["Users"])
554562
register_endpt(UserResource, "/users/<string:user_name>/", "user", tags=["Users"])
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
#
2+
# Gramps Web API - A RESTful API for the Gramps genealogy program
3+
#
4+
# Copyright (C) 2026 Gramps Web contributors
5+
#
6+
# This program is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as published by
8+
# the Free Software Foundation; either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
12+
"""Anniversaries ICS resource."""
13+
14+
import json
15+
from datetime import datetime, timezone
16+
from typing import Optional
17+
18+
from flask import Response
19+
from gramps.gen.lib import Event
20+
from gramps.gen.lib.date import gregorian
21+
from marshmallow import Schema
22+
from webargs import fields, validate
23+
24+
from ...auth import (
25+
get_permissions,
26+
get_user_from_access_token,
27+
is_tree_disabled,
28+
)
29+
from ...auth.const import ACCESS_TOKEN_SCOPE_ANNIVERSARIES_ICS, PERM_VIEW_PRIVATE
30+
from ..blueprint import api_blueprint
31+
from ..util import (
32+
abort_with_message,
33+
close_db,
34+
get_db_outside_request,
35+
get_tree_id,
36+
)
37+
from . import Resource
38+
from .filters import apply_filter
39+
from .util import get_backlinks, get_event_summary_from_object
40+
41+
42+
def _escape_ics_text(value: str) -> str:
43+
"""Escape text fields according to RFC 5545."""
44+
return (
45+
value.replace("\\", "\\\\")
46+
.replace(";", "\\;")
47+
.replace(",", "\\,")
48+
.replace("\r\n", "\\n")
49+
.replace("\n", "\\n")
50+
)
51+
52+
53+
def _event_matches_type(event: Event, allowed_types: set[str]) -> bool:
54+
"""Check if an event type matches one of the requested filters."""
55+
if not allowed_types:
56+
return True
57+
event_values = {
58+
str(event.get_type()).casefold().strip(),
59+
event.get_type().xml_str().casefold().strip(),
60+
}
61+
return not event_values.isdisjoint(allowed_types)
62+
63+
64+
def _get_anniversary_date_components(event: Event) -> Optional[tuple[int, int, int]]:
65+
"""Get Gregorian (year, month, day) tuple for an event date."""
66+
if event.date is None or not event.date.is_valid():
67+
return None
68+
gdate = gregorian(event.date)
69+
month = gdate.get_month()
70+
day = gdate.get_day()
71+
if month < 1 or day < 1:
72+
return None
73+
year = gdate.get_year()
74+
if year < 1:
75+
year = 1970
76+
if year > 9999:
77+
year = 9999
78+
return year, month, day
79+
80+
81+
def _is_event_in_anchor_scope(
82+
db_handle,
83+
event: Event,
84+
allowed_people: set[str],
85+
allowed_families: set[str],
86+
) -> bool:
87+
"""Check if an event is linked to people/families in anchor scope."""
88+
backlinks = get_backlinks(db_handle, event.handle)
89+
people = set(backlinks.get("person", []))
90+
if not people.isdisjoint(allowed_people):
91+
return True
92+
families = set(backlinks.get("family", []))
93+
return not families.isdisjoint(allowed_families)
94+
95+
96+
def _resolve_anchor_people_handles(
97+
db_handle, anchor_gramps_id: str, generation_depth: int
98+
) -> set[str]:
99+
"""Resolve people handles to include for an anchor+generation filter."""
100+
anchor = db_handle.get_person_from_gramps_id(anchor_gramps_id)
101+
if anchor is None:
102+
abort_with_message(404, "Anchor person not found")
103+
rules = {
104+
"function": "or",
105+
"rules": [
106+
{
107+
"name": "IsLessThanNthGenerationAncestorOf",
108+
"values": [anchor_gramps_id, generation_depth],
109+
},
110+
{
111+
"name": "IsLessThanNthGenerationDescendantOf",
112+
"values": [anchor_gramps_id, generation_depth],
113+
},
114+
],
115+
}
116+
handles = db_handle.get_person_handles(sort_handles=True)
117+
return set(
118+
apply_filter(
119+
db_handle,
120+
{"rules": json.dumps(rules)},
121+
"Person",
122+
handles,
123+
)
124+
)
125+
126+
127+
def _resolve_family_handles_for_people(db_handle, people_handles: set[str]) -> set[str]:
128+
"""Resolve families attached to in-scope people handles."""
129+
family_handles = set()
130+
for handle in people_handles:
131+
person = db_handle.get_person_from_handle(handle)
132+
if person is None:
133+
continue
134+
family_handles.update(person.family_list)
135+
family_handles.update(person.parent_family_list)
136+
return family_handles
137+
138+
139+
def _build_ics(events: list[Event], db_handle, tree_id: str) -> str:
140+
"""Build ICS calendar content for a list of events."""
141+
dtstamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
142+
lines = [
143+
"BEGIN:VCALENDAR",
144+
"VERSION:2.0",
145+
"PRODID:-//Gramps Web//Anniversaries//EN",
146+
"CALSCALE:GREGORIAN",
147+
"METHOD:PUBLISH",
148+
"X-WR-CALNAME:Gramps Anniversaries",
149+
]
150+
for event in events:
151+
date_components = _get_anniversary_date_components(event)
152+
if date_components is None:
153+
continue
154+
year, month, day = date_components
155+
dtstart = f"{year:04d}{month:02d}{day:02d}"
156+
summary = _escape_ics_text(get_event_summary_from_object(db_handle, event))
157+
description = _escape_ics_text(
158+
f"Gramps ID: {event.gramps_id or ''}\\nType: {event.get_type().xml_str()}"
159+
)
160+
uid = _escape_ics_text(f"{event.handle}@{tree_id}.anniversaries.gramps-web")
161+
lines.extend(
162+
[
163+
"BEGIN:VEVENT",
164+
f"UID:{uid}",
165+
f"DTSTAMP:{dtstamp}",
166+
f"DTSTART;VALUE=DATE:{dtstart}",
167+
"RRULE:FREQ=YEARLY",
168+
f"SUMMARY:{summary}",
169+
f"DESCRIPTION:{description}",
170+
"END:VEVENT",
171+
]
172+
)
173+
lines.append("END:VCALENDAR")
174+
return "\r\n".join(lines) + "\r\n"
175+
176+
177+
def _event_sort_key(event: Event) -> tuple[int, int, int, str]:
178+
"""Return stable sort key for anniversary events."""
179+
date_components = _get_anniversary_date_components(event)
180+
if date_components is None:
181+
return (12, 31, 9999, event.handle)
182+
year, month, day = date_components
183+
return (month, day, year, event.handle)
184+
185+
186+
class AnniversariesIcsQueryArgs(Schema):
187+
"""Query arguments for GET /anniversaries.ics."""
188+
189+
token = fields.Str(
190+
required=True,
191+
validate=validate.Length(min=1),
192+
metadata={"description": "Persistent access token value."},
193+
)
194+
event_types = fields.DelimitedList(
195+
fields.Str(validate=validate.Length(min=1)),
196+
metadata={"description": "Comma-delimited event type names to include."},
197+
)
198+
anchor_gramps_id = fields.Str(
199+
metadata={"description": "Anchor person Gramps ID for family-scope filtering."},
200+
)
201+
generation_depth = fields.Integer(
202+
load_default=4,
203+
validate=validate.Range(min=1, max=9),
204+
metadata={"description": "Generation depth around the anchor person."},
205+
)
206+
207+
208+
class AnniversariesIcsResource(Resource):
209+
"""Public anniversaries ICS feed resource."""
210+
211+
@api_blueprint.arguments(AnniversariesIcsQueryArgs, location="query")
212+
def get(self, args: dict) -> Response:
213+
"""Return anniversaries in ICS format."""
214+
user = get_user_from_access_token(
215+
args["token"], ACCESS_TOKEN_SCOPE_ANNIVERSARIES_ICS
216+
)
217+
if user is None:
218+
abort_with_message(401, "Invalid access token")
219+
if user.role is None or user.role < 0:
220+
abort_with_message(403, "User account is disabled")
221+
222+
tree_id = get_tree_id(str(user.id))
223+
if is_tree_disabled(tree=tree_id):
224+
abort_with_message(503, "This tree is temporarily disabled")
225+
226+
permissions = get_permissions(username=user.name, tree=tree_id)
227+
view_private = PERM_VIEW_PRIVATE in permissions
228+
db_handle = get_db_outside_request(
229+
tree=tree_id,
230+
view_private=view_private,
231+
readonly=True,
232+
user_id=str(user.id),
233+
)
234+
try:
235+
iter_event_handles = db_handle.method("iter_event_handles")
236+
get_event_from_handle = db_handle.method("get_event_from_handle")
237+
assert iter_event_handles is not None
238+
assert get_event_from_handle is not None
239+
events = []
240+
for handle in iter_event_handles():
241+
event = get_event_from_handle(handle)
242+
if event is not None:
243+
events.append(event)
244+
245+
allowed_types = {
246+
event_type.casefold().strip()
247+
for event_type in args.get("event_types", [])
248+
if event_type and event_type.strip()
249+
}
250+
events = [
251+
event for event in events if _event_matches_type(event, allowed_types)
252+
]
253+
events = [
254+
event
255+
for event in events
256+
if _get_anniversary_date_components(event) is not None
257+
]
258+
259+
if args.get("anchor_gramps_id"):
260+
allowed_people = _resolve_anchor_people_handles(
261+
db_handle, args["anchor_gramps_id"], args["generation_depth"]
262+
)
263+
allowed_families = _resolve_family_handles_for_people(
264+
db_handle, allowed_people
265+
)
266+
events = [
267+
event
268+
for event in events
269+
if _is_event_in_anchor_scope(
270+
db_handle, event, allowed_people, allowed_families
271+
)
272+
]
273+
274+
events.sort(key=_event_sort_key)
275+
payload = _build_ics(events=events, db_handle=db_handle, tree_id=tree_id)
276+
finally:
277+
close_db(db_handle)
278+
279+
response = Response(payload, status=200, mimetype="text/calendar")
280+
response.headers["Content-Disposition"] = "inline; filename=anniversaries.ics"
281+
return response

0 commit comments

Comments
 (0)