From 0aae9e5c03cf6d454228659a410ec745ce13e153 Mon Sep 17 00:00:00 2001 From: Brian Glass Date: Fri, 11 Sep 2026 14:53:11 -0400 Subject: [PATCH] Link the dates in commemoration stories to the day they fall on Stories point at other commemorations by date -- "a disciple of Saint Anthony (January 17)", "for his life, see December 20" -- and there are about 340 such mentions across the story text. They are now links to that day's readings page, keeping the reader's tradition and calendar. The links are added at render time by a template filter, not written into the data, so the API, the MCP tools and the Alexa speech still get plain text. Which dates link. About 300 of the mentions are cross-references. Historical dates are left alone: those followed by a year ("January 2, 1833"), date ranges ("21-22 November"), and the two calendar-qualified dates ("March 5 OC, March 18 NC") -- the app files that saint under the new-calendar date, so the OC date would link to a day he isn't on. A reference to the day being viewed gets no link. Which year. A date links within the current Church year, which begins on September 1, so a page links the same way whenever it is read. Feb 29 is the exception and links to the soonest one that hasn't passed. On the saint page, which has no date of its own, "current" means today. The Julian calendar. A story's "July 19" is a Church-calendar date. For an Old Calendar reader the readings URL is the civil date, so it links to civil Aug 1. Adds datetools.julian_to_gregorian, which takes the Julian date as integers because a Julian Feb 29 in 2100 can't be a Python date. The saint page links on the calendar the reader last chose. It can't use a fixed one: the readings page remembers whatever calendar a URL carries, so a `gregorian` link would quietly switch an Old Calendar reader to the New. Reading the session makes the page send Vary: Cookie, the same as the readings index, and a test asserts it so the page can't later be cached across readers. Styling. On the readings page these links sat outside #content and were getting browser-default blue; on the saint page a solid accent-red link read like a rubric heading. They now keep the paragraph's colour with a thin accent-red underline, borrowed from the nav's active-item treatment, turning red on hover. Checked in light and dark mode. Scoped under #orthocal-content so it outranks `#content a` on the saint page. In print they look exactly like the surrounding text, via a matching rule in print.css. 19 new tests. 216 pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019nQRjJpPqW89xqzAf1vsEX --- calendarium/datetools.py | 12 ++ calendarium/templates/readings.html | 3 +- .../templatetags/commemoration_links.py | 144 +++++++++++++++++ calendarium/tests/test_commemoration_links.py | 150 ++++++++++++++++++ commemorations/templates/saint_detail.html | 3 +- commemorations/views.py | 22 +++ orthocal/static/main.css | 19 +++ orthocal/static/print.css | 9 ++ 8 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 calendarium/templatetags/commemoration_links.py create mode 100644 calendarium/tests/test_commemoration_links.py diff --git a/calendarium/datetools.py b/calendarium/datetools.py index a65ce5c8..7217b8b0 100644 --- a/calendarium/datetools.py +++ b/calendarium/datetools.py @@ -263,6 +263,18 @@ def gregorian_to_julian(year, month, day): # will raise a ValueError. This is a problem, but doesn't occur until 2100. return date(year, month, day) +def julian_to_gregorian(year, month, day): + """Convert a Julian date to a Gregorian date. + + Takes the Julian date as three integers rather than a `date`, because a + Julian Feb 29 in a century year that the Gregorian calendar doesn't leap + (1900, 2100) can't be represented as a Python `date` at all -- the same + limitation gregorian_to_julian runs into in the other direction.""" + + jd = jdcal.jcal2jd(year, month, day) + year, month, day, _ = jdcal.jd2gcal(*jd) + return date(year, month, day) + def compute_pascha_distance(dt): """Compute the distance of a given day from Pascha. diff --git a/calendarium/templates/readings.html b/calendarium/templates/readings.html index 7553858a..b3a17356 100644 --- a/calendarium/templates/readings.html +++ b/calendarium/templates/readings.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% load fullurl %} {% load scripture_extras %} +{% load commemoration_links %} {% block title %}{% if request.resolver_match.url_name == "index" %}Orthodox Daily Scripture Readings and Lives of the Saints{% else %}Orthodox Daily Readings for {{ day.gregorian_date|date:"F j, Y" }}{% endif %}{% endblock %} @@ -141,7 +142,7 @@

Commemorations

{{ reading.title }}

- {{ reading.story|safe }} + {{ reading.story|link_commemoration_dates:day }}
{% endfor %} {% endif %} diff --git a/calendarium/templatetags/commemoration_links.py b/calendarium/templatetags/commemoration_links.py new file mode 100644 index 00000000..16ef3c16 --- /dev/null +++ b/calendarium/templatetags/commemoration_links.py @@ -0,0 +1,144 @@ +"""Link the dates a commemoration's story mentions to the day they fall on. + +Stories point at other commemorations by date -- "a disciple of Saint +Anthony (January 17)", "for his life, see December 20" -- and those are dates +on the Church calendar. This turns them into links to that day's readings +page, keeping the reader's tradition and calendar. +""" +import calendar +import re +from datetime import date + +from django import template +from django.urls import reverse +from django.utils.html import escape +from django.utils.safestring import mark_safe + +from ..datetools import Calendar, julian_to_gregorian + +register = template.Library() + +# Spelled out rather than taken from calendar.month_name, which follows the +# process locale; the stories are English whatever the reader's language. +MONTHS = { + 'January': 1, 'February': 2, 'March': 3, 'April': 4, 'May': 5, + 'June': 6, 'July': 7, 'August': 8, 'September': 9, 'October': 10, + 'November': 11, 'December': 12, + 'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'Jun': 6, 'Jul': 7, + 'Aug': 8, 'Sept': 9, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12, +} +_FULL = 'January|February|March|April|May|June|July|August|September|October|November|December' +_ABBR = 'Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sept|Sep|Oct|Nov|Dec' + +# "January 17", "Dec. 4", "July 19th", "5 August", "the 5th of August". +DATE_RE = re.compile( + rf'\b(?:(?P{_FULL}|(?:{_ABBR})\.?)\s+(?P\d{{1,2}})(?:st|nd|rd|th)?' + rf'|(?P\d{{1,2}})(?:st|nd|rd|th)?\s+(?:of\s+)?(?P{_FULL}))\b' +) + +# Dates that are not pointers to a commemoration, and so are left alone: +# +# "January 2, 1833" -- a historical date with a year; an event in a life. +_YEAR_AFTER = re.compile(r',?\s*(?:AD\s*)?\d{3,4}\b') +# "21-22 November", "November 21-22" -- a span of days, also an event. +_RANGE_AFTER = re.compile(r'\s*[-–—]\s*\d') +_RANGE_BEFORE = re.compile(r'\d\s*[-–—]\s*$') +# "(March 5 OC, March 18 NC)" -- explicitly calendar-qualified. Where these +# occur the app files the saint under the new-calendar date, so the Old +# Calendar date would link to a day the saint isn't on. +_CALENDAR_MARK = re.compile( + r'\s*\(?\s*(?:OC|OS|NC|NS|O\.S\.|N\.S\.|[Oo]ld [Ss]tyle|[Nn]ew [Ss]tyle' + r'|[Oo]ld [Cc]alendar|[Nn]ew [Cc]alendar)(?![A-Za-z])' +) + +_TAG_RE = re.compile(r'(<[^>]*>)') + + +def _is_leap(year, calendar_kind): + if calendar_kind == Calendar.Julian: + return year % 4 == 0 + return calendar.isleap(year) + + +def link_date(month, day, church_today, calendar_kind): + """The civil date to link to for `month`/`day` on the Church calendar, + or None when that is the day already being viewed. + + `church_today` is the viewed day on the Church calendar: the Julian date + for an Old Calendar reader, the civil date otherwise. + + A date links within the current Church year, which begins on September 1, + so a page always links the same way regardless of when it is read. The + exception is Feb 29, which links to the soonest one that hasn't passed -- + many Church years have none. + """ + + if (month, day) == (church_today.month, church_today.day): + return None + + if (month, day) == (2, 29): + year = church_today.year + if (church_today.month, church_today.day) > (2, 29): + year += 1 + while not _is_leap(year, calendar_kind): + year += 1 + else: + start = church_today.year if church_today.month >= 9 else church_today.year - 1 + year = start if month >= 9 else start + 1 + + if calendar_kind == Calendar.Julian: + return julian_to_gregorian(year, month, day) + return date(year, month, day) + + +def _link(match, context): + text = match.string + before, after = text[:match.start()], text[match.end():] + if (_YEAR_AFTER.match(after) or _RANGE_AFTER.match(after) + or _RANGE_BEFORE.search(before) or _CALENDAR_MARK.match(after)): + return match.group(0) + + month = MONTHS[(match['month'] or match['month2']).rstrip('.')] + day = int(match['day'] or match['day2']) + if not 1 <= day <= calendar.monthrange(2000, month)[1]: + return match.group(0) + + target = link_date(month, day, context.date, context.calendar) + if target is None: + return match.group(0) + + url = reverse('readings', kwargs={ + 'tradition': context.tradition, + 'cal': context.calendar, + 'year': target.year, + 'month': target.month, + 'day': target.day, + }) + return f'{match.group(0)}' + + +@register.filter +def link_commemoration_dates(html, context): + """Link the Church-calendar dates in a story's HTML. + + `context` needs `date` (the viewed day on the Church calendar), + `calendar` and `tradition` -- a liturgics Day has all three. Only text + between tags is rewritten, and never inside an existing link, so the + story's own markup passes through untouched. + """ + + if not html: + return html + + parts = _TAG_RE.split(html) + in_link = False + for i, part in enumerate(parts): + if i % 2: + if re.match(r'', part, re.IGNORECASE): + in_link = False + elif not in_link: + parts[i] = DATE_RE.sub(lambda match: _link(match, context), part) + + return mark_safe(''.join(parts)) diff --git a/calendarium/tests/test_commemoration_links.py b/calendarium/tests/test_commemoration_links.py new file mode 100644 index 00000000..00e94cc0 --- /dev/null +++ b/calendarium/tests/test_commemoration_links.py @@ -0,0 +1,150 @@ +from datetime import date +from types import SimpleNamespace + +from django.core.management import call_command +from django.test import TestCase +from django.urls import reverse + +from commemorations.models import DayCommemoration + +from ..datetools import Calendar, Tradition +from ..templatetags.commemoration_links import link_commemoration_dates, link_date + +GREGORIAN, JULIAN = Calendar.Gregorian, Calendar.Julian + + +def readings_url(target, cal='gregorian', tradition='slavic'): + return reverse('readings', kwargs={ + 'tradition': tradition, 'cal': cal, + 'year': target.year, 'month': target.month, 'day': target.day, + }) + + +class LinkDateTestCase(TestCase): + def test_links_within_the_church_year(self): + """Jan 2, 2026 is in the Church year that began Sep 1, 2025, so a + December date links back into 2025 and a July date forward into 2026.""" + today = date(2026, 1, 2) + self.assertEqual(date(2025, 12, 4), link_date(12, 4, today, GREGORIAN)) + self.assertEqual(date(2026, 7, 19), link_date(7, 19, today, GREGORIAN)) + + def test_church_year_turns_on_september_1(self): + self.assertEqual(date(2026, 9, 1), link_date(9, 1, date(2027, 8, 31), GREGORIAN)) + self.assertEqual(date(2028, 8, 31), link_date(8, 31, date(2027, 9, 1), GREGORIAN)) + self.assertEqual(date(2027, 1, 17), link_date(1, 17, date(2026, 12, 4), GREGORIAN)) + + def test_no_link_to_the_day_being_viewed(self): + self.assertIsNone(link_date(12, 4, date(2026, 12, 4), GREGORIAN)) + + def test_julian_links_to_the_civil_date(self): + """Julian Jan 2, 2026 is civil Jan 15. A story's "July 19" is the + Julian July 19, which an Old Calendar reader keeps on civil Aug 1.""" + today = date(2026, 1, 2) + self.assertEqual(date(2026, 8, 1), link_date(7, 19, today, JULIAN)) + self.assertEqual(date(2025, 12, 17), link_date(12, 4, today, JULIAN)) + + def test_feb_29_links_to_the_soonest_that_has_not_passed(self): + self.assertEqual(date(2028, 2, 29), link_date(2, 29, date(2026, 3, 1), GREGORIAN)) + self.assertEqual(date(2028, 2, 29), link_date(2, 29, date(2028, 2, 1), GREGORIAN)) + self.assertEqual(date(2032, 2, 29), link_date(2, 29, date(2028, 3, 1), GREGORIAN)) + self.assertIsNone(link_date(2, 29, date(2028, 2, 29), GREGORIAN)) + + def test_julian_feb_29(self): + # Julian Feb 29, 2028 is civil Mar 13. + self.assertEqual(date(2028, 3, 13), link_date(2, 29, date(2026, 3, 1), JULIAN)) + # 2100 is a Julian leap year but not a Gregorian one. Its Julian Feb 29 + # can't be a Python date, but the civil date it lands on can. + self.assertEqual(date(2100, 3, 14), link_date(2, 29, date(2099, 3, 1), JULIAN)) + + +class LinkCommemorationDatesTestCase(TestCase): + today = date(2026, 1, 2) + + def render(self, html, cal=GREGORIAN): + context = SimpleNamespace(date=self.today, calendar=cal, tradition=Tradition.Slavic) + return link_commemoration_dates(html, context) + + def test_links_a_parenthetical_reference(self): + html = self.render('

Saint Seraphim, Bishop of Phanarion (Dec. 4), was ordained.

') + self.assertIn(f'Dec. 4', html) + + def test_links_day_first_dates_and_ordinals(self): + html = self.render('

Saint Oswald (5 August) is also kept on July 19th.

') + self.assertIn(f'5 August', html) + self.assertIn(f'July 19th', html) + + def test_leaves_historical_dates_alone(self): + for text in ('falling asleep in peace on January 2, 1833, chanting', + 'was consecrated on December 25 784.', + 'died in peace on 22 September 1323.'): + with self.subTest(text): + self.assertNotIn('{text}

')) + + def test_leaves_date_ranges_alone(self): + for text in ('On the night of 21-22 November he had a revelation.', + 'From November 21-22 he kept vigil.'): + with self.subTest(text): + self.assertNotIn('
{text}

')) + + def test_leaves_calendar_marked_dates_alone(self): + html = self.render('

His feast is kept on this day (March 5 OC, March 18 NC).

') + self.assertNotIn('
not February 30, nor April 31

')) + + def test_leaves_markup_and_existing_links_alone(self): + html = self.render('

See March 6 and March 7.

') + self.assertIn('

', html) + self.assertIn('March 6', html) + self.assertIn(f'March 7', html) + + def test_links_keep_the_calendar(self): + html = self.render('

(see July 19)

', JULIAN) + self.assertIn(f'July 19', html) + + def test_passes_empty_stories_through(self): + self.assertEqual('', self.render('')) + self.assertIsNone(self.render(None)) + + +class StoryLinksOnPagesTestCase(TestCase): + fixtures = ['calendarium.json', 'commemorations.json'] + + # The Jan 2 story of St Seraphim of Sarov mentions the saint he was named + # for: "Hieromartyr Seraphim, Bishop of Phanarion (Dec. 4)". + SERAPHIM = 5209 + + def setUp(self): + # Slugs aren't in the fixture; the Dockerfile backfills them after + # loading it, and so does this, mirroring commemorations.tests. + call_command('backfill_saint_slugs') + + def test_readings_page_links_story_dates(self): + response = self.client.get(readings_url(date(2026, 1, 2))) + self.assertContains(response, f'Dec. 4') + + def test_julian_readings_page_links_to_the_civil_date(self): + # Civil Jan 15, 2026 is Julian Jan 2; Julian Dec 4, 2025 is civil Dec 17. + response = self.client.get(readings_url(date(2026, 1, 15), cal='julian')) + self.assertContains(response, f'Dec. 4') + + def test_saint_page_links_story_dates(self): + saint = DayCommemoration.objects.get(pk=self.SERAPHIM).saints.first() + response = self.client.get(reverse('saint-detail', args=[saint.slug])) + self.assertContains(response, '/readings/slavic/gregorian/') + self.assertContains(response, '>Dec. 4') + # The links depend on the reader's remembered calendar, so the page + # must not be cached across readers. Reading the session is what + # makes Django send this; keep it that way. + self.assertIn('Cookie', response['Vary']) + + def test_saint_page_keeps_an_old_calendar_reader_on_the_old_calendar(self): + # What a reader does: visiting a Julian page remembers the calendar. + # (Editing client.session directly doesn't reach the request with + # signed-cookie sessions, since the cookie *is* the session.) + self.client.get(readings_url(date(2026, 1, 15), cal='julian')) + saint = DayCommemoration.objects.get(pk=self.SERAPHIM).saints.first() + response = self.client.get(reverse('saint-detail', args=[saint.slug])) + self.assertContains(response, '/readings/slavic/julian/') + self.assertNotContains(response, '/readings/slavic/gregorian/') diff --git a/commemorations/templates/saint_detail.html b/commemorations/templates/saint_detail.html index 63514042..bf9d331d 100644 --- a/commemorations/templates/saint_detail.html +++ b/commemorations/templates/saint_detail.html @@ -1,4 +1,5 @@ {% extends "content_base.html" %} +{% load commemoration_links %} {% block title %}{{ saint.display_name }}{% endblock %} @@ -15,7 +16,7 @@

Commemorations

{{ dc.title }}

{% if dc.has_story %} - {{ dc.story|safe }} + {{ dc.story|link_commemoration_dates:story_links }} {% endif %}
{% endfor %} diff --git a/commemorations/views.py b/commemorations/views.py index 0b03cad5..9b2930fe 100644 --- a/commemorations/views.py +++ b/commemorations/views.py @@ -1,5 +1,9 @@ +from types import SimpleNamespace + from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone +from calendarium.datetools import Calendar, Tradition, cal_session_key, gregorian_to_julian from calendarium.liturgics.day import _has_story from .models import DayCommemoration, Saint @@ -21,6 +25,23 @@ def _occasion_date(day): return f'Moveable (Pascha {day.pdist:+d} days)' +def _story_link_context(request): + """What the dates in a saint's stories link to. + + A saint page has no date of its own, so links resolve against today, on + the calendar and tradition the reader last chose. They have to use the + remembered calendar rather than a fixed one: the readings page remembers + whatever calendar a URL carries, so linking an Old Calendar reader to a + `gregorian` URL would quietly switch them to the New Calendar.""" + + tradition = request.session.get('tradition', Tradition.Slavic) + cal = request.session.get(cal_session_key(tradition), Calendar.Gregorian) + today = timezone.localtime().date() + if cal == Calendar.Julian: + today = gregorian_to_julian(today.year, today.month, today.day) + return SimpleNamespace(date=today, calendar=cal, tradition=tradition) + + def _attach_display_name(saint): """Saint.name is backfilled from whichever DayCommemoration happened to be used when the row was created, which is often occasion-specific @@ -84,4 +105,5 @@ def saint_detail_view(request, slug): return render(request, 'saint_detail.html', context={ 'saint': saint, 'commemorations': commemorations, + 'story_links': _story_link_context(request), }) diff --git a/orthocal/static/main.css b/orthocal/static/main.css index 89e2f138..19773822 100644 --- a/orthocal/static/main.css +++ b/orthocal/static/main.css @@ -821,6 +821,25 @@ section.readings h2 { color: var(--color-text-faint); cursor: help; } +/* Dates in a commemoration's story that link to that day (see + calendarium/templatetags/commemoration_links.py). A solid accent-red link + in running prose reads like a rubric heading, and on the readings page -- + which sits outside #content -- these were getting browser-default blue. + So the date keeps the paragraph's own colour and borrows the nav's accent + underline instead: findable as a cross-reference without shouting. + Scoped under #orthocal-content, which wraps both the readings and saint + pages, so it outranks the generic `#content a` rule on the saint page. */ +#orthocal-content a.commemoration-date { + color: inherit; + text-decoration: underline; + text-decoration-color: var(--color-accent); + text-decoration-thickness: 1px; + text-underline-offset: 0.2em; +} +#orthocal-content a.commemoration-date:hover { + color: var(--color-accent); + text-decoration-thickness: 2px; +} .day>p { text-align: center; } diff --git a/orthocal/static/print.css b/orthocal/static/print.css index 0ea57947..a181cb49 100644 --- a/orthocal/static/print.css +++ b/orthocal/static/print.css @@ -94,3 +94,12 @@ table.month tr:first-child th { h1, h2, h3, h4 { page-break-after: avoid; } +/* A commemoration story's dates are links on screen (see main.css), but a + link goes nowhere on paper -- print them exactly as the surrounding text, + with none of the screen treatment. Same selector as the screen rule, and + this sheet loads after main.css, so it wins; it also has to outrank the + saint page's generic `#content a` red, which main.css applies in print. */ +#orthocal-content a.commemoration-date { + color: inherit; + text-decoration: none; +}