Something is wrong with the request that was made to the server.
9 |
10 | {% endblock content %}
11 |
--------------------------------------------------------------------------------
/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "hosting": {
3 | "public": "public",
4 | "ignore": [
5 | "firebase.json",
6 | "**/.*"
7 | ],
8 | "rewrites": [
9 | {
10 | "source": "**",
11 | "run": {
12 | "serviceId": "orthocal",
13 | "region": "us-central1"
14 | }
15 | }
16 | ]
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/orthocal/apps.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from django.apps import AppConfig
4 |
5 | logger = logging.getLogger(__name__)
6 |
7 | class OrthocalConfig(AppConfig):
8 | name = 'orthocal'
9 | verbose_name = 'Orthodox Calendar'
10 | orthocal_started = False
11 |
12 | def ready(self, *args, **kwargs):
13 | self.orthocal_started = True
14 | logger.info('Orthocal is ready.')
15 |
--------------------------------------------------------------------------------
/orthocal/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for orthocal project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'orthocal.settings')
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/commemorations/migrations/0002_alter_commemoration_unique_together.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1.4 on 2023-01-15 21:14
2 |
3 | from django.db import migrations
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('commemorations', '0001_initial'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterUniqueTogether(
14 | name='commemoration',
15 | unique_together={('month', 'day', 'title')},
16 | ),
17 | ]
18 |
--------------------------------------------------------------------------------
/orthocal/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for orthocal project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
8 | """
9 |
10 | import os
11 |
12 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'orthocal.settings')
13 |
14 | from django.conf import settings
15 | from django.core.asgi import get_asgi_application
16 |
17 | application = get_asgi_application()
18 |
--------------------------------------------------------------------------------
/calendarium/api_urls.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.urls import path
3 | from django.views.decorators.cache import cache_page
4 |
5 | from orthocal.decorators import cache
6 |
7 | from .api import api
8 | from .feeds import ReadingsFeed
9 | from .ical import ical
10 |
11 | urlpatterns = [
12 | path('', api.urls),
13 | path('/ical/', ical, name='ical'),
14 | path('feed/', cache(ReadingsFeed()), name='rss-feed'),
15 | path('feed//', cache(ReadingsFeed()), name='rss-feed-cal'),
16 | ]
17 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/media/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/media/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/bible/tests/test_parse.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from ..models import Verse
4 |
5 |
6 | class ParseTest(TestCase):
7 | def test_gen_9_23(self):
8 | 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.'
9 | verse = Verse.objects.get(book='GEN', chapter=9, verse=23, language='en')
10 | self.assertEqual(expected, verse.content)
11 |
--------------------------------------------------------------------------------
/orthocal/static/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/media/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/media/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/bible/management/commands/ingest_usfx.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand, CommandError
2 |
3 | from bible import models, parse
4 |
5 |
6 | class Command(BaseCommand):
7 | help = 'Ingest a USFX Bible.'
8 |
9 | def add_arguments(self, parser):
10 | parser.add_argument('path')
11 | parser.add_argument('-l', '--language')
12 |
13 | def handle(self, *args, **options):
14 | for verse in parse.parse_usfx(options['path'], language=options['language']):
15 | models.Verse.objects.create(**verse)
16 |
--------------------------------------------------------------------------------
/commemorations/migrations/0003_alter_commemoration_story.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1.4 on 2023-01-15 21:15
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('commemorations', '0002_alter_commemoration_unique_together'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name='commemoration',
15 | name='story',
16 | field=models.TextField(blank=True, null=True),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "pip" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/commemorations/migrations/0006_commemoration_alt_title.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1.6 on 2023-02-13 23:42
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('commemorations', '0005_alter_commemoration_day_alter_commemoration_month'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name='commemoration',
15 | name='alt_title',
16 | field=models.CharField(max_length=200, null=True),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/calendarium/liturgics/__init__.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 |
3 | from .day import Day
4 | from .year import Year
5 |
6 | async def amonth_of_days(year, month, **kwargs):
7 | dt = datetime(year, month, 1)
8 | while dt.month == month:
9 | day = Day(dt.year, dt.month, dt.day, **kwargs)
10 | await day.ainitialize()
11 | yield day
12 | dt += timedelta(days=1)
13 |
14 | def month_of_days(year, month, **kwargs):
15 | dt = datetime(year, month, 1)
16 | while dt.month == month:
17 | day = Day(dt.year, dt.month, dt.day, **kwargs)
18 | day.initialize()
19 | yield day
20 | dt += timedelta(days=1)
21 |
--------------------------------------------------------------------------------
/commemorations/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 |
4 | class Commemoration(models.Model):
5 | title = models.CharField(max_length=200)
6 | alt_title = models.CharField(max_length=200, null=True)
7 | high_rank = models.BooleanField()
8 | month = models.SmallIntegerField(db_index=True)
9 | day = models.SmallIntegerField(db_index=True, null=True, blank=True)
10 | story = models.TextField(null=True, blank=True)
11 | ordering = models.SmallIntegerField()
12 |
13 | class Meta:
14 | unique_together = 'month', 'day', 'title'
15 | ordering = 'ordering',
16 |
17 | def __repr__(self):
18 | return f''
19 |
--------------------------------------------------------------------------------
/commemorations/migrations/0004_alter_commemoration_options_commemoration_ordering.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1.4 on 2023-01-17 13:44
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('commemorations', '0003_alter_commemoration_story'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterModelOptions(
14 | name='commemoration',
15 | options={'ordering': ('ordering',)},
16 | ),
17 | migrations.AddField(
18 | model_name='commemoration',
19 | name='ordering',
20 | field=models.SmallIntegerField(default=1),
21 | preserve_default=False,
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'orthocal.settings')
10 | try:
11 | from django.core.management import execute_from_command_line
12 | except ImportError as exc:
13 | raise ImportError(
14 | "Couldn't import Django. Are you sure it's installed and "
15 | "available on your PYTHONPATH environment variable? Did you "
16 | "forget to activate a virtual environment?"
17 | ) from exc
18 | execute_from_command_line(sys.argv)
19 |
20 |
21 | if __name__ == '__main__':
22 | main()
23 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | ask_sdk_core==1.19.0
2 | django-ask-sdk==1.0.0
3 | django-cors-headers==4.6.0
4 | django-fullurl==1.4
5 | django-google-fonts==0.0.3
6 | django-ninja==1.3.0
7 | django==5.1.4
8 | google-cloud-logging==3.11.3
9 | icalendar==6.1.0
10 | jdcal==1.4.1
11 | Jinja2==3.1.4 # Typogrify imports this even though we're not using it
12 | newrelic==10.4.0
13 | python-dateutil==2.9.0.post0
14 | requests==2.32.3
15 | servestatic[brotli]==2.1.1
16 | typogrify==2.0.7
17 | uvicorn[standard]==0.34.0
18 | # oscrypto is a dependency of one of the above packages.
19 | # We can go back to mainline oscrypto once
20 | # https://github.com/wbond/oscrypto/issues/78 is fixed
21 | oscrypto @ https://github.com/wbond/oscrypto/archive/1547f535001ba568b239b8797465536759c742a3.zip
22 |
--------------------------------------------------------------------------------
/alexa/views.py:
--------------------------------------------------------------------------------
1 | #from django.shortcuts import render
2 | from django_ask_sdk import skill_adapter
3 |
4 | from .skills import orthodox_daily_skill
5 |
6 | # See https://github.com/alexa/alexa-skills-kit-sdk-for-python/issues/202
7 | # Note: creating the RequestVerifier instance here also allows it to persist
8 | # between requests and saves an extra request to Amazon S3 to fetch a
9 | # certificate for most requests.
10 |
11 | request_verifier = skill_adapter.RequestVerifier(
12 | signature_cert_chain_url_key=skill_adapter.SIGNATURE_CERT_CHAIN_URL_KEY,
13 | signature_key='HTTP_SIGNATURE_256',
14 | )
15 |
16 | orthodox_daily_view = skill_adapter.SkillAdapter.as_view(
17 | skill=orthodox_daily_skill,
18 | verify_signature=False,
19 | verifiers=[request_verifier],
20 | )
21 |
--------------------------------------------------------------------------------
/commemorations/migrations/0005_alter_commemoration_day_alter_commemoration_month.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1.4 on 2023-01-17 13:47
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('commemorations', '0004_alter_commemoration_options_commemoration_ordering'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name='commemoration',
15 | name='day',
16 | field=models.SmallIntegerField(blank=True, db_index=True, null=True),
17 | ),
18 | migrations.AlterField(
19 | model_name='commemoration',
20 | name='month',
21 | field=models.SmallIntegerField(db_index=True),
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/commemorations/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1.4 on 2023-01-15 21:07
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | initial = True
9 |
10 | dependencies = [
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name='Commemoration',
16 | fields=[
17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18 | ('title', models.CharField(max_length=200)),
19 | ('high_rank', models.BooleanField()),
20 | ('month', models.SmallIntegerField()),
21 | ('day', models.SmallIntegerField(blank=True, null=True)),
22 | ('story', models.TextField()),
23 | ],
24 | ),
25 | ]
26 |
--------------------------------------------------------------------------------
/orthocal/static/print.css:
--------------------------------------------------------------------------------
1 | @page {
2 | margin: 1in;
3 | }
4 | html {
5 | font-size: 12px;
6 | }
7 | body {
8 | padding: 0;
9 | margin: 1in 0 0 0;
10 | }
11 | body > header {
12 | display: none;
13 | }
14 | body > nav {
15 | display: none;
16 | }
17 | main#orthocal {
18 | width: 100%;
19 | border-top: 0;
20 | margin: 0;
21 | padding: 0;
22 | }
23 | table.month {
24 | font-size: 8pt;
25 | margin: -1in -1in -1in 0in;
26 | width: 100%;
27 | }
28 | table.month tr:first-child th {
29 | padding-bottom: 0.5em;
30 | }
31 | .print-exclude {
32 | display: none;
33 | }
34 | .passage {
35 | widows: 2;
36 | orphans: 2;
37 | column-rule: 0;
38 | }
39 | section.readings {
40 | border: 0;
41 | }
42 | main#orthocal > header h1 {
43 | font-size: 200%;
44 | }
45 | h1, h2, h3, h4 {
46 | page-break-after: avoid;
47 | }
48 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | configs:
2 | newrelic_config:
3 | file: ./newrelic.ini
4 | services:
5 | local: # for local development
6 | build: .
7 | ports:
8 | - "8000:8000"
9 | volumes:
10 | - .:/orthocal:consistent
11 | environment:
12 | - PORT=8000
13 | - ALLOWED_HOSTS=localhost
14 | - UVICORN_RELOAD=true
15 | web: # Use to test as it will be deployed in production
16 | build: .
17 | ports:
18 | - "8000:8000"
19 | cpu_count: 4
20 | environment:
21 | - PORT=8000
22 | - ALLOWED_HOSTS=localhost
23 | - NEW_RELIC_CONFIG_FILE=/orthocal-secrets/newrelic.ini
24 | - NEW_RELIC_ENVIRONMENT=development
25 | - WEB_CONCURRENCY=4
26 | configs:
27 | - source: newrelic_config
28 | target: /orthocal-secrets/newrelic.ini
29 | tests:
30 | build: .
31 | command: ./manage.py test --keepdb
32 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # For timings use the following:
2 | # | ts -s '[%Y-%m-%d %H:%M:%.S]'
3 | #
4 | docker:
5 | docker build -t orthocal .
6 |
7 | run:
8 | docker run -it -e PORT=8000 -e ALLOWED_HOSTS='localhost' -e WEB_CONCURRENCY=4 -p8000:8000 orthocal
9 |
10 | uvicorn:
11 | # newrelic-admin run-program uvicorn --lifespan off --host 0.0.0.0 --port 8000 orthocal.asgi:application
12 | newrelic-admin run-program uvicorn --lifespan off --host 0.0.0.0 --port 8000 --workers 4 orthocal.asgi:application
13 | # newrelic-admin run-program uvicorn --workers 2 --lifespan off --host 0.0.0.0 --port 8000 orthocal.asgi:application
14 |
15 | deploy:
16 | docker tag orthocal:latest gcr.io/orthocal-1d1b9/orthocal:latest
17 | docker push gcr.io/orthocal-1d1b9/orthocal:latest
18 |
19 | test:
20 | docker run -it -e PORT=8000 -p8000:8000 orthocal ./manage.py test
21 |
22 | firebase:
23 | firebase use --add orthocal-1d1b9
24 | firebase deploy --only hosting
25 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.13-slim
2 |
3 | WORKDIR /orthocal
4 |
5 | # newrelic.ini should be stored in Google Cloud Secret Manager and mounted as a volume.
6 | # NEW_RELIC_CONFIG_FILE and NEW_RELIC_ENVIRONMENT should be set in GC Run as well.
7 | # WEB_CONCURRENCY can also be set to specify the number of workers to run.
8 | # The PORT environment variable is set by GC Run and controls the port server listens on.
9 | CMD ["newrelic-admin", "run-program", "python", "server.py"]
10 |
11 | COPY requirements.txt .
12 | RUN pip install --upgrade pip && \
13 | pip install --no-cache-dir -r requirements.txt
14 | COPY . .
15 |
16 | # Precompile to bytecode to reduce warmup time
17 | RUN \
18 | python -c "import compileall; compileall.compile_path(maxlevels=10)" && \
19 | python -m compileall .
20 |
21 | # The sqlite database is read-only, so we build it into the image.
22 | RUN \
23 | ./manage.py collectstatic --noinput && \
24 | ./manage.py migrate && \
25 | ./manage.py loaddata calendarium commemorations
26 |
--------------------------------------------------------------------------------
/calendarium/tests/test_feeds.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from django.test import TestCase
4 | from django.urls import reverse
5 |
6 | from ..datetools import Calendar
7 |
8 |
9 | class FeedTest(TestCase):
10 | fixtures = ['calendarium.json']
11 |
12 | def test_links(self):
13 | url = reverse('rss-feed-cal', kwargs={'cal': Calendar.Gregorian})
14 | response = self.client.get(url)
15 | self.assertEqual(response.status_code, 200)
16 | links = re.findall(r'(.*?)', response.content.decode('utf-8'))
17 | for link in links[1:]:
18 | self.assertIn(Calendar.Gregorian, link)
19 |
20 | def test_links_julian(self):
21 | url = reverse('rss-feed-cal', kwargs={'cal': Calendar.Julian})
22 | response = self.client.get(url)
23 | self.assertEqual(response.status_code, 200)
24 | links = re.findall(r'(.*?)', response.content.decode('utf-8'))
25 | for link in links[1:]:
26 | self.assertIn(Calendar.Julian, link)
27 |
--------------------------------------------------------------------------------
/orthocal/views.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from django.apps import apps
4 | from django.http import JsonResponse
5 | from django.template.response import TemplateResponse
6 | from django.views import generic
7 |
8 | from .apps import OrthocalConfig
9 | from .decorators import etag
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 | async def startup_probe(request, *args, **kwargs):
14 | app_config = apps.get_app_config(OrthocalConfig.name)
15 | if app_config.orthocal_started:
16 | return JsonResponse({'started': True})
17 | else:
18 | return JsonResponse({'started': False}, status=500)
19 |
20 | @etag
21 | async def alexa(request):
22 | return TemplateResponse(request, 'alexa.html')
23 |
24 | @etag
25 | async def api(request):
26 | return TemplateResponse(request, 'api.html')
27 |
28 | @etag
29 | async def feeds(request):
30 | return TemplateResponse(request, 'feeds.html')
31 |
32 | @etag
33 | async def about(request):
34 | return TemplateResponse(request, 'about.html')
35 |
--------------------------------------------------------------------------------
/orthocal/management/commands/publish.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | import requests
4 |
5 | from urllib.parse import urljoin
6 |
7 | from django.conf import settings
8 | from django.core.management.base import BaseCommand, CommandError
9 | from django.urls import reverse
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 |
14 | class Command(BaseCommand):
15 | help = 'Publish RSS Feeds to a Websub hub.'
16 |
17 | def handle(self, *args, **options):
18 | feed_paths = [
19 | reverse('rss-feed'),
20 | reverse('rss-feed-cal', kwargs={'cal': 'gregorian'}),
21 | reverse('rss-feed-cal', kwargs={'cal': 'julian'}),
22 | ]
23 |
24 | response = requests.post(settings.ORTHOCAL_WEBSUB_URL, data={
25 | 'hub.mode': 'publish',
26 | 'hub.url': [urljoin(settings.ORTHOCAL_PUBLIC_URL, p) for p in feed_paths]
27 | })
28 |
29 | if not response.ok:
30 | raise CommandError(f'WEBSUB Publish error ({response.status_code}): {response.text}')
31 |
--------------------------------------------------------------------------------
/bible/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1.7 on 2023-03-15 13:09
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | initial = True
9 |
10 | dependencies = [
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name='Verse',
16 | fields=[
17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18 | ('book', models.CharField(db_index=True, max_length=3)),
19 | ('chapter', models.IntegerField(db_index=True)),
20 | ('verse', models.IntegerField(db_index=True)),
21 | ('content', models.TextField()),
22 | ('paragraph_start', models.BooleanField(default=False)),
23 | ('language', models.CharField(max_length=10)),
24 | ],
25 | options={
26 | 'unique_together': {('book', 'chapter', 'verse', 'language')},
27 | },
28 | ),
29 | ]
30 |
--------------------------------------------------------------------------------
/calendarium/templates/help.ssml:
--------------------------------------------------------------------------------
1 |
2 |
Orthodox Daily makes it easy to access the daily scripture readings. Simply
3 | ask me to open Orthodox Daily and I will provide you with a few details
4 | about today, including the fasting rules, and then read the prescribed
5 | scriptures. The scriptures are read from the King James Version and at
6 | present follow OCA rubrics.
7 |
8 |
You can also ask me directly about a particular day. For instance
9 | you can say, "Alexa, ask Orthodox Daily about tomorrow." Or you could say,
10 | "Alexa, ask Orthodox daily about the fast on Friday," or "Alexa, ask Orthodox
11 | Daily about saints on August 10."
12 |
13 |
If you want to skip all the extra details and go straight to the scriptures,
14 | just say, "Alexa, ask Orthodox Daily to read the scriptures." If you missed the
15 | scriptures yesterday, you can say, "Alexa, ask Orthodox Daily to read the
16 | scriptures for yesterday."
17 |
18 |
19 |
20 |
What would you like to do?
21 |
22 |
--------------------------------------------------------------------------------
/server.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import logging
3 | import os
4 |
5 | from unittest.mock import patch
6 |
7 | import uvicorn
8 |
9 | from uvicorn.supervisors import multiprocess
10 |
11 | logging.basicConfig(level=logging.INFO)
12 | logger = logging.getLogger(__name__)
13 |
14 |
15 | # The ping/pong in the multiprocess supervisor does not work correctly in
16 | # Google Cloud Run. See https://github.com/encode/uvicorn/discussions/2399
17 | class Process(multiprocess.Process):
18 | def ping(self, *args, **kwargs):
19 | return True
20 |
21 |
22 | @patch('uvicorn.supervisors.multiprocess.Process', Process)
23 | def main():
24 | logger.info("Starting Uvicorn.")
25 |
26 | port = int(os.environ.get('PORT', 8000))
27 | reload = os.environ.get('UVICORN_RELOAD', 'false').lower() == 'true'
28 |
29 | uvicorn.run(
30 | app='orthocal.asgi:application',
31 | host='0.0.0.0',
32 | port=port,
33 | lifespan='off',
34 | log_level='debug',
35 | reload=reload,
36 | )
37 |
38 | if __name__ == '__main__':
39 | main()
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Brian Glass
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/bible/migrations/0002_load_scriptures.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1.4 on 2022-12-13 19:16
2 |
3 | from django.db import migrations
4 |
5 | from bible.parse import parse_usfx
6 |
7 | def load_default_scriptures(apps, schema_editor):
8 | Verse = apps.get_model('bible', 'Verse')
9 |
10 | for verse in parse_usfx('data/eng-kjv_usfx.xml'):
11 | Verse.objects.create(language='en', **verse)
12 |
13 | for verse in parse_usfx('data/ron-rccv.usfx.xml'):
14 | Verse.objects.create(language='ro', **verse)
15 |
16 | for verse in parse_usfx('data/srp1865_usfx.xml'):
17 | Verse.objects.create(language='sr', **verse)
18 |
19 | def unload_default_scriptures(apps, schema_editor):
20 | Verse = apps.get_model('bible', 'Verse')
21 | Verse.objects.filter(language='eng').delete()
22 | Verse.objects.filter(language='ro').delete()
23 | Verse.objects.filter(language='sr').delete()
24 |
25 | class Migration(migrations.Migration):
26 |
27 | dependencies = [
28 | ('bible', '0001_initial'),
29 | ]
30 |
31 | operations = [
32 | migrations.RunPython(load_default_scriptures, unload_default_scriptures),
33 | ]
34 |
--------------------------------------------------------------------------------
/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
22 |
--------------------------------------------------------------------------------
/orthocal/static/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
22 |
--------------------------------------------------------------------------------
/calendarium/urls.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from django.conf import settings
4 | from django.urls import path
5 | from django.views.generic import TemplateView
6 | from django.views.generic.base import RedirectView
7 |
8 | from . import views
9 | from orthocal.decorators import cache, etag, etag_date, acache
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 | urlpatterns = [
14 | path('readings/////', etag(views.readings_view), name='readings'),
15 | # Eventually we can remove this redirect, but we're still getting traffic here from crawlers.
16 | path('calendar/////', RedirectView.as_view(permanent=True, pattern_name='readings')),
17 | path('calendar////', acache(etag(views.calendar_view)), name='calendar'),
18 | path('calendar-embed////', views.calendar_embed_view, name='calendar-embed'),
19 | path('calendar-embed/', etag_date(views.calendar_embed_view), name='calendar-embed-default'),
20 | path('calendar/', etag_date(views.calendar_view), name='calendar-default'),
21 | path('', etag_date(views.readings_view), name='index'),
22 | ]
23 |
--------------------------------------------------------------------------------
/orthocal/converters.py:
--------------------------------------------------------------------------------
1 | from django.urls.converters import IntConverter
2 |
3 | from calendarium.datetools import Calendar
4 |
5 | CAL_RE = '(gregorian|julian|oca|rocor)'
6 |
7 | class CalendarConverter:
8 | regex = CAL_RE
9 |
10 | def to_python(self, value):
11 | match value:
12 | case 'gregorian' | 'oca':
13 | return Calendar.Gregorian
14 | case 'julian' | 'rocor':
15 | return Calendar.Julian
16 |
17 | def to_url(self, value):
18 | return value
19 |
20 |
21 | class YearConverter(IntConverter):
22 | def to_python(self, value):
23 | year = super().to_python(value)
24 |
25 | # 1583 is OK for Gregorian, but Julian breaks it
26 | if not 1584 <= year <= 4099:
27 | raise ValueError(f'{year} is outside a valid year range for this application.')
28 |
29 | return year
30 |
31 |
32 | class MonthConverter(IntConverter):
33 | def to_python(self, value):
34 | month = super().to_python(value)
35 |
36 | if not 1 <= month <= 12:
37 | raise ValueError('The month is outside a valid range.')
38 |
39 | return month
40 |
41 |
42 | class DayConverter(IntConverter):
43 | def to_python(self, value):
44 | day = super().to_python(value)
45 |
46 | if not 1 <= day <= 31:
47 | raise ValueError('The day is outside a valid range.')
48 |
49 | return day
50 |
--------------------------------------------------------------------------------
/bible/tests/tests_models.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from .. import models
4 |
5 |
6 | class VerseTestCase(TestCase):
7 | def test_reference(self):
8 | tests = [
9 | ("Matt 1.1-25", 25),
10 | ("Matt 4.25-5.13", 14),
11 | ("Matt 10.32-36, 11.1", 6),
12 | ("Matt 6.31-34, 7.9-11", 7),
13 | ("Matt 10.1, 5-8", 5),
14 | ("Mark 15.22, 25, 33-41", 11),
15 | # single chapter book
16 | ("Jude 1-10", 10),
17 | ("1 John 2.7-17", 11),
18 | ("Gen 17.1-2, 4, 5-7, 8, 9-10, 11-12, 14", 12),
19 | # discontinuous chapters
20 | ("Job 38.1-23; 42.1-5", 28),
21 | # multiple books
22 | ("1 Cor 5.6-8; Gal 3.13-14", 5),
23 | # multiple books with : instead of .
24 | ("Matt 26:2-20; John 13:3-17; Matt 26:21-39; Luke 22:43-45; Matt 26:40-27:2", 94),
25 | # individual full chapters
26 | ("Prov 10, 3, 8", 32 + 35 + 36),
27 | # Multiple chapters
28 | ("Jonah 1.1-4.11", 17 + 10 + 10 + 11),
29 | # Deuterocanonical
30 | ("4 Kgs 2.6-14", 9),
31 | ("Baruch 3.35-4.4", 3 + 4),
32 | ("Wis 3.1-9", 9),
33 | ("Daniel 3.1-23; Song of the Three 1-66", 23 + 66),
34 | ]
35 |
36 | for reference, count in tests:
37 | with self.subTest(msg=reference):
38 | passage = models.Verse.objects.lookup_reference(reference)
39 | self.assertEqual(passage.count(), count)
40 |
--------------------------------------------------------------------------------
/calendarium/templates/feed_description.html:
--------------------------------------------------------------------------------
1 | {% load fullurl typogrify_tags %}
2 |
60 |
61 | {% for reading in obj.stories %}
62 |
63 |
{{ reading.title|widont }}
64 |
65 | {{ reading.story|safe }}
66 |
67 | {% endfor %}
68 | {% endif %}
69 |
--------------------------------------------------------------------------------
/orthocal/templates/feeds.html:
--------------------------------------------------------------------------------
1 | {% extends "content_base.html" %}
2 |
3 | {% load fullurl %}
4 |
5 | {% block title %}Orthodox iCal and RSS Feeds{% endblock %}
6 |
7 | {% block head %}
8 |
9 |
10 | {# this page was moved from /ical/ to /feeds/. /ical/ is now a redirect, but we'll keep this here to help Google for now. #}
11 |
12 | {% endblock %}
13 |
14 | {% block content %}
15 |
Feeds
16 |
RSS
17 |
18 |
Orthocal.info provides an RSS feed that can be used to embed the daily readings on your website or
19 | used in an RSS feed reader. The default is the Gregorian calendar, but Julian is also available
Start with the
14 | API Documentation
15 | and then experiment with the examples below.
16 |
17 |
Code Examples
18 |
19 |
Website administrators can use these APIs on either the front-end or
20 | back-end to embed the daily readings on the sites they maintain.
21 | Following are some simple examples of how to fetch the data from the api and incorporate
22 | it into a page using either PHP or Javascript.
23 |
24 |
PHP Example
25 |
26 |
27 |
28 |
Browser-based Javascript Examples
29 |
30 |
HTML With jQuery Example
31 |
32 |
33 |
34 |
ES6 Example
35 |
36 |
37 |
38 |
39 |
40 |
React.js Example
41 |
42 |
You can view the code for the previous version of this site that was
43 | built with React.js in the orthocal-client Github repository.
45 | {% endblock %}
46 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # orthocal-python
2 | Orthocal Rewritten in Python
3 |
4 | ## For Docker Development
5 |
6 | Build image:
7 |
8 | docker-compose build
9 |
10 | Run the image in a container locally. Local file changes will be automatically detected while running:
11 |
12 | docker-compose up local
13 |
14 | Run the image locally as it will be run in production:
15 |
16 | docker-compose up web
17 |
18 | Run tests:
19 |
20 | docker-compose run tests
21 |
22 | ## Local Configuration
23 |
24 | You can create a local_settings.py file in the orthocal directory and add
25 | custom Django settings. This file will be imported into the main settings.py file.
26 |
27 | ## Environment Variables:
28 |
29 | These environment variables should be set in the runtime environment.
30 | For orthocal.info, these are set in Google Cloud Run service. They can
31 | also be passed to a container at runtime using the docker -e argument.
32 | (See the Makefile for an example).
33 |
34 | SECRET_KEY - the Django secret key
35 | TZ - a valid timezone; the default is America/Los_angeles
36 | BASE_URL - the part of the url common to all urls on the site; the default is https://orthocal.info
37 | PORT - the port for the web service to listen on; This must be set. There is no default.
38 | ALLOWED_HOSTS - See Django docs for [ALLOWED_HOSTS](https://docs.djangoproject.com/en/4.2/ref/settings/#allowed-hosts).
39 | WEB_CONCURRENCY - How many processes to run (see uvicorn docs)
40 |
41 | ## For Local Development
42 |
43 | Install dependencies:
44 |
45 | pip install -r requirements.txt
46 |
47 | Load initial data:
48 |
49 | ./manage.py collectstatic --noinput
50 | ./manage.py migrate
51 | ./manage.py loaddata calendarium commemorations
52 |
53 | Run developement server:
54 |
55 | ./manage.py runserver
56 |
57 | Run tests:
58 |
59 | ./manage.py test --keepdb
60 |
--------------------------------------------------------------------------------
/calendarium/templates/calendar_day.html:
--------------------------------------------------------------------------------
1 | {% load fullurl %}
2 |
The primary purpose for building this service was to support
12 | Orthodox Daily,
13 | an Amazon Alexa skill. Orthodox Daily makes it easy to access the daily
14 | scripture readings and commemorations. Simply ask Alexa to open Orthodox Daily, and she will
15 | provide you with a few details about today, including the fasting rules,
16 | and then read the prescribed scriptures and lives of the saints out loud.
17 | The scriptures are read from the King James Version and follow OCA rubrics
18 | and fasting guidelines. For the Alexa skill, the number of readings is
19 | abbreviated. A fuller list is available on this website. The stories from
20 | the lives of the saints are borrowed from abbamoses.com by
21 | permission.
22 |
23 |
You can also ask Alexa directly about a particular day. For instance you can
24 | say, “Alexa, ask Orthodox Daily about tomorrow.” Or you could say, “Alexa, ask
25 | Orthodox daily about the fast on Friday,” or “Alexa, ask Orthodox Daily about
26 | saints on August 10.”
27 |
28 |
If you want to skip all the extra details and go straight to the scriptures,
29 | just say, “Alexa, ask Orthodox Daily to read the scriptures.” If you missed
30 | the scriptures yesterday, you can say, “Alexa, ask Orthodox Daily to read
31 | the scriptures for yesterday.” If you'd like to jump to the lives of the
32 | saints, just say, “Alexa, ask Orthodox Daily to read the lives of the
33 | saints,” or “Alexa, ask Orthodox Daily about the commemorations on
34 | Saturday.”
35 |
36 |
There are some known bugs. Orthodox Daily runs in Pacific timezone. If you live
37 | on the east coast and open Orthodox daily at 1am, you will get the information
38 | for the previous day. Also, because the readings are specified using Septuagint
39 | versification, an incorrect reading may on rare occasions be provided (mostly
40 | in the Proverbs).
4 | {% load static %}
5 |
6 | {% block title %}Orthodox Daily Readings{% endblock %}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | {% block head %}{% endblock %}
46 |
47 |
48 | Orthodox Calendar
49 |
50 |
62 |
63 |
64 | {% block main %}{% endblock %}
65 |
66 |
67 | {% block scripts %}{% endblock %}
68 |
69 |
70 |
--------------------------------------------------------------------------------
/calendarium/tests/test_ical.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from urllib.parse import urljoin, urlparse
4 |
5 | import icalendar
6 |
7 | from django.test import TestCase
8 | from django.urls import resolve, reverse
9 | from django.utils import timezone
10 |
11 | from ..datetools import Calendar
12 | from ..ical import generate_ical
13 |
14 |
15 | class CalendarTest(TestCase):
16 | fixtures = ['calendarium.json']
17 |
18 | def test_ical(self):
19 | """ical endpoint should return 200."""
20 | url = reverse('ical', kwargs={'cal': Calendar.Gregorian})
21 | response = self.client.get(url)
22 | self.assertEqual(response.status_code, 200)
23 |
24 | def test_ical_julian(self):
25 | """ical Julian endpoint should return 200."""
26 | url = reverse('ical', kwargs={'cal': Calendar.Julian})
27 | response = self.client.get(url)
28 | self.assertEqual(response.status_code, 200)
29 |
30 | def test_ical_urls(self):
31 | """urls should point to Gregorian readings."""
32 |
33 | url = reverse('ical', kwargs={'cal': Calendar.Gregorian})
34 | response = self.client.get(url)
35 | cal = icalendar.Calendar.from_ical(response.content)
36 | for event in cal.walk('vevent'):
37 | parts = urlparse(event['url'])
38 | match = resolve(parts.path)
39 | self.assertEqual(match.kwargs['cal'], Calendar.Gregorian)
40 |
41 | def test_ical_julian_urls(self):
42 | """urls should point to Julian readings."""
43 |
44 | url = reverse('ical', kwargs={'cal': Calendar.Julian})
45 | response = self.client.get(url)
46 | cal = icalendar.Calendar.from_ical(response.content)
47 | for event in cal.walk('vevent'):
48 | parts = urlparse(event['url'])
49 | match = resolve(parts.path)
50 | self.assertEqual(match.kwargs['cal'], Calendar.Julian)
51 |
52 | async def test_ical_content(self):
53 | """ical with timestamp of Jan 7, 2022 should have Synaxis of St. John."""
54 |
55 | def build_absolute_uri(url):
56 | return urljoin('http://testserver', url)
57 |
58 | timestamp = datetime.datetime(2022, 1, 7, tzinfo=datetime.timezone.utc)
59 | cal = await generate_ical(timestamp, Calendar.Gregorian, build_absolute_uri)
60 | for event in cal.walk('vevent'):
61 | if event['dtstart'].dt.date() == timestamp.date():
62 | summary = event.decoded('summary').decode('utf-8')
63 | self.assertEqual(summary, 'Synaxis of St John the Baptist')
64 | break
65 | else:
66 | self.fail('No event for timestamp found')
67 |
68 | async def test_ical_content_julian(self):
69 | """ical with timestamp of Jan 7, 2022 should have Nativity of Christ."""
70 |
71 | def build_absolute_uri(url):
72 | return urljoin('http://testserver', url)
73 |
74 | timestamp = datetime.datetime(2022, 1, 7, tzinfo=datetime.timezone.utc)
75 | cal = await generate_ical(timestamp, Calendar.Julian, build_absolute_uri)
76 | for event in cal.walk('vevent'):
77 | if event['dtstart'].dt.date() == timestamp.date():
78 | summary = event.decoded('summary').decode('utf-8')
79 | self.assertEqual(summary, 'Nativity of Christ')
80 | break
81 | else:
82 | self.fail('No event for timestamp found')
83 |
--------------------------------------------------------------------------------
/skill-package/interactionModels/custom/en-US.json:
--------------------------------------------------------------------------------
1 | {
2 | "interactionModel": {
3 | "languageModel": {
4 | "invocationName": "orthodox daily",
5 | "intents": [
6 | {
7 | "name": "AMAZON.CancelIntent",
8 | "samples": []
9 | },
10 | {
11 | "name": "AMAZON.HelpIntent",
12 | "samples": []
13 | },
14 | {
15 | "name": "AMAZON.StopIntent",
16 | "samples": []
17 | },
18 | {
19 | "name": "Day",
20 | "slots": [
21 | {
22 | "name": "date",
23 | "type": "AMAZON.DATE"
24 | }
25 | ],
26 | "samples": [
27 | "fasting {date}",
28 | "{date}",
29 | "about {date}",
30 | "feasts",
31 | "feasts {date}",
32 | "for feasts {date}",
33 | "for fasting information {date}",
34 | "for fasting information",
35 | "for the fast {date}",
36 | "the fast",
37 | "is there a fast {date}",
38 | "fasting"
39 | ]
40 | },
41 | {
42 | "name": "Scriptures",
43 | "slots": [
44 | {
45 | "name": "date",
46 | "type": "AMAZON.DATE"
47 | }
48 | ],
49 | "samples": [
50 | "for the readings on {date}",
51 | "for the scriptures on {date}",
52 | "Read the scriptures",
53 | "Read the scriptures for {date}",
54 | "read the bible for {date}",
55 | "read the daily readings for {date}",
56 | "read the readings for {date}"
57 | ]
58 | },
59 | {
60 | "name": "AMAZON.YesIntent",
61 | "samples": []
62 | },
63 | {
64 | "name": "AMAZON.NoIntent",
65 | "samples": []
66 | },
67 | {
68 | "name": "AMAZON.NextIntent",
69 | "samples": []
70 | },
71 | {
72 | "name": "AMAZON.NavigateHomeIntent",
73 | "samples": []
74 | },
75 | {
76 | "name": "Commemorations",
77 | "slots": [
78 | {
79 | "name": "date",
80 | "type": "AMAZON.DATE"
81 | }
82 | ],
83 | "samples": [
84 | "for the lives of saints",
85 | "read the lives of saints",
86 | "for the commemorations",
87 | "about the saints",
88 | "for the life of the saints",
89 | "about the life of the saints",
90 | "read the saints",
91 | "about the life of the saint",
92 | "give me the saints",
93 | "tell me the saints for {date}",
94 | "about the saints for {date}",
95 | "about the commemorations",
96 | "for the lives of the saints",
97 | "for the saints",
98 | "saint of the day",
99 | "read the lives of the saints",
100 | "read the commemorations",
101 | "about the lives of the saints",
102 | "about the saints on {date}",
103 | "for the commemorations for {date}",
104 | "about the commemorations on {date}"
105 | ]
106 | }
107 | ],
108 | "types": []
109 | }
110 | },
111 | "version": "6"
112 | }
--------------------------------------------------------------------------------
/orthocal/templates/about.html:
--------------------------------------------------------------------------------
1 | {% extends "content_base.html" %}
2 |
3 | {% block title %}About the Orthodox Calendar{% endblock %}
4 |
5 | {% block head %}
6 |
7 | {% endblock %}
8 |
9 | {% block content %}
10 |
About
11 |
Orthocal.info is an Eastern Orthodox calendar service providing
12 | commemorations, fasting, scripture
13 | readings and other information for each day of the liturgical year.
14 | The readings follow the Slavic calendar. Fasting indications follow
15 | the Fasting & Fast-Free Seasons of the Church
16 | provided by the Orthodox Church in America (OCA). The primary purpose of
17 | this service is to provide convenient access to daily devotional material
18 | for lay persons and not to be an authoritative or comprehensive guide to
19 | the feasts and fasts of the Church. The service supports this goal by
20 | providing tools for mobile developers and parish webmasters, an Alexa
21 | skill, multiple types of feeds, and a web interface.
22 |
23 |
24 |
Contact the Author
25 |
Orthocal.info is
26 | provided to the Orthodox community with no strings attached.
27 | However, it is helpful to me, the author of the work, to know who is
28 | using it and how. I will then be able to know how best to support
29 | your usage of the api and will be able to keep you informed about
30 | changes or maintenance work that needs to be done. Please contact
31 | me via the
32 | contact form
33 | on my blog,
34 | Parochianus,
35 | and let me know about your usage. Please report bugs using the
36 | Github issue tracker.
37 | And, as always, a link and an acknowledgement is nice, but not required.
38 |
39 |
Technology & Data
40 |
The Source Code
41 | is freely available. The original version of this site was written in a
42 | combination of Go and Node.js, but has been rewritten in Python and Django
43 | since the author finds that much easier to maintain. The site is deployed on
44 | Google Cloud Run and
45 | Firebase.
46 | The algorithm and calendar data used in this service are based on the
47 | algorithm and data developed by Paul Kachur for his
48 | orthodox_calendar project.
49 | The lives of the saints are taken by permission from John Brady's
50 | Abbamoses.com.
51 |
52 |
Scriptures
53 |
Because this is open-source and free, the service must use a
54 | translation of the Bible that is either public domain or has agreeable
55 | license terms. At this point I have chosen to stick with the King James
56 | Version. While not ideal, many clergy consider this to be one of the
57 | best options for Orthodox. Because the rubrics use Septuagint
58 | versification, a handful of the Old Testament readings may be incorrect.
59 | Some of the composite readings are taken from the translations of the
60 | Archimandrite Ephrem (Lash). His translations can be accessed at
61 | Anastasis.