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; +}