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
6 changes: 5 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ name: Tests

on:
push:
branches-ignore: [main]
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
Expand Down
40 changes: 40 additions & 0 deletions bible/migrations/0003_add_translation_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Generated by Django 6.0.7 on 2026-07-30 20:26

from django.db import migrations, models


def backfill_translations(apps, schema_editor):
# AddField's default only gives every existing row 'kjv' -- correct for
# the English rows, wrong for Romanian/Serbian, which were never KJV.
Verse = apps.get_model('bible', 'Verse')
Verse.objects.filter(language='ro').update(translation='rccv')
Verse.objects.filter(language='sr').update(translation='srp1865')


def unbackfill_translations(apps, schema_editor):
Verse = apps.get_model('bible', 'Verse')
Verse.objects.filter(language__in=('ro', 'sr')).update(translation='kjv')


class Migration(migrations.Migration):

dependencies = [
('bible', '0002_load_scriptures'),
]

operations = [
migrations.AlterUniqueTogether(
name='verse',
unique_together=set(),
),
migrations.AddField(
model_name='verse',
name='translation',
field=models.CharField(default='kjv', max_length=20),
),
migrations.RunPython(backfill_translations, unbackfill_translations),
migrations.AlterUniqueTogether(
name='verse',
unique_together={('book', 'chapter', 'verse', 'language', 'translation')},
),
]
46 changes: 46 additions & 0 deletions bible/migrations/0004_load_lxx2012_web.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from django.db import migrations

from bible.parse import parse_usfx

TRANSLATION = 'lxx2012-web'

# Only these WEB books are used -- the rest of that file is WEB's own OT and
# Deuterocanon, which we don't want here since LXX2012 supplies the OT half
# of this pairing instead.
NT_BOOKS = {
'MAT', 'MRK', 'LUK', 'JHN', 'ACT', 'ROM', '1CO', '2CO', 'GAL', 'EPH',
'PHP', 'COL', '1TH', '2TH', '1TI', '2TI', 'TIT', 'PHM', 'HEB', 'JAS',
'1PE', '2PE', '1JN', '2JN', '3JN', 'JUD', 'REV',
}


def load_lxx2012_web(apps, schema_editor):
Verse = apps.get_model('bible', 'Verse')

for verse in parse_usfx('data/eng-lxx2012_usfx.xml'):
if verse['chapter'] is None or verse['verse'] is None:
continue
Verse.objects.create(language='en', translation=TRANSLATION, **verse)

for verse in parse_usfx('data/eng-web_usfx.xml'):
if verse['book'] not in NT_BOOKS:
continue
if verse['chapter'] is None or verse['verse'] is None:
continue
Verse.objects.create(language='en', translation=TRANSLATION, **verse)


def unload_lxx2012_web(apps, schema_editor):
Verse = apps.get_model('bible', 'Verse')
Verse.objects.filter(translation=TRANSLATION).delete()


class Migration(migrations.Migration):

dependencies = [
('bible', '0003_add_translation_field'),
]

operations = [
migrations.RunPython(load_lxx2012_web, unload_lxx2012_web),
]
20 changes: 17 additions & 3 deletions bible/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,21 @@ class ReferenceParseError(Exception):
pass


# The translation used when a caller doesn't specify one -- keeps every
# existing call site (which predates the translation field) resolving to
# exactly the same rows as before, per language.
DEFAULT_TRANSLATIONS = {
'en': 'kjv',
'ro': 'rccv',
'sr': 'srp1865',
}


class VerseManager(models.Manager):
def lookup_reference(self, reference, language='en'):
def lookup_reference(self, reference, language='en', translation=None):
if translation is None:
translation = DEFAULT_TRANSLATIONS[language]

conditionals = []
book = ''

Expand Down Expand Up @@ -77,7 +90,7 @@ def lookup_reference(self, reference, language='en'):

# Run the query
expression = functools.reduce(operator.or_, conditionals)
return self.filter(language=language).filter(expression)
return self.filter(language=language, translation=translation).filter(expression)


class Verse(models.Model):
Expand All @@ -87,11 +100,12 @@ class Verse(models.Model):
content = models.TextField()
paragraph_start = models.BooleanField(default=False)
language = models.CharField(max_length=10)
translation = models.CharField(max_length=20, default='kjv')

objects = VerseManager()

class Meta:
unique_together = 'book', 'chapter', 'verse', 'language'
unique_together = 'book', 'chapter', 'verse', 'language', 'translation'

def __str__(self):
blurb = textwrap.shorten(self.content, width=20, placeholder='...')
Expand Down
24 changes: 20 additions & 4 deletions bible/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ def parse_usfx(filename):
book, chapter, verse = None, None, None
paragraph_start = False
is_valid_content = False
was_valid_content = False
strings = []

def make_verse():
Expand Down Expand Up @@ -59,6 +60,12 @@ def make_verse():
yield make_verse()

verse = node.getAttribute('id')
if verse and '-' in verse:
# A verse bridge (e.g. "1-2"), where the source merges
# two verses into one printed unit -- store it under the
# first number rather than failing to parse an int; the
# combined text is already all in this one entry.
verse = verse.split('-')[0]
is_valid_content = True
case [pulldom.START_ELEMENT, 've']:
yield make_verse()
Expand All @@ -67,11 +74,20 @@ def make_verse():
case [pulldom.START_ELEMENT, 'p']:
paragraph_start = True

# Footnote Element
case [pulldom.START_ELEMENT, 'f']:
# Footnote and cross-reference elements -- <x> (cross-reference,
# e.g. WEB's "11:33 Daniel 6:22-23" pointing back to an OT
# parallel) is structurally the same kind of aside as <f>
# (footnote), so it needs the same is_valid_content suppression;
# without it, the reference text gets appended straight into the
# verse content.
case [pulldom.START_ELEMENT, 'f' | 'x']:
was_valid_content = is_valid_content
is_valid_content = False
case [pulldom.END_ELEMENT, 'f']:
is_valid_content = True
case [pulldom.END_ELEMENT, 'f' | 'x']:
# Restore whatever was in effect before the aside rather than
# assuming True -- these can appear in content (like Psalm
# title <d> blocks) that isn't part of a verse.
is_valid_content = was_valid_content

# Character content
case [pulldom.CHARACTERS, _]:
Expand Down
12 changes: 11 additions & 1 deletion bible/tests/test_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,15 @@
class ParseTest(TestCase):
def test_gen_9_23(self):
expected = 'And Shem and Japheth took a garment, and laid it upon both their shoulders, and went backward, and covered the nakedness of their father; and their faces were backward, and they saw not their father’s nakedness.'
verse = Verse.objects.get(book='GEN', chapter=9, verse=23, language='en')
verse = Verse.objects.get(book='GEN', chapter=9, verse=23, language='en', translation='kjv')
self.assertEqual(expected, verse.content)

def test_web_cross_references_stripped(self):
"""WEB annotates verses with <x> cross-reference elements (e.g.
pointing Heb 11:33 back to Daniel 6) that aren't part of the verse
text itself. These must not leak into the stored content the way
<f> footnotes were already excluded."""
expected = 'who through faith subdued kingdoms, worked out righteousness, obtained promises, stopped the mouths of lions,'
verse = Verse.objects.get(book='HEB', chapter=11, verse=33, language='en', translation='lxx2012-web')
self.assertEqual(expected, verse.content)
self.assertNotIn('Daniel', verse.content)
34 changes: 23 additions & 11 deletions calendarium/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from pydantic import AnyUrl, AnyHttpUrl, conint, constr, validator

from . import datetools, liturgics, views
from .datetools import Calendar, Tradition
from .datetools import Calendar, Tradition, Translation
from orthocal.decorators import etag, etag_date, instrument_endpoint

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -162,9 +162,9 @@ class OembedSchema(Schema):
def not_implemented_handler(request, exc):
return api.create_response(request, {'message': 'Not Implemented'}, status=501)

async def _get_calendar_day(request, cal, tradition, year, month, day):
async def _get_calendar_day(request, cal, tradition, year, month, day, translation=None):
try:
day = liturgics.Day(year, month, day, calendar=cal, tradition=tradition, language=request.LANGUAGE_CODE)
day = liturgics.Day(year, month, day, calendar=cal, tradition=tradition, language=request.LANGUAGE_CODE, translation=translation)
except ValueError:
# The date is out of range or invalid
raise Http404
Expand All @@ -178,26 +178,32 @@ async def _get_calendar_day(request, cal, tradition, year, month, day):
@api.get('{cal:cal}/{year:year}/{month:month}/{day:day}/', response=DaySchema)
@instrument_endpoint
@decorate_view(etag)
async def get_calendar_day(request, cal: Calendar, year: year, month: month, day: day):
async def get_calendar_day(request, cal: Calendar, year: year, month: month, day: day, translation: Translation = None):
"""Get information about the liturgical day for the given calendar and date.
The *cal* path parameter should be `gregorian` or `julian`. The legacy `oca` or `rocor`
will still work, but should be avoided for new code. This serves the Slavic/OCA
tradition; see the `{tradition}/{cal}/...` routes below for the Greek tradition.
The optional *translation* query parameter selects the Bible translation
(`kjv` or `lxx2012-web`); it only affects English content and defaults to
`kjv` when omitted.
"""

return await _get_calendar_day(request, cal, Tradition.Slavic, year, month, day)
return await _get_calendar_day(request, cal, Tradition.Slavic, year, month, day, translation)

@api.get('{tradition:tradition}/{cal:cal}/{year:year}/{month:month}/{day:day}/', response=DaySchema)
@instrument_endpoint
@decorate_view(etag)
async def get_calendar_day_tradition(request, tradition: Tradition, cal: Calendar, year: year, month: month, day: day):
async def get_calendar_day_tradition(request, tradition: Tradition, cal: Calendar, year: year, month: month, day: day, translation: Translation = None):
"""Get information about the liturgical day for the given tradition, calendar, and date.
The *tradition* path parameter should be `slavic` or `greek`. The legacy `oca`,
`antiochian`, and `goa` will still work, but should be avoided for new code.
The *cal* path parameter should be `gregorian` or `julian`.
The optional *translation* query parameter selects the Bible translation
(`kjv` or `lxx2012-web`); it only affects English content and defaults to
`kjv` when omitted.
"""

return await _get_calendar_day(request, cal, tradition, year, month, day)
return await _get_calendar_day(request, cal, tradition, year, month, day, translation)

async def _get_calendar_month(request, cal, tradition, year, month) -> List[DaySchemaLite]:
days = [d async for d in liturgics.amonth_of_days(year, month, calendar=cal, tradition=tradition)]
Expand Down Expand Up @@ -239,26 +245,32 @@ async def get_calendar_month_tradition(request, tradition: Tradition, cal: Calen
@api.get('{cal:cal}/', response=DaySchema, summary='Get Today')
@instrument_endpoint
@decorate_view(etag_date)
async def get_calendar_default(request, cal: Calendar):
async def get_calendar_default(request, cal: Calendar, translation: Translation = None):
"""Get information about the current liturgical day for the given calendar.
The timezone is Pacific Time. The *cal* path parameter should be
`gregorian` or `julian`. The legacy `oca` or `rocor` will still work, but
should be avoided for new code. This serves the Slavic/OCA tradition; see
the `{tradition}/{cal}/` route below for the Greek tradition.
The optional *translation* query parameter selects the Bible translation
(`kjv` or `lxx2012-web`); it only affects English content and defaults to
`kjv` when omitted.
"""
dt = timezone.localtime()
return await _get_calendar_day(request, cal, Tradition.Slavic, dt.year, dt.month, dt.day)
return await _get_calendar_day(request, cal, Tradition.Slavic, dt.year, dt.month, dt.day, translation)

@api.get('{tradition:tradition}/{cal:cal}/', response=DaySchema, summary='Get Today (by tradition)')
@instrument_endpoint
@decorate_view(etag_date)
async def get_calendar_default_tradition(request, tradition: Tradition, cal: Calendar):
async def get_calendar_default_tradition(request, tradition: Tradition, cal: Calendar, translation: Translation = None):
"""Get information about the current liturgical day for the given tradition and calendar.
The timezone is Pacific Time. The *tradition* path parameter should be
`slavic` or `greek`. The *cal* path parameter should be `gregorian` or `julian`.
The optional *translation* query parameter selects the Bible translation
(`kjv` or `lxx2012-web`); it only affects English content and defaults to
`kjv` when omitted.
"""
dt = timezone.localtime()
return await _get_calendar_day(request, cal, tradition, dt.year, dt.month, dt.day)
return await _get_calendar_day(request, cal, tradition, dt.year, dt.month, dt.day, translation)

@api.get('oembed/calendar/', response=OembedSchema, exclude_none=True)
@instrument_endpoint
Expand Down
20 changes: 20 additions & 0 deletions calendarium/datetools.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,26 @@ def cal_session_key(tradition):
always Gregorian and shouldn't clobber a Slavic Julian/Gregorian choice."""
return f'cal_{tradition}'

class Translation(StrEnum):
KJV = 'kjv'
LXX2012WEB = 'lxx2012-web'

def translation_session_key(language):
"""Mirrors cal_session_key(tradition): translation choice is remembered
per-language, since it's only meaningful for English today."""
return f'translation_{language}'

# Display labels for every translation code that can appear in Verse rows,
# not just the ones selectable via the dropdown -- rccv/srp1865 are included
# so the "Scripture Readings (...)" heading is accurate for Romanian/Serbian
# too, instead of always showing "(KJV)" regardless of language.
TRANSLATION_LABELS = {
'kjv': 'King James Version',
'lxx2012-web': 'LXX2012 & WEB',
'rccv': 'Romanian Corrected Cornilescu Version',
'srp1865': 'Serbian (Karadžić/Daničić, 1865)',
}

class FastLevels(IntEnum):
NoFast = 0
Fast = 1
Expand Down
13 changes: 7 additions & 6 deletions calendarium/liturgics/day.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,12 @@ class Day:
shared and lives here in the base class.
"""

def __new__(cls, year, month, day, calendar=Calendar.Gregorian, tradition=Tradition.Slavic, language='en'):
def __new__(cls, year, month, day, calendar=Calendar.Gregorian, tradition=Tradition.Slavic, language='en', translation=None):
if cls is Day:
cls = _DAY_CLASSES[tradition]
return super().__new__(cls)

def __init__(self, year, month, day, calendar=Calendar.Gregorian, tradition=Tradition.Slavic, language='en'):
def __init__(self, year, month, day, calendar=Calendar.Gregorian, tradition=Tradition.Slavic, language='en', translation=None):
self.gregorian_date = date(year, month, day)

if calendar == Calendar.Gregorian:
Expand All @@ -123,6 +123,7 @@ def __init__(self, year, month, day, calendar=Calendar.Gregorian, tradition=Trad
self.calendar = calendar
self.pyear = _YEAR_CLASSES[tradition](pyear, calendar)
self.language = language
self.translation = translation

async def ainitialize(self):
"""Do the expensive stuff here to keep it out of the constructor."""
Expand Down Expand Up @@ -440,7 +441,7 @@ async def aget_readings(self, fetch_content=False):
if hasattr(self, 'readings'):
if fetch_content:
for reading in self.readings:
await reading.pericope.aget_passage(language=self.language)
await reading.pericope.aget_passage(language=self.language, translation=self.translation)

return self.readings

Expand Down Expand Up @@ -505,7 +506,7 @@ async def aget_readings(self, fetch_content=False):
self.readings = []
for reading in rows:
if fetch_content:
await reading.pericope.aget_passage(language=self.language)
await reading.pericope.aget_passage(language=self.language, translation=self.translation)

if -42 < self.pdist < -7 and self.feast_level < 7 and reading.source == 'Matins Gospel':
# Place Lenten Matins Gospel at the top
Expand All @@ -524,7 +525,7 @@ async def aget_abbreviated_readings(self, fetch_content=False):
if hasattr(self, 'abbreviated_readings'):
if fetch_content:
for reading in self.abbreviated_readings:
await reading.pericope.aget_passage(language=self.language)
await reading.pericope.aget_passage(language=self.language, translation=self.translation)

return self.abbreviated_readings

Expand Down Expand Up @@ -586,7 +587,7 @@ async def aget_abbreviated_readings(self, fetch_content=False):

if fetch_content:
for reading in readings:
await reading.pericope.aget_passage(language=self.language)
await reading.pericope.aget_passage(language=self.language, translation=self.translation)

self.abbreviated_readings = readings
return readings
Expand Down
Loading