Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions calendarium/datetools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion calendarium/templates/readings.html
Original file line number Diff line number Diff line change
@@ -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 %}

Expand Down Expand Up @@ -141,7 +142,7 @@ <h1>Commemorations</h1>
<article class="passage" id="commemoration-{{ reading.id }}">
<h2>{{ reading.title }}</h2>

{{ reading.story|safe }}
{{ reading.story|link_commemoration_dates:day }}
</article>
{% endfor %}
{% endif %}
Expand Down
144 changes: 144 additions & 0 deletions calendarium/templatetags/commemoration_links.py
Original file line number Diff line number Diff line change
@@ -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<month>{_FULL}|(?:{_ABBR})\.?)\s+(?P<day>\d{{1,2}})(?:st|nd|rd|th)?'
rf'|(?P<day2>\d{{1,2}})(?:st|nd|rd|th)?\s+(?:of\s+)?(?P<month2>{_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'<a class="commemoration-date" href="{escape(url)}">{match.group(0)}</a>'


@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'<a\b', part, re.IGNORECASE):
in_link = True
elif re.match(r'</a\s*>', 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))
150 changes: 150 additions & 0 deletions calendarium/tests/test_commemoration_links.py
Original file line number Diff line number Diff line change
@@ -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('<p>Saint Seraphim, Bishop of Phanarion (Dec. 4), was ordained.</p>')
self.assertIn(f'<a class="commemoration-date" href="{readings_url(date(2025, 12, 4))}">Dec. 4</a>', html)

def test_links_day_first_dates_and_ordinals(self):
html = self.render('<p>Saint Oswald (5 August) is also kept on July 19th.</p>')
self.assertIn(f'<a class="commemoration-date" href="{readings_url(date(2026, 8, 5))}">5 August</a>', html)
self.assertIn(f'<a class="commemoration-date" href="{readings_url(date(2026, 7, 19))}">July 19th</a>', 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('<a ', self.render(f'<p>{text}</p>'))

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('<a ', self.render(f'<p>{text}</p>'))

def test_leaves_calendar_marked_dates_alone(self):
html = self.render('<p>His feast is kept on this day (March 5 OC, March 18 NC).</p>')
self.assertNotIn('<a ', html)

def test_ignores_impossible_dates(self):
self.assertNotIn('<a ', self.render('<p>not February 30, nor April 31</p>'))

def test_leaves_markup_and_existing_links_alone(self):
html = self.render('<p title="March 5">See <a href="/x">March 6</a> and <i>March 7</i>.</p>')
self.assertIn('<p title="March 5">', html)
self.assertIn('<a href="/x">March 6</a>', html)
self.assertIn(f'<i><a class="commemoration-date" href="{readings_url(date(2026, 3, 7))}">March 7</a></i>', html)

def test_links_keep_the_calendar(self):
html = self.render('<p>(see July 19)</p>', JULIAN)
self.assertIn(f'<a class="commemoration-date" href="{readings_url(date(2026, 8, 1), cal="julian")}">July 19</a>', 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'<a class="commemoration-date" href="{readings_url(date(2025, 12, 4))}">Dec. 4</a>')

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'<a class="commemoration-date" href="{readings_url(date(2025, 12, 17), cal="julian")}">Dec. 4</a>')

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</a>')
# 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/')
3 changes: 2 additions & 1 deletion commemorations/templates/saint_detail.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{% extends "content_base.html" %}
{% load commemoration_links %}

{% block title %}{{ saint.display_name }}{% endblock %}

Expand All @@ -15,7 +16,7 @@ <h3 class="saint-commemorations-title">Commemorations</h3>
<article class="passage">
<h2>{{ dc.title }} <span class="story-date">({{ dc.date_display }})</span></h2>
{% if dc.has_story %}
{{ dc.story|safe }}
{{ dc.story|link_commemoration_dates:story_links }}
{% endif %}
</article>
{% endfor %}
Expand Down
22 changes: 22 additions & 0 deletions commemorations/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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),
})
Loading