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 }} ({{ dc.date_display }})
{% 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;
+}