├── alexa ├── __init__.py ├── tests │ ├── __init__.py │ ├── data │ │ ├── launch_intent_envelope.json │ │ ├── help_intent_envelope.json │ │ ├── no_intent_envelope.json │ │ ├── next_intent_envelope_followup.json │ │ ├── next_intent_envelope.json │ │ ├── next_intent_commemorations_envelope.json │ │ ├── next_intent_commemorations_multiple_envelope.json │ │ ├── meeting_envelope.json │ │ ├── scriptures_intent_envelope_long.json │ │ └── ssml_escaping_envelope.json │ ├── test_speech.py │ └── test_intents.py ├── migrations │ └── __init__.py ├── models.py ├── admin.py ├── apps.py ├── urls.py └── views.py ├── bible ├── __init__.py ├── tests │ ├── __init__.py │ ├── test_parse.py │ └── tests_models.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── ingest_usfx.py ├── migrations │ ├── __init__.py │ ├── 0001_initial.py │ └── 0002_load_scriptures.py ├── admin.py ├── views.py ├── apps.py ├── parse.py ├── models.py └── books.py ├── commemorations ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_alter_commemoration_unique_together.py │ ├── 0003_alter_commemoration_story.py │ ├── 0006_commemoration_alt_title.py │ ├── 0004_alter_commemoration_options_commemoration_ordering.py │ ├── 0005_alter_commemoration_day_alter_commemoration_month.py │ └── 0001_initial.py ├── admin.py ├── tests.py ├── views.py ├── apps.py └── models.py ├── calendarium ├── tests │ ├── __init__.py │ ├── test_feeds.py │ ├── test_dateutils.py │ ├── test_ical.py │ ├── test_views.py │ └── test_api.py ├── migrations │ ├── __init__.py │ └── 0001_squashed_0003_rename_day_month_day_calendarium_month_f3d07a_idx_and_more.py ├── __init__.py ├── admin.py ├── apps.py ├── api_urls.py ├── liturgics │ └── __init__.py ├── templates │ ├── help.ssml │ ├── feed_description.html │ ├── calendar_day.html │ ├── oembed_calendar.html │ ├── calendar.html │ ├── calendar_embed.html │ └── readings.html ├── urls.py ├── feeds.py ├── ical.py ├── models.py └── views.py ├── orthocal ├── management │ ├── __init__.py │ └── commands │ │ └── publish.py ├── __init__.py ├── static │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-150x150.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── browserconfig.xml │ ├── site.webmanifest │ ├── print.css │ ├── safari-pinned-tab.svg │ └── main.css ├── templates │ ├── content_base.html │ ├── 500.html │ ├── 404.html │ ├── 400.html │ ├── feeds.html │ ├── api.html │ ├── alexa.html │ ├── base.html │ └── about.html ├── apps.py ├── wsgi.py ├── asgi.py ├── views.py ├── converters.py ├── urls.py ├── sitemaps.py ├── middleware.py └── decorators.py ├── public ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── mstile-150x150.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── robots.txt ├── browserconfig.xml ├── site.webmanifest ├── safari-pinned-tab.svg └── 404.html ├── skill-package ├── assets │ └── images │ │ ├── en-CA_largeIconUri.png │ │ ├── en-CA_smallIconUri.png │ │ ├── en-US_largeIconUri.png │ │ └── en-US_smallIconUri.png ├── interactionModels │ └── custom │ │ ├── en-CA.json │ │ └── en-US.json └── skill.json ├── .gitignore ├── ask-resources.json ├── .dockerignore ├── firebase.json ├── .github └── dependabot.yml ├── manage.py ├── requirements.txt ├── docker-compose.yaml ├── Makefile ├── Dockerfile ├── server.py ├── LICENSE └── README.md /alexa/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bible/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /alexa/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bible/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /alexa/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bible/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bible/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /commemorations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /calendarium/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /orthocal/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bible/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /calendarium/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /commemorations/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /orthocal/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'orthocal.apps.OrthocalConfig' 2 | -------------------------------------------------------------------------------- /calendarium/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'orthocal.apps.CalendariumConfig' 2 | -------------------------------------------------------------------------------- /alexa/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /alexa/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /bible/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /bible/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /calendarium/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /commemorations/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /commemorations/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /commemorations/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/public/mstile-150x150.png -------------------------------------------------------------------------------- /orthocal/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/orthocal/static/favicon.ico -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /orthocal/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/orthocal/static/favicon-16x16.png -------------------------------------------------------------------------------- /orthocal/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/orthocal/static/favicon-32x32.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /orthocal/static/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/orthocal/static/mstile-150x150.png -------------------------------------------------------------------------------- /orthocal/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/orthocal/static/apple-touch-icon.png -------------------------------------------------------------------------------- /orthocal/static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/orthocal/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /orthocal/static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/orthocal/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /skill-package/assets/images/en-CA_largeIconUri.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/skill-package/assets/images/en-CA_largeIconUri.png -------------------------------------------------------------------------------- /skill-package/assets/images/en-CA_smallIconUri.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/skill-package/assets/images/en-CA_smallIconUri.png -------------------------------------------------------------------------------- /skill-package/assets/images/en-US_largeIconUri.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/skill-package/assets/images/en-US_largeIconUri.png -------------------------------------------------------------------------------- /skill-package/assets/images/en-US_smallIconUri.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianglass/orthocal-python/HEAD/skill-package/assets/images/en-US_smallIconUri.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.sqlite3 3 | local_settings.py 4 | .DS_Store 5 | .ask 6 | .ackrc 7 | ve 8 | .coverage 9 | .firebase 10 | static 11 | default-cache 12 | -------------------------------------------------------------------------------- /alexa/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AlexaConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'alexa' 7 | -------------------------------------------------------------------------------- /bible/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BibleConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'bible' 7 | -------------------------------------------------------------------------------- /orthocal/templates/content_base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
{% block content %}{% endblock %}
5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /calendarium/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | class CalendariumConfig(AppConfig): 4 | default_auto_field = 'django.db.models.BigAutoField' 5 | name = 'calendarium' 6 | -------------------------------------------------------------------------------- /commemorations/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CommemorationsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'commemorations' 7 | -------------------------------------------------------------------------------- /ask-resources.json: -------------------------------------------------------------------------------- 1 | { 2 | "askcliResourcesVersion": "2020-03-31", 3 | "profiles": { 4 | "default": { 5 | "skillMetadata": { 6 | "src": "./skill-package" 7 | } 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /api/gregorian/ 3 | Disallow: /api/julian/ 4 | Disallow: /api/oca/ 5 | Disallow: /api/rocor/ 6 | Disallow: /echo/ 7 | 8 | Sitemap: https://orthocal.info/sitemap.xml 9 | -------------------------------------------------------------------------------- /alexa/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.decorators.cache import never_cache 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | path('echo/', never_cache(views.orthodox_daily_view)), 8 | ] 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | ve 2 | public 3 | .coverage 4 | .ackrc 5 | .git 6 | orthocal/local_settings.py 7 | *sqlite3 8 | **/__pycache__ 9 | **/*.pyc 10 | **/*.pyo 11 | **/*.pyd 12 | **/.DS_Store 13 | .ask 14 | skill-package 15 | .coverage 16 | .firebase 17 | static 18 | default-cache 19 | -------------------------------------------------------------------------------- /orthocal/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "content_base.html" %} 2 | 3 | {% block title %}Server Error{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

Server Error

8 |

Oops. Something went wrong.

9 |
10 | {% endblock content %} 11 | -------------------------------------------------------------------------------- /orthocal/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "content_base.html" %} 2 | 3 | {% block title %}Not Found{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

Not Found!

8 |

That url does not exist on this site.

9 |
10 | {% endblock content %} 11 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /orthocal/static/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /orthocal/templates/400.html: -------------------------------------------------------------------------------- 1 | {% extends "content_base.html" %} 2 | 3 | {% block title %}Bad Request{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

Bad Request

8 |

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 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /orthocal/static/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 20 | 21 | 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 |

{{ obj.titles.0 }}

3 | 4 |

5 | {% filter widont %} 6 | {{ obj.fast_level_desc }} 7 | {% if obj.fast_level and obj.fast_exception %}— {{ obj.fast_exception_desc }}{% endif %} 8 | {% endfilter %} 9 |

10 | 11 | {% if day.service_notes %} 12 |

Service Notes

13 |
    14 | {% for note in day.service_notes %} 15 |
  • {{ note|widont }}
  • 16 | {% endfor %} 17 |
18 | {% endif %} 19 | 20 | {% if obj.feasts %} 21 |

Feasts

22 |
    23 | {% for feast in obj.feasts %} 24 |
  • {{ feast|widont }}
  • 25 | {% endfor %} 26 |
27 | {% endif %} 28 | 29 | {% if obj.saints %} 30 |

Commemorations

31 |
    32 | {% for saint in obj.saints %} 33 |
  • {{ saint|widont }}
  • 34 | {% endfor %} 35 |
36 | {% endif %} 37 | 38 |

Scripture Readings (KJV)

39 | 40 | {% for reading in obj.get_readings %} 41 |
42 |

43 | {% filter widont %} 44 | {{ reading.pericope.display }} 45 | ({{ reading.source }}{% if reading.desc %}, {{ reading.desc }}{% endif %}) 46 | {% endfilter %} 47 |

48 | 49 |

50 | {% for verse in reading.pericope.get_passage %} 51 | {% if verse.paragraph_start and not forloop.first %}

{% endif %} 52 | {{ verse.verse }} {{ verse.content }} 53 | {% endfor %} 54 |

55 |
56 | {% endfor %} 57 | 58 | {% if obj.stories %} 59 |

Commemorations

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

20 | 21 |
{% fullurl "rss-feed" %}
22 |
{% fullurl "rss-feed-cal" cal="gregorian" %}
23 |
{% fullurl "rss-feed-cal" cal="julian" %}
24 | 25 |

iCal

26 | 27 |

An ical feed for the new calendar is available at:

28 | 29 |
{% fullurl "ical" cal="gregorian" %}
30 | 31 |

Or for the old calendar:

32 | 33 |
{% fullurl "ical" cal="julian" %}
34 | 35 |

You can see what this looks like in Google Calendar below.

36 | 37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /orthocal/templates/api.html: -------------------------------------------------------------------------------- 1 | {% extends "content_base.html" %} 2 | {% load fullurl %} 3 | 4 | {% block title %}Orthodox Calendar and Readings API{% endblock %} 5 | 6 | {% block head %} 7 | 8 | {% endblock %} 9 | 10 | {% block content %} 11 |

API Documentation

12 | 13 |

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 | 5 | 6 | {% else %} 7 | onclick="location.href='{% url "readings" cal=cal year=day.gregorian_date.year month=day.gregorian_date.month day=day.gregorian_date.day %}'"> 8 | 9 | {% endif %} 10 |

11 | {{ day_number }} 12 |

13 | 14 | {# Only show fasting information on fast days #} 15 | {% if day.fast_level and day.fast_level != 10 or day.fast_level == 11 %} 16 |

{{ day.fast_level_desc }}{% if day.fast_level and day.fast_exception_desc %}—{{ day.fast_exception_desc }}{% endif %}

17 | {% endif %} 18 | 19 | {% if day.service_notes %} 20 |
    21 | {% for note in day.service_notes %} 22 |
  • {{ note }}
  • 23 | {% endfor %} 24 |
25 | {% endif %} 26 | 27 | {% if day.weekday == 0 or day.pdist > -9 and day.pdist < 7 %} 28 | {% if day.titles %} 29 |
    30 | {% for title in day.titles %} 31 |
  • {{ title }}
  • 32 | {% endfor %} 33 |
34 | {% endif %} 35 | {% endif %} 36 | 37 | 38 | {% if day.feasts %} 39 |
    40 | {% for feast in day.feasts %} 41 |
  • {{ feast }}
  • 42 | {% endfor %} 43 |
44 | {% endif %} 45 | 46 | {% if day.saints %} 47 |
    48 | {% for saint in day.saints %} 49 |
  • {{ saint }}
  • 50 | {% endfor %} 51 |
52 | {% endif %} 53 |
54 | 55 | -------------------------------------------------------------------------------- /calendarium/templates/oembed_calendar.html: -------------------------------------------------------------------------------- 1 | 87 | 88 | {{ content|safe }} 89 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Page Not Found 7 | 8 | 23 | 24 | 25 |
26 |

404

27 |

Page Not Found

28 |

The specified file was not found on this website. Please check the URL for mistakes and try again.

29 |

Why am I seeing this?

30 |

This page was generated by the Firebase Command-Line Interface. To modify it, edit the 404.html file in your project's configured public directory.

31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /orthocal/urls.py: -------------------------------------------------------------------------------- 1 | """orthocal URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/4.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib.sitemaps.views import sitemap 17 | from django.urls import include, path, register_converter, reverse 18 | from django.views.generic.base import RedirectView 19 | 20 | from . import converters, sitemaps, views 21 | 22 | register_converter(converters.CalendarConverter, 'cal') 23 | register_converter(converters.YearConverter, 'year') 24 | register_converter(converters.MonthConverter, 'month') 25 | register_converter(converters.DayConverter, 'day') 26 | 27 | sitemaps = { 28 | 'static': sitemaps.StaticViewSitemap, 29 | 'calendar': sitemaps.CalendarSitemap, 30 | 'calendar-julian': sitemaps.CalendarJulianSitemap, 31 | 'readings': sitemaps.ReadingsSitemap, 32 | 'readings-julian': sitemaps.ReadingsJulianSitemap, 33 | } 34 | 35 | urlpatterns = [ 36 | path('alexa/', views.alexa, name='alexa'), 37 | path('api/', views.api, name='api'), 38 | path('ical/', RedirectView.as_view(permanent=True, pattern_name='feeds')), 39 | path('feeds/', views.feeds, name='feeds'), 40 | path('about/', views.about, name='about'), 41 | path('api/', include('calendarium.api_urls')), 42 | path('', include('alexa.urls')), 43 | path('', include('calendarium.urls')), 44 | path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='sitemap'), 45 | path('startup/', views.startup_probe), 46 | path('health/', views.startup_probe), 47 | ] 48 | -------------------------------------------------------------------------------- /orthocal/sitemaps.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.contrib import sitemaps 4 | from django.urls import reverse 5 | from django.utils import timezone 6 | 7 | from dateutil.relativedelta import relativedelta 8 | from dateutil.rrule import rrule, DAILY, MONTHLY 9 | 10 | 11 | class StaticViewSitemap(sitemaps.Sitemap): 12 | priority = 1.0 13 | 14 | def items(self): 15 | return ['index', 'alexa', 'api', 'feeds', 'about', 'api:openapi-view'] 16 | 17 | def location(self, item): 18 | return reverse(item) 19 | 20 | def changefreq(self, item): 21 | return 'daily' if item == 'index' else 'monthly' 22 | 23 | 24 | class CalendarSitemap(sitemaps.Sitemap): 25 | priority = 0.50 26 | changefreq = 'monthly' 27 | 28 | def items(self): 29 | timestamp = timezone.localtime().replace(day=1) 30 | start_dt = timestamp.date() - relativedelta(months=12) 31 | end_dt = start_dt + relativedelta(months=24) 32 | return rrule(MONTHLY, dtstart=start_dt, until=end_dt) 33 | 34 | def location(self, item): 35 | return reverse('calendar', kwargs={'year': item.year, 'month': item.month, 'cal': 'gregorian',}) 36 | 37 | 38 | class CalendarJulianSitemap(CalendarSitemap): 39 | def location(self, item): 40 | return reverse('calendar', kwargs={'year': item.year, 'month': item.month, 'cal': 'julian',}) 41 | 42 | 43 | class ReadingsSitemap(sitemaps.Sitemap): 44 | priority = 0.75 45 | changefreq = 'monthly' 46 | 47 | def items(self): 48 | timestamp = timezone.localtime().replace(day=1) 49 | start_dt = timestamp.date() - timedelta(days=365) 50 | end_dt = start_dt + timedelta(days=365*2) 51 | return rrule(DAILY, dtstart=start_dt, until=end_dt) 52 | 53 | def location(self, item): 54 | return reverse('readings', kwargs={'year': item.year, 'month': item.month, 'day': item.day, 'cal': 'gregorian',}) 55 | 56 | 57 | class ReadingsJulianSitemap(ReadingsSitemap): 58 | def location(self, item): 59 | return reverse('readings', kwargs={'year': item.year, 'month': item.month, 'day': item.day, 'cal': 'julian',}) 60 | -------------------------------------------------------------------------------- /calendarium/feeds.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from dateutil.rrule import rrule, DAILY 4 | from django.conf import settings 5 | from django.contrib.syndication.views import Feed 6 | from django.template.loader import render_to_string 7 | from django.urls import reverse 8 | from django.utils import timezone 9 | from django.utils.feedgenerator import Rss201rev2Feed 10 | 11 | from . import liturgics 12 | from .datetools import Calendar 13 | 14 | 15 | class WSRssFeed(Rss201rev2Feed): 16 | def add_root_elements(self, handler): 17 | super().add_root_elements(handler) 18 | handler.addQuickElement('atom:link', '', { 19 | 'rel': 'hub', 20 | 'href': settings.ORTHOCAL_WEBSUB_URL, 21 | }) 22 | 23 | 24 | class ReadingsFeed(Feed): 25 | feed_type = WSRssFeed 26 | link = '/' 27 | description_template = 'feed_description.html' 28 | item_categories = categories = 'orthodox', 'christian', 'religion' 29 | 30 | def get_object(self, request, cal=Calendar.Gregorian): 31 | return {'cal': cal} 32 | 33 | def title(self, obj): 34 | return f'Orthodox Daily Readings ({obj["cal"].title()})' 35 | 36 | def description(self, obj): 37 | return f'Daily readings from scripture and the lives of the saints according to the {obj["cal"].title()} calendar.' 38 | 39 | def items(self, obj): 40 | now = timezone.localtime() 41 | start_dt = now - timedelta(days=10) 42 | for dt in rrule(DAILY, dtstart=start_dt, until=now): 43 | day = liturgics.Day(dt.year, dt.month, dt.day, calendar=obj['cal']) 44 | day.initialize() 45 | yield day 46 | 47 | def item_pubdate(self, day): 48 | dt = day.gregorian_date 49 | tzinfo = timezone.get_current_timezone() 50 | return datetime(dt.year, dt.month, dt.day, tzinfo=tzinfo) 51 | 52 | def item_title(self, day): 53 | return day.summary_title 54 | 55 | def item_link(self, day): 56 | dt = day.gregorian_date 57 | return reverse('readings', kwargs={ 58 | 'cal': day.pyear.calendar, 59 | 'year': dt.year, 60 | 'month': dt.month, 61 | 'day': dt.day 62 | }) 63 | -------------------------------------------------------------------------------- /skill-package/interactionModels/custom/en-CA.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 | "commemorations", 32 | "saints", 33 | "saints {date}", 34 | "commemorations {date}", 35 | "feasts {date}", 36 | "for feasts {date}", 37 | "for commemorations {date}", 38 | "for fasting information {date}", 39 | "for fasting information", 40 | "for the fast {date}", 41 | "the fast", 42 | "is there a fast {date}", 43 | "fasting" 44 | ] 45 | }, 46 | { 47 | "name": "Scriptures", 48 | "slots": [ 49 | { 50 | "name": "date", 51 | "type": "AMAZON.DATE" 52 | } 53 | ], 54 | "samples": [ 55 | "for the readings on {date}", 56 | "for the scriptures on {date}", 57 | "Read the scriptures", 58 | "Read the scriptures for {date}", 59 | "read the bible for {date}", 60 | "read the daily readings for {date}", 61 | "read the readings for {date}" 62 | ] 63 | }, 64 | { 65 | "name": "AMAZON.YesIntent", 66 | "samples": [] 67 | }, 68 | { 69 | "name": "AMAZON.NoIntent", 70 | "samples": [] 71 | }, 72 | { 73 | "name": "AMAZON.NextIntent", 74 | "samples": [] 75 | } 76 | ] 77 | } 78 | }, 79 | "version": "1" 80 | } -------------------------------------------------------------------------------- /orthocal/templates/alexa.html: -------------------------------------------------------------------------------- 1 | {% extends "content_base.html" %} 2 | 3 | {% block title %}Alexa Skill for the Orthodox Calendar{% endblock %} 4 | 5 | {% block head %} 6 | 7 | {% endblock %} 8 | 9 | {% block content %} 10 |

Alexa Skill

11 |

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).

41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /calendarium/templates/calendar.html: -------------------------------------------------------------------------------- 1 | {% extends "content_base.html" %} 2 | {% load fullurl %} 3 | 4 | {% block title %}{% if request.resolver_match.url_name == "calendar-default" %}Orthodox Monthly Calendar{% else %}Orthodox Calendar for {{ this_month|date:"F Y" }}{% endif %}{% endblock %} 5 | 6 | {% block head %} 7 | {% if request.resolver_match.url_name == "calendar-default" %} 8 | 9 | {% else %} 10 | 11 | {% endif %} 12 | 13 | 14 | 15 | 16 | {% if noindex %} 17 | 18 | {% endif %} 19 | {% endblock %} 20 | 21 | {% block extra_nav %} 22 | ← {{ previous_month|date:"F" }} 23 | {{ next_month|date:"F" }} → 24 | 25 |
26 | 27 | 28 | 29 | {% endblock extra_nav %} 30 | 31 | {% block main %} 32 | {{ content|safe }} 33 | {% endblock %} 34 | 35 | {% block scripts %} 36 | 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /calendarium/templates/calendar_embed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 97 | 98 | 99 | {{ content|safe }} 100 | 101 | 102 | -------------------------------------------------------------------------------- /bible/parse.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from xml.dom import pulldom 4 | 5 | space_re = re.compile(r'\s+', flags=re.DOTALL) 6 | 7 | def parse_usfx(filename): 8 | book, chapter, verse = None, None, None 9 | paragraph_start = False 10 | is_valid_content = False 11 | strings = [] 12 | 13 | def make_verse(): 14 | nonlocal is_valid_content, paragraph_start 15 | 16 | content = ''.join(strings) 17 | content = space_re.sub(' ', content) 18 | 19 | # Older printings of the KJV started each verse on a new 20 | # line and used a paragraph symbol to indicate the 21 | # paragraph breaks. The

elements don't seem to quite 22 | # line up with them. We strip the paragraph symbols since 23 | # we are using the

elements. 24 | content = content.replace('¶','').strip() 25 | 26 | result = { 27 | 'book': book, 28 | 'chapter': chapter, 29 | 'verse': verse, 30 | 'content': content, 31 | 'paragraph_start': paragraph_start, 32 | } 33 | 34 | strings.clear() 35 | is_valid_content = False 36 | paragraph_start = False 37 | 38 | return result 39 | 40 | for event, node in pulldom.parse(filename): 41 | match [event, node.nodeName]: 42 | # Book element 43 | case [pulldom.START_ELEMENT, 'book']: 44 | if is_valid_content: 45 | yield make_verse() 46 | 47 | book = node.getAttribute('id') 48 | 49 | # Chapter element 50 | case [pulldom.START_ELEMENT, 'c']: 51 | if is_valid_content: 52 | yield make_verse() 53 | 54 | chapter = node.getAttribute('id') 55 | 56 | # Verse elements 57 | case [pulldom.START_ELEMENT, 'v']: 58 | if is_valid_content: 59 | yield make_verse() 60 | 61 | verse = node.getAttribute('id') 62 | is_valid_content = True 63 | case [pulldom.START_ELEMENT, 've']: 64 | yield make_verse() 65 | 66 | # paragraph element 67 | case [pulldom.START_ELEMENT, 'p']: 68 | paragraph_start = True 69 | 70 | # Footnote Element 71 | case [pulldom.START_ELEMENT, 'f']: 72 | is_valid_content = False 73 | case [pulldom.END_ELEMENT, 'f']: 74 | is_valid_content = True 75 | 76 | # Character content 77 | case [pulldom.CHARACTERS, _]: 78 | if is_valid_content: 79 | strings.append(node.wholeText) 80 | -------------------------------------------------------------------------------- /calendarium/tests/test_dateutils.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from django.test import TestCase 4 | 5 | from .. import datetools 6 | 7 | 8 | class TestDateutil(TestCase): 9 | def test_gregorian_date_to_jdn(self): 10 | tests = [ 11 | (date(2018, 1, 15), 2458134), 12 | (date(2000, 5, 29), 2451694), 13 | ] 14 | 15 | for day, expected in tests: 16 | with self.subTest(): 17 | actual = datetools.gregorian_to_jdn(day) 18 | self.assertEqual(actual, expected) 19 | 20 | def test_julian_to_jdn(self): 21 | expected = 2455676 22 | actual = datetools.julian_to_jdn(date(2011, 4, 11)) 23 | self.assertEqual(expected, actual) 24 | 25 | def test_compute_pascha_jdn(self): 26 | data = [ 27 | (2022, 2459694), 28 | (2011, 2455676), 29 | ] 30 | 31 | for year, pascha in data: 32 | with self.subTest(year): 33 | actual = datetools.compute_pascha_jdn(year) 34 | self.assertEqual(pascha, actual) 35 | 36 | def test_compute_pascha_distance(self): 37 | tests = [ 38 | (date(2018, 5, 9), 31, 2018), 39 | (date(2018, 1, 1), 260, 2017), 40 | ] 41 | 42 | for dt, expected_distance, expected_year in tests: 43 | with self.subTest(): 44 | distance, year = datetools.compute_pascha_distance(dt) 45 | self.assertEqual(expected_distance, distance) 46 | self.assertEqual(expected_year, year) 47 | 48 | def test_weekday_from_pdist(self): 49 | data = [ 50 | (-15, datetools.Weekday.Saturday), 51 | (-14, datetools.Weekday.Sunday), 52 | (-13, datetools.Weekday.Monday), 53 | (-1, datetools.Weekday.Saturday), 54 | (0, datetools.Weekday.Sunday), 55 | (31, datetools.Weekday.Wednesday), 56 | (49, datetools.Weekday.Sunday), 57 | ] 58 | 59 | for distance, expected in data: 60 | with self.subTest(distance): 61 | actual = datetools.weekday_from_pdist(distance) 62 | self.assertEqual(expected, actual) 63 | 64 | def test_surrounding_weekends(self): 65 | data = [ 66 | (37, (34, 35, 41, 42)), # May 23, 2023 67 | (41, (34, 35, 48, 42)), # May 27, 2023 68 | (-61, (-64, -63, -57, -56)), # February 14, 2023 69 | (-63, (-64, -70, -57, -56)), # February 12, 2023 70 | ] 71 | 72 | for pdist, expected in data: 73 | with self.subTest(pdist): 74 | actual = datetools.surrounding_weekends(pdist) 75 | self.assertEqual(expected, actual) 76 | -------------------------------------------------------------------------------- /orthocal/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load google_fonts %}{% load i18n %}{% get_current_language as LANGUAGE_CODE %} 2 | 3 | 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.

62 | {% endblock %} 63 | -------------------------------------------------------------------------------- /calendarium/ical.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import zoneinfo 3 | 4 | from datetime import date, datetime, timedelta 5 | 6 | import icalendar 7 | 8 | from dateutil.rrule import rrule, DAILY 9 | from django.conf import settings 10 | from django.core.cache import cache 11 | from django.http import HttpResponse 12 | from django.urls import reverse 13 | from django.utils import timezone 14 | from django.views.decorators.cache import cache_control 15 | 16 | from . import liturgics 17 | from .datetools import Calendar 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | @cache_control(max_age=settings.ORTHOCAL_ICAL_TTL*60*60) 22 | async def ical(request, cal=Calendar.Gregorian): 23 | key = f'ical-feed-{cal}' 24 | 25 | if not (serialized_calendar := await cache.aget(key)): 26 | timestamp = timezone.localtime() 27 | calendar = await generate_ical(timestamp, cal, request.build_absolute_uri) 28 | serialized_calendar = calendar.to_ical() 29 | await cache.aset(key, serialized_calendar, timeout=settings.ORTHOCAL_ICAL_TTL*60*60) 30 | 31 | return HttpResponse(serialized_calendar, content_type='text/calendar') 32 | 33 | async def generate_ical(timestamp, cal, build_absolute_uri): 34 | title = cal.title() 35 | ttl = settings.ORTHOCAL_ICAL_TTL 36 | 37 | calendar = icalendar.Calendar() 38 | calendar.add('prodid', '-//brianglass//Orthocal//en') 39 | calendar.add('version', '2.0') 40 | calendar.add('name', f'Orthodox Feasts and Fasts ({title})') 41 | calendar.add('x-wr-calname', f'Orthodox Feasts and Fasts ({title})') 42 | calendar.add('refresh-interval;value=duration', f'PT{ttl}H') 43 | calendar.add('x-published-ttl', f'PT{ttl}H') 44 | calendar.add('timezone-id', settings.ORTHOCAL_ICAL_TZ) 45 | calendar.add('x-wr-timezone', settings.ORTHOCAL_ICAL_TZ) 46 | 47 | start_dt = timestamp.date() - timedelta(days=30) 48 | end_dt = start_dt + timedelta(days=30 * 7) 49 | 50 | for dt in rrule(DAILY, dtstart=start_dt, until=end_dt): 51 | day = liturgics.Day(dt.year, dt.month, dt.day, calendar=cal) 52 | await day.ainitialize() 53 | 54 | day_path = reverse('readings', kwargs={ 55 | 'cal': cal, 56 | 'year': dt.year, 57 | 'month': dt.month, 58 | 'day': dt.day 59 | }) 60 | url = build_absolute_uri(day_path) 61 | uid = f'{dt.strftime("%Y-%m-%d")}.{title}@orthocal.info' 62 | 63 | event = icalendar.Event() 64 | event.add('uid', uid) 65 | event.add('dtstamp', timestamp) 66 | event.add('dtstart', icalendar.vDate(dt)) # We use vDate to make an all-day event 67 | event.add('summary', day.summary_title) 68 | event.add('description', await ical_description(day, url)) 69 | event.add('url', url) 70 | event.add('class', 'public') 71 | calendar.add_component(event) 72 | 73 | return calendar 74 | 75 | async def ical_description(day, url): 76 | description = '' 77 | 78 | if day.fast_exception_desc and day.fast_level: 79 | description += f'{day.fast_level_desc} \u2013 {day.fast_exception_desc}\n\n' 80 | else: 81 | description += f'{day.fast_level_desc}\n\n' 82 | 83 | if day.feasts: 84 | description += ' \u2022 '.join(day.feasts) + '\n\n' 85 | 86 | if day.saints: 87 | description += ' \u2022 '.join(day.saints) + '\n\n' 88 | 89 | for reading in await day.aget_readings(): 90 | if reading.desc: 91 | description += f'{reading.pericope.display} ({reading.source}, {reading.desc})\n' 92 | else: 93 | description += f'{reading.pericope.display} ({reading.source})\n' 94 | 95 | # HTML links seem to actually work in Google Calendar, but not ical, so we 96 | # just leave the link raw. 97 | description += f'\nFollow this link for full readings:\n{url}' 98 | 99 | return description 100 | -------------------------------------------------------------------------------- /orthocal/middleware.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | 4 | import newrelic.agent 5 | 6 | from asgiref.sync import iscoroutinefunction, markcoroutinefunction 7 | from django.conf import settings 8 | from django.utils import timezone 9 | from django.utils.cache import get_max_age, patch_cache_control, patch_vary_headers 10 | from django.utils.decorators import sync_and_async_middleware 11 | from google.cloud.logging_v2.handlers.middleware import RequestMiddleware 12 | from newrelic.api.transaction import current_transaction 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | @sync_and_async_middleware 17 | def cache_control(get_response): 18 | if iscoroutinefunction(get_response): 19 | async def middleware(request): 20 | response = await get_response(request) 21 | patch_headers(response) 22 | return response 23 | else: 24 | def middleware(request): 25 | response = get_response(request) 26 | patch_headers(response) 27 | return response 28 | 29 | return middleware 30 | 31 | @sync_and_async_middleware 32 | def request_queueing(get_response): 33 | if iscoroutinefunction(get_response): 34 | async def middleware(request): 35 | set_request_queueing(request) 36 | return await get_response(request) 37 | else: 38 | def middleware(request): 39 | set_request_queueing(request) 40 | return get_response(request) 41 | 42 | return middleware 43 | 44 | @sync_and_async_middleware 45 | def log_language(get_response): 46 | def log_language(request): 47 | if accept_language := request.META.get('HTTP_ACCEPT_LANGUAGE'): 48 | language = accept_language.split(';')[0].split(',')[0] 49 | logger.debug(f"Language: {language}") 50 | 51 | if iscoroutinefunction(get_response): 52 | async def middleware(request): 53 | log_language(request) 54 | return await get_response(request) 55 | else: 56 | def middleware(request): 57 | log_language(request) 58 | return get_response(request) 59 | 60 | return middleware 61 | 62 | @sync_and_async_middleware 63 | def google_logging_middleware(get_response): 64 | if iscoroutinefunction(get_response): 65 | async def middleware(request): 66 | response = await get_response(request) 67 | google_middleware = RequestMiddleware(lambda request: response) 68 | return google_middleware(request) 69 | else: 70 | middleware = RequestMiddleware(get_response) 71 | 72 | return middleware 73 | 74 | # Helper functions 75 | 76 | def patch_headers(response): 77 | # We don't let the browser cache as long as the CDN in case we make changes 78 | # to the site. 79 | max_age = get_max_age(response) or settings.ORTHOCAL_MAX_AGE 80 | 81 | # We allow the CDN to cache until midnight. We can purge the cache by 82 | # redeploying to Firebase. 83 | now = timezone.localtime() 84 | midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1) 85 | cdn_max_age = int((midnight - now).total_seconds()) 86 | 87 | # s-maxage is for Firebase CDN. 88 | patch_cache_control(response, public=True, max_age=max_age, s_maxage=cdn_max_age) 89 | patch_vary_headers(response, settings.ORTHOCAL_VARY_HEADERS) 90 | 91 | def set_request_queueing(request): 92 | """Set Newrelic "request queuing" from Fastly X-Timer header.""" 93 | 94 | # See https://developer.fastly.com/reference/http/http-headers/X-Timer/ 95 | x_timer = request.META.get('HTTP_X_TIMER') 96 | transaction = newrelic.agent.current_transaction() 97 | 98 | if x_timer and transaction: 99 | fields = x_timer.split(',') 100 | try: 101 | transaction.queue_start = float(fields[0][1:]) 102 | except (IndexError, ValueError): 103 | pass 104 | -------------------------------------------------------------------------------- /calendarium/migrations/0001_squashed_0003_rename_day_month_day_calendarium_month_f3d07a_idx_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-08-08 13:32 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | replaces = [('calendarium', '0001_initial'), ('calendarium', '0002_alter_pericope_unique_together'), ('calendarium', '0003_rename_day_month_day_calendarium_month_f3d07a_idx_and_more')] 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Composite', 19 | fields=[ 20 | ('composite_num', models.SmallIntegerField(primary_key=True, serialize=False)), 21 | ('content', models.TextField()), 22 | ], 23 | ), 24 | migrations.CreateModel( 25 | name='Pericope', 26 | fields=[ 27 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ('pericope', models.CharField(max_length=8)), 29 | ('book', models.CharField(max_length=16)), 30 | ('display', models.CharField(max_length=128)), 31 | ('sdisplay', models.CharField(max_length=64)), 32 | ('desc', models.CharField(max_length=128)), 33 | ('preverse', models.CharField(max_length=8)), 34 | ('prefix', models.CharField(max_length=255)), 35 | ('prefixb', models.CharField(max_length=128)), 36 | ('verses', models.CharField(max_length=128)), 37 | ('suffix', models.CharField(max_length=255)), 38 | ('flag', models.SmallIntegerField()), 39 | ], 40 | options={ 41 | 'unique_together': {('pericope', 'book')}, 42 | }, 43 | ), 44 | migrations.CreateModel( 45 | name='Day', 46 | fields=[ 47 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 48 | ('pdist', models.SmallIntegerField(db_index=True)), 49 | ('month', models.SmallIntegerField()), 50 | ('day', models.SmallIntegerField()), 51 | ('title', models.CharField(max_length=255)), 52 | ('subtitle', models.CharField(max_length=128)), 53 | ('feast_name', models.CharField(max_length=255)), 54 | ('feast_level', models.SmallIntegerField()), 55 | ('service', models.SmallIntegerField()), 56 | ('service_note', models.CharField(max_length=64)), 57 | ('saint', models.CharField(max_length=128)), 58 | ('fast', models.SmallIntegerField()), 59 | ('fast_exception', models.SmallIntegerField()), 60 | ('flag', models.SmallIntegerField()), 61 | ], 62 | options={ 63 | 'indexes': [models.Index(fields=['month', 'day'], name='calendarium_month_f3d07a_idx')], 64 | }, 65 | ), 66 | migrations.CreateModel( 67 | name='Reading', 68 | fields=[ 69 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 70 | ('month', models.SmallIntegerField()), 71 | ('day', models.SmallIntegerField()), 72 | ('pdist', models.SmallIntegerField(db_index=True)), 73 | ('source', models.CharField(max_length=64)), 74 | ('desc', models.CharField(max_length=64)), 75 | ('ordering', models.SmallIntegerField()), 76 | ('flag', models.SmallIntegerField()), 77 | ('pericope', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='calendarium.pericope')), 78 | ], 79 | options={ 80 | 'indexes': [models.Index(fields=['month', 'day'], name='calendarium_month_f02834_idx')], 81 | }, 82 | ), 83 | ] 84 | -------------------------------------------------------------------------------- /bible/models.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import operator 3 | import re 4 | import textwrap 5 | 6 | from django.db import models 7 | from django.db.models import Q 8 | 9 | from . import books 10 | 11 | ref_re = re.compile(r'(?:([\w\s]+)\s+)?(\d.*)') 12 | # matches patterns like 26:40-27:2. In this example, group 1 is 26, group 2 is 13 | # 40, group 3 is 27, and group 4 is 2. 14 | range_re = re.compile(r'(\d+)(?:[\.:](\d+))?(?:-(?:(\d+)[\.:])?(\d+))?') 15 | 16 | 17 | class ReferenceParseError(Exception): 18 | pass 19 | 20 | 21 | class VerseManager(models.Manager): 22 | def lookup_reference(self, reference, language='en'): 23 | conditionals = [] 24 | book = '' 25 | 26 | for passage in re.split(r'\s*;\s*', reference): 27 | 28 | # Parse out the book and verse range 29 | m = ref_re.match(passage) 30 | book_abbreviation, specification = m.groups() 31 | if book_abbreviation: 32 | book = books.normalize_book_name(book_abbreviation) 33 | 34 | # Iterate over the verse ranges 35 | previous_chapter = '' 36 | for verse_range in re.split(r',\s*', specification): 37 | 38 | # Parse out the range of verses 39 | m = range_re.match(verse_range) 40 | last_chapter, last_verse = m.group(3), m.group(4) 41 | if books.is_chapterless(book): 42 | first_chapter, first_verse = 1, m.group(1) 43 | elif m.group(2): 44 | first_chapter, first_verse = m.group(1), m.group(2) 45 | elif previous_chapter: 46 | first_chapter, first_verse = previous_chapter, m.group(1) 47 | else: 48 | first_chapter, first_verse = m.group(1), None 49 | 50 | # create conditionals for the query 51 | if last_verse: 52 | if last_chapter and last_chapter != first_chapter: 53 | # Handle ranges that span chapters 54 | conditional = Q(book=book) & ( 55 | (Q(chapter=first_chapter) & Q(verse__gte=first_verse)) | 56 | (Q(chapter__gt=first_chapter) & Q(chapter__lt=last_chapter)) | 57 | (Q(chapter=last_chapter) & Q(verse__lte=last_verse)) 58 | ) 59 | else: 60 | # Handle ranges that are contained within a single chapter 61 | conditional = Q(book=book) & Q(chapter=first_chapter) & Q(verse__gte=first_verse) & Q(verse__lte=last_verse) 62 | elif first_verse: 63 | # Handle a single verse 64 | conditional = Q(book=book) & Q(chapter=first_chapter) & Q(verse=first_verse) 65 | else: 66 | # Handle full chapters 67 | conditional = Q(book=book) & Q(chapter=first_chapter) 68 | 69 | conditionals.append(conditional) 70 | 71 | # Remember the most recently used chapter, unless it was a full chapter. 72 | # If it was a full chapter, it can't be reused in a subsequent range. 73 | if last_chapter: 74 | previous_chapter = last_chapter 75 | elif first_verse: 76 | previous_chapter = first_chapter 77 | 78 | # Run the query 79 | expression = functools.reduce(operator.or_, conditionals) 80 | return self.filter(language=language).filter(expression) 81 | 82 | 83 | class Verse(models.Model): 84 | book = models.CharField(max_length=3, db_index=True) 85 | chapter = models.IntegerField(db_index=True) 86 | verse = models.IntegerField(db_index=True) 87 | content = models.TextField() 88 | paragraph_start = models.BooleanField(default=False) 89 | language = models.CharField(max_length=10) 90 | 91 | objects = VerseManager() 92 | 93 | class Meta: 94 | unique_together = 'book', 'chapter', 'verse', 'language' 95 | 96 | def __str__(self): 97 | blurb = textwrap.shorten(self.content, width=20, placeholder='...') 98 | return f'{self.book} {self.chapter}:{self.verse} {blurb}' 99 | -------------------------------------------------------------------------------- /orthocal/decorators.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import hashlib 3 | import logging 4 | import newrelic.agent 5 | 6 | from django.conf import settings 7 | from django.core.cache import cache as default_cache 8 | from django.utils import timezone 9 | from django.utils.cache import get_cache_key, has_vary_header, learn_cache_key, patch_response_headers 10 | from django.views.decorators.cache import cache_page 11 | from django.views.decorators.http import etag as etag_decorator 12 | 13 | from calendarium.datetools import Calendar 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | # Helper functions 18 | 19 | def get_etag(request, *args, **kwargs): 20 | hash = hashlib.md5() 21 | 22 | hash.update(settings.ORTHOCAL_REVISION.encode('utf8')) 23 | 24 | for header in settings.ORTHOCAL_VARY_HEADERS: 25 | if value := request.headers.get(header): 26 | hash.update(f'{header}: {value}'.encode('utf8')) 27 | 28 | return f'"{hash.hexdigest()}"' 29 | 30 | def get_date_variable_etag(request, *args, **kwargs): 31 | hash = hashlib.md5() 32 | 33 | now = timezone.localtime() 34 | date = f'{now.year}/{now.month}/{now.day}' 35 | hash.update(date.encode('utf8')) 36 | 37 | hash.update(settings.ORTHOCAL_REVISION.encode('utf8')) 38 | 39 | cal = request.session.get('cal', Calendar.Gregorian) 40 | hash.update(cal.encode('utf8')) 41 | 42 | for header in settings.ORTHOCAL_VARY_HEADERS: 43 | if value := request.headers.get(header): 44 | hash.update(f'{header}: {value}'.encode('utf8')) 45 | 46 | return f'"{hash.hexdigest()}"' 47 | 48 | # Decorators 49 | 50 | etag = etag_decorator(get_etag) 51 | etag_date = etag_decorator(get_date_variable_etag) 52 | cache = cache_page(settings.ORTHOCAL_MAX_AGE) 53 | 54 | def instrument_endpoint(view): 55 | @functools.wraps(view) 56 | async def wrapped_view(*args, **kwargs): 57 | transaction_name = f"{view.__module__}:{view.__name__}" 58 | newrelic.agent.set_transaction_name(transaction_name) 59 | return await view(*args, **kwargs) 60 | 61 | return wrapped_view 62 | 63 | def acache_page(timeout, cache=None, key_prefix=None): 64 | """Asynchronous version of Django's cache_page decorator.""" 65 | 66 | if not cache: 67 | cache = default_cache 68 | 69 | def decorator(view): 70 | @functools.wraps(view) 71 | async def wrapped_view(request, *args, **kwargs): 72 | 73 | # Fetch from cache if available 74 | if request.method in ('GET', 'HEAD'): 75 | if key := get_cache_key(request, key_prefix, request.method, cache=cache): 76 | if response := await cache.aget(key): 77 | return response 78 | 79 | response = await view(request, *args, **kwargs) 80 | 81 | if request.method not in ('GET', 'HEAD'): 82 | return response 83 | 84 | # Don't cache responses that set a user-specific (and maybe security 85 | # sensitive) cookie in response to a cookie-less request. 86 | if not request.COOKIES and response.cookies and has_vary_header(response, 'Cookie'): 87 | return response 88 | 89 | if 'private' in response.get('Cache-Control', ()): 90 | return response 91 | 92 | patch_response_headers(response, timeout) 93 | 94 | # Store the response in the cache. 95 | if timeout and response.status_code == 200: 96 | cache_key = learn_cache_key(request, response, timeout, key_prefix=key_prefix, cache=cache) 97 | if hasattr(response, 'render') and callable(response.render): 98 | view_name = f'{view.__module__}:{view.__name__}' 99 | logger.warning(f'The acache_page decorator cannot asynchronously ' 100 | f'cache a TemplateResponse for view: {view_name}.') 101 | response.add_post_render_callback(lambda r: cache.set(cache_key, r, timeout)) 102 | else: 103 | await cache.aset(cache_key, response, timeout) 104 | 105 | return response 106 | 107 | return wrapped_view 108 | return decorator 109 | 110 | acache = acache_page(settings.ORTHOCAL_MAX_AGE) 111 | -------------------------------------------------------------------------------- /calendarium/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from django.test import RequestFactory, TestCase 4 | from django.urls import reverse 5 | from django.utils import timezone 6 | from http.cookies import SimpleCookie 7 | 8 | from ..views import render_calendar_html 9 | from ..datetools import Calendar 10 | 11 | 12 | class TestReadingsView(TestCase): 13 | fixtures = ['calendarium.json', 'commemorations.json'] 14 | 15 | def test_gregorian_default(self): 16 | now = timezone.localtime() 17 | url = reverse('index') 18 | response = self.client.get(url) 19 | self.assertEqual(response.status_code, 200) 20 | self.assertEqual(response.context['date'], now.date()) 21 | self.assertEqual(response.context['cal'], Calendar.Gregorian) 22 | 23 | def test_julian_default(self): 24 | """Pages should default to Julian after visiting a Julian page.""" 25 | url = reverse('readings', kwargs={ 26 | 'cal': 'julian', 27 | 'year': 2022, 28 | 'month': 1, 29 | 'day': 7, 30 | }) 31 | response = self.client.get(url) 32 | 33 | now = timezone.localtime() 34 | url = reverse('index') 35 | response = self.client.get(url) 36 | self.assertEqual(response.status_code, 200) 37 | self.assertEqual(response.context['date'], now.date()) 38 | self.assertEqual(response.context['cal'], Calendar.Julian) 39 | 40 | def test_gregorian(self): 41 | url = reverse('readings', kwargs={ 42 | 'cal': 'gregorian', 43 | 'year': 2022, 44 | 'month': 1, 45 | 'day': 7, 46 | }) 47 | response = self.client.get(url) 48 | self.assertEqual(response.status_code, 200) 49 | self.assertEqual(response.context['date'].day, 7) 50 | self.assertEqual(response.context['date'].month, 1) 51 | self.assertEqual(response.context['cal'], Calendar.Gregorian) 52 | 53 | def test_gregorian_404(self): 54 | url = reverse('readings', kwargs={ 55 | 'cal': 'gregorian', 56 | 'year': 2022, 57 | 'month': 2, 58 | 'day': 29, 59 | }) 60 | response = self.client.get(url) 61 | self.assertEqual(response.status_code, 404) 62 | 63 | 64 | class TestCalendarView(TestCase): 65 | fixtures = ['calendarium.json', 'commemorations.json'] 66 | 67 | def test_gregorian_default(self): 68 | now = timezone.localtime() 69 | this_month = date(now.year, now.month, 1) 70 | url = reverse('calendar-default') 71 | response = self.client.get(url) 72 | self.assertEqual(response.status_code, 200) 73 | self.assertEqual(response.context['this_month'], this_month) 74 | self.assertEqual(response.context['cal'], Calendar.Gregorian) 75 | self.assertEqual(response.context['day'].pyear.calendar, Calendar.Gregorian) 76 | 77 | def test_julian_default(self): 78 | """Pages should default to Julian after visiting a Julian page.""" 79 | url = reverse('readings', kwargs={ 80 | 'cal': 'julian', 81 | 'year': 2022, 82 | 'month': 1, 83 | 'day': 7, 84 | }) 85 | response = self.client.get(f'{url}?foo') # We send an argument to bypass caching 86 | 87 | now = timezone.localtime() 88 | this_month = date(now.year, now.month, 1) 89 | url = reverse('calendar-default') 90 | response = self.client.get(url) 91 | self.assertEqual(response.status_code, 200) 92 | self.assertEqual(response.context['this_month'], this_month) 93 | self.assertEqual(response.context['cal'], Calendar.Julian) 94 | self.assertEqual(response.context['day'].pyear.calendar, Calendar.Julian) 95 | 96 | async def test_render_calendar_html(self): 97 | now = date(2022, 1, 7) 98 | request = RequestFactory().get('/') 99 | html = await render_calendar_html(request, 2022, 1, cal=Calendar.Gregorian) 100 | self.assertIn(now.strftime('%B'), html) 101 | self.assertIn('Synaxis 3 Hierarchs', html) 102 | 103 | async def test_render_calendar_html_julian(self): 104 | now = date(2022, 1, 7) 105 | request = RequestFactory().get('/') 106 | html = await render_calendar_html(request, 2022, 1, cal=Calendar.Julian) 107 | self.assertIn(now.strftime('%B'), html) 108 | self.assertIn('Nativity of Christ', html) 109 | -------------------------------------------------------------------------------- /alexa/tests/data/launch_intent_envelope.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "session": { 4 | "new": true, 5 | "sessionId": "amzn1.echo-api.session.d5e46ec4-7b75-4ccc-938a-f2d2ed4a7dd6", 6 | "application": { 7 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 8 | }, 9 | "attributes": {}, 10 | "user": { 11 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 12 | } 13 | }, 14 | "context": { 15 | "Viewports": [ 16 | { 17 | "type": "APL", 18 | "id": "main", 19 | "shape": "RECTANGLE", 20 | "dpi": 213, 21 | "presentationType": "STANDARD", 22 | "canRotate": false, 23 | "configuration": { 24 | "current": { 25 | "mode": "HUB", 26 | "video": { 27 | "codecs": [ 28 | "H_264_42", 29 | "H_264_41" 30 | ] 31 | }, 32 | "size": { 33 | "type": "DISCRETE", 34 | "pixelWidth": 1280, 35 | "pixelHeight": 800 36 | } 37 | } 38 | } 39 | } 40 | ], 41 | "Viewport": { 42 | "experiences": [ 43 | { 44 | "arcMinuteWidth": 346, 45 | "arcMinuteHeight": 216, 46 | "canRotate": false, 47 | "canResize": false 48 | } 49 | ], 50 | "mode": "HUB", 51 | "shape": "RECTANGLE", 52 | "pixelWidth": 1280, 53 | "pixelHeight": 800, 54 | "dpi": 213, 55 | "currentPixelWidth": 1280, 56 | "currentPixelHeight": 800, 57 | "touch": [ 58 | "SINGLE" 59 | ], 60 | "video": { 61 | "codecs": [ 62 | "H_264_42", 63 | "H_264_41" 64 | ] 65 | } 66 | }, 67 | "Extensions": { 68 | "available": { 69 | "aplext:backstack:10": {} 70 | } 71 | }, 72 | "System": { 73 | "application": { 74 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 75 | }, 76 | "user": { 77 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 78 | }, 79 | "device": { 80 | "deviceId": "amzn1.ask.device.AHJS3UNHASC2IYCUZLW7B7JFFK47EBNNDD3TY6WV5QKDI4SXVTMH5SYYTFJKBPU4NORLUJOCLRXGFHWL7WAAPCEGYBM6D2HB3RP6CGARAIXDXZVQQ5SLJVAEPIMXZDVGVY2JAAWKIXDI3ENCCQUWQUI6UPDQ", 81 | "supportedInterfaces": {} 82 | }, 83 | "apiEndpoint": "https://api.amazonalexa.com", 84 | "apiAccessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOiJodHRwczovL2FwaS5hbWF6b25hbGV4YS5jb20iLCJpc3MiOiJBbGV4YVNraWxsS2l0Iiwic3ViIjoiYW16bjEuYXNrLnNraWxsLmZhMWE3YWRhLTEwY2MtNGQ3MS1iOGFhLWQ3YzYwNGZmNTJjYSIsImV4cCI6MTY3MzU1OTcwMCwiaWF0IjoxNjczNTU5NDAwLCJuYmYiOjE2NzM1NTk0MDAsInByaXZhdGVDbGFpbXMiOnsiY29udGV4dCI6IkFBQUFBQUFBQUFCZ2NKOGtoZUErSUhMTmJSQ0tRUm5lSXdFQUFBQUFBQURSaU92QUpuRnovSWQzL25OR2ROdGMyWVkyOVVaUkQxZ1B0VDZxem52YllMTmxHbmRpSjRPYkIwTW5PMEQ5RmlER2Z6WTR0d3I0eFowNkFhRTlqWWloWFNQYkZLbmpFNmc1MDhKdStDdzZqbzVIUWNwVS9VRklZTkI5c0MzV2ZCL1VFK3hjN0tRaDNUUVhqMEd4ZjRxRE9ISkFZZlpVbDZmazZhb2pMeFFtb29ldno0SWZ3M0NKcEl4US9ueE1vY1RzTFYyUXZuT0Q2UCtUOHZIZ21xeVdnSlQ3dFVqM0tZSEMwYmlodWI1SkpMbXZCWCtMYlUrK1NvV3RaRS91VXc5bFNoS05GQlFsOHJSL01aNStUYjBzSkhtNjB1dUpRRHFwUlk1M0YyZU1ESEh2OUtTUWFTUDFHZzljWlhuL0N5RU5nL0N2UzZ1NDE2ZjFGK3h2clhGbFlFRmNGU25RdnpNWDJVazJFa1AzQWllb1Nzdi8xMktLV3gxVjZ2MXVEcitXOFJBPSIsImRldmljZUlkIjoiYW16bjEuYXNrLmRldmljZS5BSEpTM1VOSEFTQzJJWUNVWkxXN0I3SkZGSzQ3RUJOTkREM1RZNldWNVFLREk0U1hWVE1INVNZWVRGSktCUFU0Tk9STFVKT0NMUlhHRkhXTDdXQUFQQ0VHWUJNNkQySEIzUlA2Q0dBUkFJWERYWlZRUTVTTEpWQUVQSU1YWkRWR1ZZMkpBQVdLSVhESTNFTkNDUVVXUVVJNlVQRFEiLCJ1c2VySWQiOiJhbXpuMS5hc2suYWNjb3VudC5BRkNBSDJXNlk1WEgySUlCNlZVTjNHTktPR1BHVVBRQko1U0dNSjZSVllONEI3V0VSR1FVM1U1WFpVWU1ZUjVIMjdNSDVWUE1KQlpWRDRUTU1JRExaRVI1NlVFUk5FVjY0TllGVlkzQUg0TUdRREhZRFozSk9JU1I3N1E3UUxEWDI3VldSNEpBUTVVU0lSVEFIRlAzVE5XS1lNUEpLSEhIMzZNVDdJUExDRTNFV1A0R0tSUVBJUEE2R0tEVlJUR0NLVFZXUkxRTEVYTkZKVVEifX0.Z7hVl4z5N8OjZqvJUgcscLenUsfn9e0sq_dCe3GMDMNgt-yGxXFMQylW-EhuXGAqaXzADjNqhBYd52VO_MKAJgvO7KSkIWPx7ygEiuYO5bO9wL1Ok88a1xjMXMiQqYYZIiSgbAjqFsPNajqRyMl61_DsvbgHs0NKhG-BE4oJzsRxkbYI-fbagTm3OBoPiD1H5amhdrfo15Yg4wHXldW2G20I3P_KfXa5yAUG1Kc1QvXoyT32jRaz3AXGJQ-I8O2Ujb3KkoVuywsaGyP5_WL-_5-nQ0XdagmbOmZol0hoNIcsj-e3cx5CxYuk8Iez6jZJuMitAfM2dvhoOBYcYe59Kg" 85 | } 86 | }, 87 | "request": { 88 | "type": "LaunchRequest", 89 | "requestId": "amzn1.echo-api.request.dd77878d-baeb-4ec3-94d1-eb9aa3e58352", 90 | "locale": "en-US", 91 | "timestamp": "2023-01-12T21:36:40Z", 92 | "shouldLinkResultBeReturned": false 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /calendarium/models.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.db import models 4 | from django.utils.functional import cached_property 5 | 6 | from bible.models import Verse 7 | from .datetools import get_day_name 8 | 9 | # pdist is the distance between the given day and Pascha for the current calendar year 10 | # pdist values >= 1000 are for floats and are programmatically mapped 11 | # Rows with pdist == 999 are for days on the fixed calendar (e.g. Menaion) 12 | # Rows with pdist in the 701-711 range are Matins gospels 13 | 14 | 15 | class Day(models.Model): 16 | pdist = models.SmallIntegerField(db_index=True) 17 | month = models.SmallIntegerField() 18 | day = models.SmallIntegerField() 19 | title = models.CharField(max_length=255) 20 | subtitle = models.CharField(max_length=128) 21 | feast_name = models.CharField(max_length=255) 22 | feast_level = models.SmallIntegerField() 23 | service = models.SmallIntegerField() 24 | service_note = models.CharField(max_length=64) 25 | saint = models.CharField(max_length=128) 26 | fast = models.SmallIntegerField() 27 | fast_exception = models.SmallIntegerField() 28 | flag = models.SmallIntegerField() 29 | 30 | def __str__(self): 31 | return self.full_title 32 | 33 | @cached_property 34 | def full_title(self): 35 | return f'{self.title}: {self.subtitle}' if self.subtitle else self.title 36 | 37 | class Meta: 38 | indexes = [models.Index(fields=('month', 'day'))] 39 | 40 | 41 | class Reading(models.Model): 42 | # Ordering field 43 | # 44 | # 1-99 lenten matins 45 | # 100+ 1st hour (lent) 46 | # 200+ 3rd hour 47 | # 300+ 6th hour 48 | # 400+ 9th hour 49 | # 500+ lenten vespers 50 | # 600+ vespers 51 | # 700+ matins 52 | # 800+ liturgy epistles 53 | # 900+ liturgy gospels 54 | # 100+ post-liturgy 55 | 56 | month = models.SmallIntegerField() 57 | day = models.SmallIntegerField() 58 | pdist = models.SmallIntegerField(db_index=True) 59 | source = models.CharField(max_length=64) 60 | desc = models.CharField(max_length=64) 61 | pericope = models.ForeignKey('Pericope', on_delete=models.CASCADE) 62 | ordering = models.SmallIntegerField() 63 | flag = models.SmallIntegerField() 64 | 65 | class Meta: 66 | indexes = [models.Index(fields=('month', 'day'))] 67 | 68 | async def aget_pericope(self): 69 | # Using self.pericope only works synchronously. 70 | return await Pericope.objects.aget(id=self.pericope_id) 71 | 72 | @cached_property 73 | def day_name(self): 74 | return get_day_name(self.pdist) 75 | 76 | 77 | class Pericope(models.Model): 78 | pericope = models.CharField(max_length=8) 79 | book = models.CharField(max_length=16) 80 | display = models.CharField(max_length=128) 81 | sdisplay = models.CharField(max_length=64) 82 | desc = models.CharField(max_length=128) 83 | preverse = models.CharField(max_length=8) 84 | prefix = models.CharField(max_length=255) 85 | prefixb = models.CharField(max_length=128) 86 | verses = models.CharField(max_length=128) 87 | suffix = models.CharField(max_length=255) 88 | flag = models.SmallIntegerField() 89 | 90 | class Meta: 91 | unique_together = 'pericope', 'book' 92 | 93 | def __str__(self): 94 | return self.display 95 | 96 | async def aget_passage(self, language='en'): 97 | try: 98 | return self.passage 99 | except AttributeError: 100 | self.passage = [verse async for verse in self.get_passage(language=language)] 101 | return self.passage 102 | 103 | def get_passage(self, language='en'): 104 | match = re.match(r'Composite (\d+)', self.display) 105 | if match: 106 | return Composite.objects.filter( 107 | composite_num=match.group(1) 108 | ).annotate( 109 | # Make the composite look like a Verse instance. 110 | book=models.Value(''), 111 | chapter=models.Value(1), 112 | verse=models.Value(1), 113 | language=models.Value('en'), 114 | paragraph_start=models.Value(True), 115 | ) 116 | else: 117 | return Verse.objects.lookup_reference(self.sdisplay, language=language) 118 | 119 | 120 | class Composite(models.Model): 121 | composite_num = models.SmallIntegerField(primary_key=True) 122 | content = models.TextField() 123 | -------------------------------------------------------------------------------- /alexa/tests/data/help_intent_envelope.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "session": { 4 | "new": true, 5 | "sessionId": "amzn1.echo-api.session.abebbab0-195a-4c83-83a9-50c370471e6d", 6 | "application": { 7 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 8 | }, 9 | "attributes": {}, 10 | "user": { 11 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 12 | } 13 | }, 14 | "context": { 15 | "Viewports": [ 16 | { 17 | "type": "APL", 18 | "id": "main", 19 | "shape": "RECTANGLE", 20 | "dpi": 213, 21 | "presentationType": "STANDARD", 22 | "canRotate": false, 23 | "configuration": { 24 | "current": { 25 | "mode": "HUB", 26 | "video": { 27 | "codecs": [ 28 | "H_264_42", 29 | "H_264_41" 30 | ] 31 | }, 32 | "size": { 33 | "type": "DISCRETE", 34 | "pixelWidth": 1280, 35 | "pixelHeight": 800 36 | } 37 | } 38 | } 39 | } 40 | ], 41 | "Viewport": { 42 | "experiences": [ 43 | { 44 | "arcMinuteWidth": 346, 45 | "arcMinuteHeight": 216, 46 | "canRotate": false, 47 | "canResize": false 48 | } 49 | ], 50 | "mode": "HUB", 51 | "shape": "RECTANGLE", 52 | "pixelWidth": 1280, 53 | "pixelHeight": 800, 54 | "dpi": 213, 55 | "currentPixelWidth": 1280, 56 | "currentPixelHeight": 800, 57 | "touch": [ 58 | "SINGLE" 59 | ], 60 | "video": { 61 | "codecs": [ 62 | "H_264_42", 63 | "H_264_41" 64 | ] 65 | } 66 | }, 67 | "Extensions": { 68 | "available": { 69 | "aplext:backstack:10": {} 70 | } 71 | }, 72 | "System": { 73 | "application": { 74 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 75 | }, 76 | "user": { 77 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 78 | }, 79 | "device": { 80 | "deviceId": "amzn1.ask.device.AHJS3UNHASC2IYCUZLW7B7JFFK47EBNNDD3TY6WV5QKDI4SXVTMH5SYYTFJKBPU4NORLUJOCLRXGFHWL7WAAPCEGYBM6D2HB3RP6CGARAIXDXZVQQ5SLJVAEPIMXZDVGVY2JAAWKIXDI3ENCCQUWQUI6UPDQ", 81 | "supportedInterfaces": {} 82 | }, 83 | "apiEndpoint": "https://api.amazonalexa.com", 84 | "apiAccessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOiJodHRwczovL2FwaS5hbWF6b25hbGV4YS5jb20iLCJpc3MiOiJBbGV4YVNraWxsS2l0Iiwic3ViIjoiYW16bjEuYXNrLnNraWxsLmZhMWE3YWRhLTEwY2MtNGQ3MS1iOGFhLWQ3YzYwNGZmNTJjYSIsImV4cCI6MTY3MzU2NDI5MSwiaWF0IjoxNjczNTYzOTkxLCJuYmYiOjE2NzM1NjM5OTEsInByaXZhdGVDbGFpbXMiOnsiY29udGV4dCI6IkFBQUFBQUFBQUFCZ2NKOGtoZUErSUhMTmJSQ0tRUm5lSXdFQUFBQUFBQUNDdUh4SndXVHlQMkc4Z0JpajhRc2Jrcjk2Qk95S1I3Y2RabWtjSnVuOUxTSVZwdGxYWHBSR05VT3pVYjJQRGNzaDZCbFJKU2tYTW05RTl3RXNmYWlZcHN5S0xZYldKTWpWekQwSFFqcldzcXB5czRranF1b2RFL01xUHVER1ZNdDJoellRQnlxMmRqWUhZUXVLMnBXNjZ1WnhmVnNBcGtpVEROZVl4OTBtR2YrVWkrdUhWdHp0a3dzbDRFcEs4MGZ0dkdlS2V0Wkloa1drM3E2MmJVRlpLcjRYQ3RSTUp4RHhrRWZrbkt5d3ZwMVlVRlgxV2ZkUVJLQVhCb0ZvZkp0eTBBSXQveDlPVjZXYXA5cHpKL1pDUHRhc0tLc09jSTJVbllkRTVTRXI5TE04OFFENFp6bkk2WXZlVThjU0p4dGZwaktzMkkzZUJlUVJWTjZFUUdsVlgzRWVydVNrN2NvQWxINzZkMFZkWHc1V3F0ZmFoN0k3U0EyWjF2ZFNXOUtZaW5vPSIsImRldmljZUlkIjoiYW16bjEuYXNrLmRldmljZS5BSEpTM1VOSEFTQzJJWUNVWkxXN0I3SkZGSzQ3RUJOTkREM1RZNldWNVFLREk0U1hWVE1INVNZWVRGSktCUFU0Tk9STFVKT0NMUlhHRkhXTDdXQUFQQ0VHWUJNNkQySEIzUlA2Q0dBUkFJWERYWlZRUTVTTEpWQUVQSU1YWkRWR1ZZMkpBQVdLSVhESTNFTkNDUVVXUVVJNlVQRFEiLCJ1c2VySWQiOiJhbXpuMS5hc2suYWNjb3VudC5BRkNBSDJXNlk1WEgySUlCNlZVTjNHTktPR1BHVVBRQko1U0dNSjZSVllONEI3V0VSR1FVM1U1WFpVWU1ZUjVIMjdNSDVWUE1KQlpWRDRUTU1JRExaRVI1NlVFUk5FVjY0TllGVlkzQUg0TUdRREhZRFozSk9JU1I3N1E3UUxEWDI3VldSNEpBUTVVU0lSVEFIRlAzVE5XS1lNUEpLSEhIMzZNVDdJUExDRTNFV1A0R0tSUVBJUEE2R0tEVlJUR0NLVFZXUkxRTEVYTkZKVVEifX0.B9rnzmSG9LxswV4jpEVKT8gsUiB5Z22xaBSGSQRw6K0bZlMbNlNhgKZUlU3zECmgk-68nHcppQ6rwnJvhyUclNDHQ8Bp6ZxIbnZC4xppluE2nE_qz28ewj8DZlfygr7-bJmUeu5vbQq9krt7IxXs2RFASp_J5zfrtMVJEV3_IeNGGNKSEIk9hcrVZ9x7fuBw5WOarXXKM4JOkqJsI0d50PwBusEkPG24SJPXLmFBisFyUclYs74tUr7QtsvthnMJblInVPp2qwy9lxCgMtZYyYS0nQRRqcBm4LvnO7DBZf_TnuKN33xT0dr64XwuOzZcXDBwCP91vq7d2k2QkDF_DA" 85 | } 86 | }, 87 | "request": { 88 | "type": "IntentRequest", 89 | "requestId": "amzn1.echo-api.request.4c22f448-017c-4e34-810a-d174ed269de5", 90 | "locale": "en-US", 91 | "timestamp": "2023-01-12T22:53:11Z", 92 | "intent": { 93 | "name": "AMAZON.HelpIntent", 94 | "confirmationStatus": "NONE" 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /calendarium/templates/readings.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load typogrify_tags %} 3 | {% load fullurl %} 4 | 5 | {% 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 %} 6 | 7 | {% block head %} 8 | {% if request.resolver_match.url_name == "index" %} 9 | 10 | {% else %} 11 | 12 | {% endif %} 13 | 14 | 15 | 16 | {% if noindex %} 17 | 18 | {% endif %} 19 | {% endblock %} 20 | 21 | {% block extra_nav %} 22 | ← Previous Day 23 | Today 24 | Next Day → 25 | 26 |
27 | 28 | 29 | 30 | {% endblock %} 31 | 32 | {% block main %} 33 |
34 |

{{ day.gregorian_date|date|widont }}
{{ day.titles.0|widont }}

35 | 36 |

37 | {% filter widont %} 38 | {{ day.fast_level_desc }} 39 | {% if day.fast_level and day.fast_exception %}— {{ day.fast_exception_desc }}{% endif %} 40 | {% endfilter %} 41 |

42 | 43 | {% if day.service_notes %} 44 |
45 |

Service Notes

46 |
    47 | {% for note in day.service_notes %} 48 |
  • {{ note|widont }}
  • 49 | {% endfor %} 50 |
51 |
52 | {% endif %} 53 | 54 | {% if day.feasts %} 55 |
56 |

Feasts

57 |
    58 | {% for feast in day.feasts %} 59 |
  • {{ feast|widont }}
  • 60 | {% endfor %} 61 |
62 |
63 | {% endif %} 64 | 65 | {% if day.saints %} 66 |
67 |

Commemorations

68 |
    69 | {% for saint in day.saints %} 70 |
  • {{ saint|widont }}
  • 71 | {% endfor %} 72 |
73 |
74 | {% endif %} 75 |
76 | 77 |
78 |

Scripture Readings (KJV)

79 | 80 | {% for reading in day.readings %} 81 |
82 |

83 | {% filter widont %} 84 | {{ reading.pericope.display }} 85 | ({{ reading.source }}{% if reading.desc %}, {{ reading.desc }}{% endif %}) 86 | {% endfilter %} 87 |

88 | 89 |

90 | {% for verse in reading.pericope.passage %} 91 | {% if verse.paragraph_start and not forloop.first %}

{% endif %} 92 | {{ verse.verse }}{{ verse.content }} 93 | {% endfor %} 94 |

95 |
96 | {% endfor %} 97 |
98 | 99 |
100 | {% if day.stories %} 101 |

Commemorations

102 | 103 | {% for reading in day.stories %} 104 |
105 |

{{ reading.title|widont }}

106 | 107 | {{ reading.story|safe }} 108 |
109 | {% endfor %} 110 | {% endif %} 111 |
112 | {% endblock %} 113 | 114 | {% block scripts %} 115 | 132 | {% endblock %} 133 | -------------------------------------------------------------------------------- /alexa/tests/data/no_intent_envelope.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "session": { 4 | "new": false, 5 | "sessionId": "amzn1.echo-api.session.78f8264f-4326-4785-a9c8-00a43d137742", 6 | "application": { 7 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 8 | }, 9 | "attributes": { 10 | "date": "2023-04-14", 11 | "original_intent": "Scriptures", 12 | "next_reading": 3 13 | }, 14 | "user": { 15 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 16 | } 17 | }, 18 | "context": { 19 | "Viewports": [ 20 | { 21 | "type": "APL", 22 | "id": "main", 23 | "shape": "RECTANGLE", 24 | "dpi": 213, 25 | "presentationType": "STANDARD", 26 | "canRotate": false, 27 | "configuration": { 28 | "current": { 29 | "mode": "HUB", 30 | "video": { 31 | "codecs": [ 32 | "H_264_42", 33 | "H_264_41" 34 | ] 35 | }, 36 | "size": { 37 | "type": "DISCRETE", 38 | "pixelWidth": 1280, 39 | "pixelHeight": 800 40 | } 41 | } 42 | } 43 | } 44 | ], 45 | "Viewport": { 46 | "experiences": [ 47 | { 48 | "arcMinuteWidth": 346, 49 | "arcMinuteHeight": 216, 50 | "canRotate": false, 51 | "canResize": false 52 | } 53 | ], 54 | "mode": "HUB", 55 | "shape": "RECTANGLE", 56 | "pixelWidth": 1280, 57 | "pixelHeight": 800, 58 | "dpi": 213, 59 | "currentPixelWidth": 1280, 60 | "currentPixelHeight": 800, 61 | "touch": [ 62 | "SINGLE" 63 | ], 64 | "video": { 65 | "codecs": [ 66 | "H_264_42", 67 | "H_264_41" 68 | ] 69 | } 70 | }, 71 | "Extensions": { 72 | "available": { 73 | "aplext:backstack:10": {} 74 | } 75 | }, 76 | "System": { 77 | "application": { 78 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 79 | }, 80 | "user": { 81 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 82 | }, 83 | "device": { 84 | "deviceId": "amzn1.ask.device.AHJS3UNHASC2IYCUZLW7B7JFFK47EBNNDD3TY6WV5QKDI4SXVTMH5SYYTFJKBPU4NORLUJOCLRXGFHWL7WAAPCEGYBM6D2HB3RP6CGARAIXDXZVQQ5SLJVAEPIMXZDVGVY2JAAWKIXDI3ENCCQUWQUI6UPDQ", 85 | "supportedInterfaces": {} 86 | }, 87 | "apiEndpoint": "https://api.amazonalexa.com", 88 | "apiAccessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOiJodHRwczovL2FwaS5hbWF6b25hbGV4YS5jb20iLCJpc3MiOiJBbGV4YVNraWxsS2l0Iiwic3ViIjoiYW16bjEuYXNrLnNraWxsLmZhMWE3YWRhLTEwY2MtNGQ3MS1iOGFhLWQ3YzYwNGZmNTJjYSIsImV4cCI6MTY3MzU2ODQ1NSwiaWF0IjoxNjczNTY4MTU1LCJuYmYiOjE2NzM1NjgxNTUsInByaXZhdGVDbGFpbXMiOnsiY29udGV4dCI6IkFBQUFBQUFBQUFCZ2NKOGtoZUErSUhMTmJSQ0tRUm5lSXdFQUFBQUFBQUMvemtjaG1tVEp4K3FZQkhZNnRVSU9zRE0wRkdQS2IwMHhmTjI3RkRjYUdlVG1FWjRRSUxkbU9ZU0x5N3hEZHpKQ21lTGlaQ3VFQWp6c1dkUlMzc05zMUJmUG9MOXFvOXdrSmJTWVZzdXJaeTYwZGlEanF2ZmVOVTJVWmYwMTJkbTFtWDJYUWZwNnQ5U1VhU2FjNUVxVGpzVFBodVoreEd6QVg3RFVzVDVPekZQYVZhUTlaSHZDVkRGVGlPN29nZVNpaktVZHRpczlldUxVYllLMUhRN2hNTy9abkUrUDZDVWNOd2w4eW9JZ0IwT3JYWE9qeTFzeno3QmdlSDM3blVaSWw2TXh6WlVHTU1nODVsM29BVmhYUU41SnVDM3pFbFhBVUZoRFMzKy9zbHp2azc5aDhBYkVYWFhQNzA3ZnJQbmN3cEE3TXZyTmZiUm1zVHdXNk5NbzdUNVhtakVVa0VkbHoxdHhENURJcTV0TmxrNndTWURMS1FKVEpLQ2Q5bjhvOWQwPSIsImRldmljZUlkIjoiYW16bjEuYXNrLmRldmljZS5BSEpTM1VOSEFTQzJJWUNVWkxXN0I3SkZGSzQ3RUJOTkREM1RZNldWNVFLREk0U1hWVE1INVNZWVRGSktCUFU0Tk9STFVKT0NMUlhHRkhXTDdXQUFQQ0VHWUJNNkQySEIzUlA2Q0dBUkFJWERYWlZRUTVTTEpWQUVQSU1YWkRWR1ZZMkpBQVdLSVhESTNFTkNDUVVXUVVJNlVQRFEiLCJ1c2VySWQiOiJhbXpuMS5hc2suYWNjb3VudC5BRkNBSDJXNlk1WEgySUlCNlZVTjNHTktPR1BHVVBRQko1U0dNSjZSVllONEI3V0VSR1FVM1U1WFpVWU1ZUjVIMjdNSDVWUE1KQlpWRDRUTU1JRExaRVI1NlVFUk5FVjY0TllGVlkzQUg0TUdRREhZRFozSk9JU1I3N1E3UUxEWDI3VldSNEpBUTVVU0lSVEFIRlAzVE5XS1lNUEpLSEhIMzZNVDdJUExDRTNFV1A0R0tSUVBJUEE2R0tEVlJUR0NLVFZXUkxRTEVYTkZKVVEifX0.S8iZPC6QTqZ70EzrxxMwqYHfYDy3WzNH-Saqr1kVQ_zi1RpBVNlK_Rbhog40H2NPPk7BMid1W98H5euhnbG_KPSzXa58GBTh-Ot1XeqsteGasWfUyAnPYYsb5Vv7YDKjDKd4U-BMlrnKbPAbsWt7Jkq-sTafHz4W3jkAkJzib6U-989lhSjcR-VXlaGzkqprmOnuxIDxobDYnDbbSbeBMFiWS9PuxOSG3OTc-djp3NQn5m8rhFc0EyIHa8wi4kalZEAUaTiynbXDQ-q3i0SwSCOe4nNdv456Gk7XIoW4jxR8ypLo3n3kpRnQLI9DLUFr0ZT6qB6JSKY7CnjwlR0Smg" 89 | } 90 | }, 91 | "request": { 92 | "type": "IntentRequest", 93 | "requestId": "amzn1.echo-api.request.e62c1e18-7f80-46d3-b74c-c5740c43c27e", 94 | "locale": "en-US", 95 | "timestamp": "2023-01-13T00:02:35Z", 96 | "intent": { 97 | "name": "AMAZON.NoIntent", 98 | "confirmationStatus": "NONE" 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /alexa/tests/data/next_intent_envelope_followup.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "session": { 4 | "new": false, 5 | "sessionId": "amzn1.echo-api.session.61b21908-bb5e-4b71-80b3-f02f07aefbac", 6 | "application": { 7 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 8 | }, 9 | "attributes": { 10 | "date": "2023-04-14", 11 | "next_reading": 1, 12 | "task_queue": [], 13 | "current_task": "scriptures" 14 | }, 15 | "user": { 16 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 17 | } 18 | }, 19 | "context": { 20 | "Viewports": [ 21 | { 22 | "type": "APL", 23 | "id": "main", 24 | "shape": "RECTANGLE", 25 | "dpi": 213, 26 | "presentationType": "STANDARD", 27 | "canRotate": false, 28 | "configuration": { 29 | "current": { 30 | "mode": "HUB", 31 | "video": { 32 | "codecs": [ 33 | "H_264_42", 34 | "H_264_41" 35 | ] 36 | }, 37 | "size": { 38 | "type": "DISCRETE", 39 | "pixelWidth": 1280, 40 | "pixelHeight": 800 41 | } 42 | } 43 | } 44 | } 45 | ], 46 | "Viewport": { 47 | "experiences": [ 48 | { 49 | "arcMinuteWidth": 346, 50 | "arcMinuteHeight": 216, 51 | "canRotate": false, 52 | "canResize": false 53 | } 54 | ], 55 | "mode": "HUB", 56 | "shape": "RECTANGLE", 57 | "pixelWidth": 1280, 58 | "pixelHeight": 800, 59 | "dpi": 213, 60 | "currentPixelWidth": 1280, 61 | "currentPixelHeight": 800, 62 | "touch": [ 63 | "SINGLE" 64 | ], 65 | "video": { 66 | "codecs": [ 67 | "H_264_42", 68 | "H_264_41" 69 | ] 70 | } 71 | }, 72 | "Extensions": { 73 | "available": { 74 | "aplext:backstack:10": {} 75 | } 76 | }, 77 | "System": { 78 | "application": { 79 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 80 | }, 81 | "user": { 82 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 83 | }, 84 | "device": { 85 | "deviceId": "amzn1.ask.device.AHJS3UNHASC2IYCUZLW7B7JFFK47EBNNDD3TY6WV5QKDI4SXVTMH5SYYTFJKBPU4NORLUJOCLRXGFHWL7WAAPCEGYBM6D2HB3RP6CGARAIXDXZVQQ5SLJVAEPIMXZDVGVY2JAAWKIXDI3ENCCQUWQUI6UPDQ", 86 | "supportedInterfaces": {} 87 | }, 88 | "apiEndpoint": "https://api.amazonalexa.com", 89 | "apiAccessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOiJodHRwczovL2FwaS5hbWF6b25hbGV4YS5jb20iLCJpc3MiOiJBbGV4YVNraWxsS2l0Iiwic3ViIjoiYW16bjEuYXNrLnNraWxsLmZhMWE3YWRhLTEwY2MtNGQ3MS1iOGFhLWQ3YzYwNGZmNTJjYSIsImV4cCI6MTY3NjA0MzYyOSwiaWF0IjoxNjc2MDQzMzI5LCJuYmYiOjE2NzYwNDMzMjksInByaXZhdGVDbGFpbXMiOnsiY29udGV4dCI6IkFBQUFBQUFBQUFEdU56U25hR0xzbFZUdUZzTVBmZSsrSXdFQUFBQUFBQURQMXlFRENMNWl3VklPMnZ6ekxqdzdDL3F3b1BSNDVSMlBGVkwraFNKdER5TVRsdFNIUzNzZEQraGwzM1M0S1lmRmZ5V1Q1Q05EOE1yRkZKWWNOZ0hjT2s5emsvT0ppSGJweUlVZ1dBNWhDYkVpNkgrOUhXUUtIVmxpYko2ZzRaVi9YZEpQb0FwSnJlQi9lTTc1T05teWxla1RIVGp4Z09BWjZrbzVpRlVHc2lPNG5PQUdleWpmMUVIWlVZYXZ2ZkhkTHMrUnVieTltZ0h5Zk8rbW9iNUV6UnB4MW5qSU1kclQ0Q01NZXZOVnpsUjMwRGEwNzg4T1htR2UwdDVIMExzUHlNU0pIWkVVazdsOUxFdUxiQUZZQmdhZzN0cmtqeFFBMWhzTm5YNCtOUVc3VFlmMER1UGZFR0cwZ0Rvejc4amgxbkVVWWtRR3ZVekIyeSs5blloZUl0YlJ5VC9LSW11ZHViU1lDRHppcWVobGtPWEFSbGN1R3hIZml1QVNoOGM5T0NNPSIsImRldmljZUlkIjoiYW16bjEuYXNrLmRldmljZS5BSEpTM1VOSEFTQzJJWUNVWkxXN0I3SkZGSzQ3RUJOTkREM1RZNldWNVFLREk0U1hWVE1INVNZWVRGSktCUFU0Tk9STFVKT0NMUlhHRkhXTDdXQUFQQ0VHWUJNNkQySEIzUlA2Q0dBUkFJWERYWlZRUTVTTEpWQUVQSU1YWkRWR1ZZMkpBQVdLSVhESTNFTkNDUVVXUVVJNlVQRFEiLCJ1c2VySWQiOiJhbXpuMS5hc2suYWNjb3VudC5BRkNBSDJXNlk1WEgySUlCNlZVTjNHTktPR1BHVVBRQko1U0dNSjZSVllONEI3V0VSR1FVM1U1WFpVWU1ZUjVIMjdNSDVWUE1KQlpWRDRUTU1JRExaRVI1NlVFUk5FVjY0TllGVlkzQUg0TUdRREhZRFozSk9JU1I3N1E3UUxEWDI3VldSNEpBUTVVU0lSVEFIRlAzVE5XS1lNUEpLSEhIMzZNVDdJUExDRTNFV1A0R0tSUVBJUEE2R0tEVlJUR0NLVFZXUkxRTEVYTkZKVVEifX0.fHtYX0zVSmMDMl26QaNMPiOMIpVwD4ggSs2AdPy__qC2QEGdLDbPQn0Qw6W_zWVhhrjzIj32zGcZf83TwNYr2tw0hy43924Ruc2HN1_PlFPnAowvCwIwjIwcUgcZUJnib2xXTbIYyN4JhYFlP0WdVCo4aQYjWWOg-KgIkBeDJ4TrEfpJ8XWGwmBmb6mm4rim_s2KHliDdS7XRxBLyxht_Z8dWA0xI5RMwtE2SnBHXkCcDOhpSP8by3XND210gnzc171Jk6wn4ssJTZFCT54Gyy9lXebvptOr8pDN2vZoUNZCuZDCS3bqJT4pJchwtzzqs3pPMwu2Vnjm5g6ucmFf0w" 90 | } 91 | }, 92 | "request": { 93 | "type": "IntentRequest", 94 | "requestId": "amzn1.echo-api.request.0ff03f46-24dc-4d4d-b868-451e6c198e8f", 95 | "locale": "en-US", 96 | "timestamp": "2023-02-10T15:35:29Z", 97 | "intent": { 98 | "name": "AMAZON.YesIntent", 99 | "confirmationStatus": "NONE" 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /alexa/tests/data/next_intent_envelope.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "session": { 4 | "new": false, 5 | "sessionId": "amzn1.echo-api.session.6faf9aaf-0a94-477d-87db-c2130f71d11d", 6 | "application": { 7 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 8 | }, 9 | "attributes": { 10 | "date": "2023-01-12", 11 | "task_queue": ["scriptures", "commemorations"], 12 | "current_task": null, 13 | "next_reading": 0 14 | }, 15 | "user": { 16 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 17 | } 18 | }, 19 | "context": { 20 | "Viewports": [ 21 | { 22 | "type": "APL", 23 | "id": "main", 24 | "shape": "RECTANGLE", 25 | "dpi": 213, 26 | "presentationType": "STANDARD", 27 | "canRotate": false, 28 | "configuration": { 29 | "current": { 30 | "mode": "HUB", 31 | "video": { 32 | "codecs": [ 33 | "H_264_42", 34 | "H_264_41" 35 | ] 36 | }, 37 | "size": { 38 | "type": "DISCRETE", 39 | "pixelWidth": 1280, 40 | "pixelHeight": 800 41 | } 42 | } 43 | } 44 | } 45 | ], 46 | "Viewport": { 47 | "experiences": [ 48 | { 49 | "arcMinuteWidth": 346, 50 | "arcMinuteHeight": 216, 51 | "canRotate": false, 52 | "canResize": false 53 | } 54 | ], 55 | "mode": "HUB", 56 | "shape": "RECTANGLE", 57 | "pixelWidth": 1280, 58 | "pixelHeight": 800, 59 | "dpi": 213, 60 | "currentPixelWidth": 1280, 61 | "currentPixelHeight": 800, 62 | "touch": [ 63 | "SINGLE" 64 | ], 65 | "video": { 66 | "codecs": [ 67 | "H_264_42", 68 | "H_264_41" 69 | ] 70 | } 71 | }, 72 | "Extensions": { 73 | "available": { 74 | "aplext:backstack:10": {} 75 | } 76 | }, 77 | "System": { 78 | "application": { 79 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 80 | }, 81 | "user": { 82 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 83 | }, 84 | "device": { 85 | "deviceId": "amzn1.ask.device.AHJS3UNHASC2IYCUZLW7B7JFFK47EBNNDD3TY6WV5QKDI4SXVTMH5SYYTFJKBPU4NORLUJOCLRXGFHWL7WAAPCEGYBM6D2HB3RP6CGARAIXDXZVQQ5SLJVAEPIMXZDVGVY2JAAWKIXDI3ENCCQUWQUI6UPDQ", 86 | "supportedInterfaces": {} 87 | }, 88 | "apiEndpoint": "https://api.amazonalexa.com", 89 | "apiAccessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOiJodHRwczovL2FwaS5hbWF6b25hbGV4YS5jb20iLCJpc3MiOiJBbGV4YVNraWxsS2l0Iiwic3ViIjoiYW16bjEuYXNrLnNraWxsLmZhMWE3YWRhLTEwY2MtNGQ3MS1iOGFhLWQ3YzYwNGZmNTJjYSIsImV4cCI6MTY3MzU2MDU5MywiaWF0IjoxNjczNTYwMjkzLCJuYmYiOjE2NzM1NjAyOTMsInByaXZhdGVDbGFpbXMiOnsiY29udGV4dCI6IkFBQUFBQUFBQUFCZ2NKOGtoZUErSUhMTmJSQ0tRUm5lSXdFQUFBQUFBQUF4R0dNc3lsMmt3aVlTQzd5NnhHUndKMzVVdHlQMFhBUElwZ05lVHJUZFJPa2hHanFxSEFDVmE2S0tCTGI5cHZNYk52aHBoVThsc0RNKzVkMGg5bGFIOWRJRUwzZ0l1UFg5dm5BYzh5UWhUWVJZdDlRNzVaYlBCaWNLdVNQbFlVamFiQzh3cnA1V3I5TWJJdEZIYWFRS0dXeUtadEhMOUYxaitjRDEwSVlaZHc3OFpHc2pBeUJwSTlQelpYcVRnWDVDbS9IL0dwUjd3OE9qczl5c1NqZExCV3BvYkVhRWNGNFpxSmJ3eVFKbXFJRkNhTVAzc2p0c0REUXk2SDlPUFplUi9lWko1WmFDZTdvQXQ2b1ErVmRtRHlrUUFFVzcrWVErQXZWNkRhSFVBVWc5U2JpaDFNNkNCSERQMENSUFZVSVVQV0NVSzY1SkRxbmdjeFV1SE9XNDRNdmFVYUxNS3JZTE41LzN1cjRaZ3ZEQ3gzNHg3dkZzS04wZUlQR01TbHZWVmswPSIsImRldmljZUlkIjoiYW16bjEuYXNrLmRldmljZS5BSEpTM1VOSEFTQzJJWUNVWkxXN0I3SkZGSzQ3RUJOTkREM1RZNldWNVFLREk0U1hWVE1INVNZWVRGSktCUFU0Tk9STFVKT0NMUlhHRkhXTDdXQUFQQ0VHWUJNNkQySEIzUlA2Q0dBUkFJWERYWlZRUTVTTEpWQUVQSU1YWkRWR1ZZMkpBQVdLSVhESTNFTkNDUVVXUVVJNlVQRFEiLCJ1c2VySWQiOiJhbXpuMS5hc2suYWNjb3VudC5BRkNBSDJXNlk1WEgySUlCNlZVTjNHTktPR1BHVVBRQko1U0dNSjZSVllONEI3V0VSR1FVM1U1WFpVWU1ZUjVIMjdNSDVWUE1KQlpWRDRUTU1JRExaRVI1NlVFUk5FVjY0TllGVlkzQUg0TUdRREhZRFozSk9JU1I3N1E3UUxEWDI3VldSNEpBUTVVU0lSVEFIRlAzVE5XS1lNUEpLSEhIMzZNVDdJUExDRTNFV1A0R0tSUVBJUEE2R0tEVlJUR0NLVFZXUkxRTEVYTkZKVVEifX0.hO5NvgeMZ2nGNh10KL9Z7W2JLJn05fvrCNl6r3ZEX548oTpHlclQRcGV_9WWOgcUR0cmN5_2RMC-USSGO2zF2-QDbzwFOEZvs3xXGEM7cRoc-zI_7Dqxxam4uANof6ft1Y8ZAT6lIGozLd3v36iTsIOEug9wv1xY5SIcATLQ21J8RVS64h5Ze29hJH6kE7hlJuiTz6p1I88qqmtcXGDJX71RogkCobW0nglAwnOwdWl-pKmcYZNrZGTSxArowfmjHL_5lmvbpqmlnautVhVcmkOgZ6BODbcdYk_rCE66GepNjsKhTfEJA6D4IyahXb03RAqQv4tkh1JqkANkJ0qf7g" 90 | } 91 | }, 92 | "request": { 93 | "type": "IntentRequest", 94 | "requestId": "amzn1.echo-api.request.e57892b4-e507-4fc5-ba55-00ac8f2b072e", 95 | "locale": "en-US", 96 | "timestamp": "2023-01-12T21:51:33Z", 97 | "intent": { 98 | "name": "AMAZON.YesIntent", 99 | "confirmationStatus": "NONE" 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /alexa/tests/data/next_intent_commemorations_envelope.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "session": { 4 | "new": false, 5 | "sessionId": "amzn1.echo-api.session.347201dc-7a62-4290-834e-8f35771a2858", 6 | "application": { 7 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 8 | }, 9 | "attributes": { 10 | "date": "2023-01-25", 11 | "next_reading": 7, 12 | "task_queue": [ 13 | "commemorations" 14 | ], 15 | "current_task": null 16 | }, 17 | "user": { 18 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 19 | } 20 | }, 21 | "context": { 22 | "Viewports": [ 23 | { 24 | "type": "APL", 25 | "id": "main", 26 | "shape": "RECTANGLE", 27 | "dpi": 213, 28 | "presentationType": "STANDARD", 29 | "canRotate": false, 30 | "configuration": { 31 | "current": { 32 | "mode": "HUB", 33 | "video": { 34 | "codecs": [ 35 | "H_264_42", 36 | "H_264_41" 37 | ] 38 | }, 39 | "size": { 40 | "type": "DISCRETE", 41 | "pixelWidth": 1280, 42 | "pixelHeight": 800 43 | } 44 | } 45 | } 46 | } 47 | ], 48 | "Viewport": { 49 | "experiences": [ 50 | { 51 | "arcMinuteWidth": 346, 52 | "arcMinuteHeight": 216, 53 | "canRotate": false, 54 | "canResize": false 55 | } 56 | ], 57 | "mode": "HUB", 58 | "shape": "RECTANGLE", 59 | "pixelWidth": 1280, 60 | "pixelHeight": 800, 61 | "dpi": 213, 62 | "currentPixelWidth": 1280, 63 | "currentPixelHeight": 800, 64 | "touch": [ 65 | "SINGLE" 66 | ], 67 | "video": { 68 | "codecs": [ 69 | "H_264_42", 70 | "H_264_41" 71 | ] 72 | } 73 | }, 74 | "Extensions": { 75 | "available": { 76 | "aplext:backstack:10": {} 77 | } 78 | }, 79 | "System": { 80 | "application": { 81 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 82 | }, 83 | "user": { 84 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 85 | }, 86 | "device": { 87 | "deviceId": "amzn1.ask.device.AHJS3UNHASC2IYCUZLW7B7JFFK47EBNNDD3TY6WV5QKDI4SXVTMH5SYYTFJKBPU4NORLUJOCLRXGFHWL7WAAPCEGYBM6D2HB3RP6CGARAIXDXZVQQ5SLJVAEPIMXZDVGVY2JAAWKIXDI3ENCCQUWQUI6UPDQ", 88 | "supportedInterfaces": {} 89 | }, 90 | "apiEndpoint": "https://api.amazonalexa.com", 91 | "apiAccessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOiJodHRwczovL2FwaS5hbWF6b25hbGV4YS5jb20iLCJpc3MiOiJBbGV4YVNraWxsS2l0Iiwic3ViIjoiYW16bjEuYXNrLnNraWxsLmZhMWE3YWRhLTEwY2MtNGQ3MS1iOGFhLWQ3YzYwNGZmNTJjYSIsImV4cCI6MTY3NDY5Nzk4NCwiaWF0IjoxNjc0Njk3Njg0LCJuYmYiOjE2NzQ2OTc2ODQsInByaXZhdGVDbGFpbXMiOnsiY29udGV4dCI6IkFBQUFBQUFBQUFBams0bW1zWnY3VHQ3RW9sd3RrSlBuSXdFQUFBQUFBQUI3MU1aQWI0SXFMNXgyNHFGOFNGR3ppLzR1OTJCNkxPdCt3c3k3RDYra1V2RU1tK0tVUktMS0QzSGhqWFMvc3VxWEdEeklsMkhvZmtNM3dGSUMzYXJ4WlFwVHRXMGRwT2d1VWVsS3FmcUI1bCtoSzVaZmwzNi9aaUlQdU0vZVFmS0VyWG5sbHYxemV5b3hFU0ZRMHhscUVNaE5wUEE3TUg5QnVBeUlzdlZ1c01wb1VZQjJ4dy8zQkpoNm9JcVNKZ21icEQ4U0VVcXYxQjRJbW1FNS9Ic2c0akxxVXVZMEtaeHhyWnJjVXB4SDRqRy9vcW93WFhRWWl4V2d1a2F4bk54dlVMQUJyeloyVDlBbEkrWTN6aDNFYTR2eDdLR2JybU80cHpwaXdpazBsZEtJaWlEMzNaRW42a1hqUmxYNzZyYy9VTDV4MktyQnYrU3A2Q2pyc2ZjeHRiOE9YUzFEa0FZajN5Z3IrWXBCMlA4YUhEUFdzK1pXYkpFdEhPUCtQcnpNaXVJPSIsImRldmljZUlkIjoiYW16bjEuYXNrLmRldmljZS5BSEpTM1VOSEFTQzJJWUNVWkxXN0I3SkZGSzQ3RUJOTkREM1RZNldWNVFLREk0U1hWVE1INVNZWVRGSktCUFU0Tk9STFVKT0NMUlhHRkhXTDdXQUFQQ0VHWUJNNkQySEIzUlA2Q0dBUkFJWERYWlZRUTVTTEpWQUVQSU1YWkRWR1ZZMkpBQVdLSVhESTNFTkNDUVVXUVVJNlVQRFEiLCJ1c2VySWQiOiJhbXpuMS5hc2suYWNjb3VudC5BRkNBSDJXNlk1WEgySUlCNlZVTjNHTktPR1BHVVBRQko1U0dNSjZSVllONEI3V0VSR1FVM1U1WFpVWU1ZUjVIMjdNSDVWUE1KQlpWRDRUTU1JRExaRVI1NlVFUk5FVjY0TllGVlkzQUg0TUdRREhZRFozSk9JU1I3N1E3UUxEWDI3VldSNEpBUTVVU0lSVEFIRlAzVE5XS1lNUEpLSEhIMzZNVDdJUExDRTNFV1A0R0tSUVBJUEE2R0tEVlJUR0NLVFZXUkxRTEVYTkZKVVEifX0.Vrl3_lh8LLpxDoNdXp1lx0moea0WLO5p-uzEkEgTgS3flxV94f2Hi_zjQTDzZVHlKYIDR7XquWOs8QYlS9wHqRGLh5wzFL--cjMfUMnrn-xEnesw7xs0DHv0hhcSi7l66ouHO_IvwYi2a7OBlTjZYvxc5LdTZ86nZaapMGkLYx8-y_dfpTQG3FJ5kaZFtW3G54NDtfCX1v4EBwGMW8a810NblykF5IWclmPbvsd9nUu44tkWyCnZcR-yjYADN3oZDANOGRpsR3uSbjFBx4RTbBewKw6FHP5gQAFYfPYRMcV43tuyfeqaSCs3z45d7TeAlE4H05bn6_YxtepkFwbrMA" 92 | } 93 | }, 94 | "request": { 95 | "type": "IntentRequest", 96 | "requestId": "amzn1.echo-api.request.2e4c5f81-30c2-4f8f-bedc-ba24112b7f4f", 97 | "locale": "en-US", 98 | "timestamp": "2023-01-26T01:48:04Z", 99 | "intent": { 100 | "name": "AMAZON.YesIntent", 101 | "confirmationStatus": "NONE" 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /alexa/tests/data/next_intent_commemorations_multiple_envelope.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "session": { 4 | "new": false, 5 | "sessionId": "amzn1.echo-api.session.0c0ec40c-f003-4087-9700-977f4c4f48d2", 6 | "application": { 7 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 8 | }, 9 | "attributes": { 10 | "date": "2023-01-26", 11 | "next_reading": 1, 12 | "task_queue": [ 13 | "commemorations" 14 | ], 15 | "current_task": null 16 | }, 17 | "user": { 18 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 19 | } 20 | }, 21 | "context": { 22 | "Viewports": [ 23 | { 24 | "type": "APL", 25 | "id": "main", 26 | "shape": "RECTANGLE", 27 | "dpi": 213, 28 | "presentationType": "STANDARD", 29 | "canRotate": false, 30 | "configuration": { 31 | "current": { 32 | "mode": "HUB", 33 | "video": { 34 | "codecs": [ 35 | "H_264_42", 36 | "H_264_41" 37 | ] 38 | }, 39 | "size": { 40 | "type": "DISCRETE", 41 | "pixelWidth": 1280, 42 | "pixelHeight": 800 43 | } 44 | } 45 | } 46 | } 47 | ], 48 | "Viewport": { 49 | "experiences": [ 50 | { 51 | "arcMinuteWidth": 346, 52 | "arcMinuteHeight": 216, 53 | "canRotate": false, 54 | "canResize": false 55 | } 56 | ], 57 | "mode": "HUB", 58 | "shape": "RECTANGLE", 59 | "pixelWidth": 1280, 60 | "pixelHeight": 800, 61 | "dpi": 213, 62 | "currentPixelWidth": 1280, 63 | "currentPixelHeight": 800, 64 | "touch": [ 65 | "SINGLE" 66 | ], 67 | "video": { 68 | "codecs": [ 69 | "H_264_42", 70 | "H_264_41" 71 | ] 72 | } 73 | }, 74 | "Extensions": { 75 | "available": { 76 | "aplext:backstack:10": {} 77 | } 78 | }, 79 | "System": { 80 | "application": { 81 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 82 | }, 83 | "user": { 84 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 85 | }, 86 | "device": { 87 | "deviceId": "amzn1.ask.device.AHJS3UNHASC2IYCUZLW7B7JFFK47EBNNDD3TY6WV5QKDI4SXVTMH5SYYTFJKBPU4NORLUJOCLRXGFHWL7WAAPCEGYBM6D2HB3RP6CGARAIXDXZVQQ5SLJVAEPIMXZDVGVY2JAAWKIXDI3ENCCQUWQUI6UPDQ", 88 | "supportedInterfaces": {} 89 | }, 90 | "apiEndpoint": "https://api.amazonalexa.com", 91 | "apiAccessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOiJodHRwczovL2FwaS5hbWF6b25hbGV4YS5jb20iLCJpc3MiOiJBbGV4YVNraWxsS2l0Iiwic3ViIjoiYW16bjEuYXNrLnNraWxsLmZhMWE3YWRhLTEwY2MtNGQ3MS1iOGFhLWQ3YzYwNGZmNTJjYSIsImV4cCI6MTY3NDc3Mzc2OSwiaWF0IjoxNjc0NzczNDY5LCJuYmYiOjE2NzQ3NzM0NjksInByaXZhdGVDbGFpbXMiOnsiY29udGV4dCI6IkFBQUFBQUFBQUFBams0bW1zWnY3VHQ3RW9sd3RrSlBuSXdFQUFBQUFBQUMweHUwYmZEdmx0c0w3MXhIL2M1dTlqVnY1KzI4Q250TE5Od05jN2JRbHpmaXRaK05kR0ZVc1hwc3MwcTRoTWtDdGE5NUgyNDNHdTh3c3JQWUNvcjdjYXVDaUEyRUtpRXo2L0VyNUdCMHAvbUpFaXZBeW42R0xRMmlvSENCeDEwODJuWUJMeGJ6aHp0QXQ2TmF6dlBmTHlxeGJMSlFwVDdhY2cwa1JMOTk0UXFUd0ZUMTI2b3ZsOXBMaWNXMDVwWlI2UEdrT2lFcm5tZjhTMmVxS2Izby9xbFBnR0piUWZKaGJvUmxFWlRPdG1yTHd1enB5M2hJRlh3THU4NHhqbWRnNVJUNVU1VHZHcHR1SnZjZ2ZHd1E0a0MyOHJiZFc2NzNDYmRsUk9lL3E1U3BzUTNjSlNSYzhJZk5VMWNhV2lSbThXSVcwc0Rwc2R5aUZqeXRsUlg0cElob1N0NllHOEo4TlZXNlBsTkF4bTJrRU9zWlZqb1AvTVBsc2VINmwxUjYvNzQ0PSIsImRldmljZUlkIjoiYW16bjEuYXNrLmRldmljZS5BSEpTM1VOSEFTQzJJWUNVWkxXN0I3SkZGSzQ3RUJOTkREM1RZNldWNVFLREk0U1hWVE1INVNZWVRGSktCUFU0Tk9STFVKT0NMUlhHRkhXTDdXQUFQQ0VHWUJNNkQySEIzUlA2Q0dBUkFJWERYWlZRUTVTTEpWQUVQSU1YWkRWR1ZZMkpBQVdLSVhESTNFTkNDUVVXUVVJNlVQRFEiLCJ1c2VySWQiOiJhbXpuMS5hc2suYWNjb3VudC5BRkNBSDJXNlk1WEgySUlCNlZVTjNHTktPR1BHVVBRQko1U0dNSjZSVllONEI3V0VSR1FVM1U1WFpVWU1ZUjVIMjdNSDVWUE1KQlpWRDRUTU1JRExaRVI1NlVFUk5FVjY0TllGVlkzQUg0TUdRREhZRFozSk9JU1I3N1E3UUxEWDI3VldSNEpBUTVVU0lSVEFIRlAzVE5XS1lNUEpLSEhIMzZNVDdJUExDRTNFV1A0R0tSUVBJUEE2R0tEVlJUR0NLVFZXUkxRTEVYTkZKVVEifX0.bLO02owHQPsxKLuoJcg1vqAJAvlJpqhAPCuMJrnJ-pur-wPeJm9v9AxOS-NxMyAwXSmVdVoc2_7AuYnugtn0zCFTC9ynJNUPwQw0EQc4S-ciiPSN_RO9K4x2u4GLk4AVProNjTL8MP9BOX8pfDZQIMkZJQvHGw0J5JUCRC2acWLesH2TJ93HOYG3WdvPQiqa87HwrwlSFW16g46JLEji2iw1Noz4KVOx2BoiFeTAMmqWol0UyDdLn93sYHcDKmLhHcopAK88tYawTDFlrNJA2w6gThZnaZMukBXv95iiTAAmi9ccsK1GsEw_05tMLb1mTXwbnvGvzpkRdN1v-5aBQw" 92 | } 93 | }, 94 | "request": { 95 | "type": "IntentRequest", 96 | "requestId": "amzn1.echo-api.request.f4cdc6da-4329-4894-8b30-0705800f5fb7", 97 | "locale": "en-US", 98 | "timestamp": "2023-01-26T22:51:09Z", 99 | "intent": { 100 | "name": "AMAZON.YesIntent", 101 | "confirmationStatus": "NONE" 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /calendarium/views.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import functools 3 | import logging 4 | 5 | from datetime import date, timedelta 6 | 7 | from dateutil.relativedelta import relativedelta 8 | from django.conf import settings 9 | from django.http import Http404, HttpResponse 10 | from django.shortcuts import render 11 | from django.template.loader import render_to_string 12 | from django.urls import reverse 13 | from django.utils import timezone 14 | 15 | from . import liturgics, models 16 | from .datetools import Calendar 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | async def readings_view(request, cal=None, year=None, month=None, day=None): 21 | cal = remember_cal(request, cal) 22 | now = timezone.localtime().date() 23 | 24 | if year and month and day: 25 | try: 26 | day = liturgics.Day(year, month, day, calendar=cal, language=request.LANGUAGE_CODE) 27 | except ValueError: 28 | raise Http404 29 | else: 30 | day = liturgics.Day(now.year, now.month, now.day, calendar=cal, language=request.LANGUAGE_CODE) 31 | 32 | await day.ainitialize() 33 | await day.aget_readings(fetch_content=True) 34 | 35 | next_date = day.gregorian_date + timedelta(days=1) 36 | previous_date = day.gregorian_date - timedelta(days=1) 37 | 38 | return render(request, 'readings.html', context={ 39 | 'day': day, 40 | 'date': day.gregorian_date, 41 | 'noindex': not is_indexable(day.gregorian_date), 42 | 'next_date': next_date, 43 | 'next_nofollow': not is_indexable(next_date), 44 | 'previous_date': previous_date, 45 | 'previous_nofollow': not is_indexable(previous_date), 46 | 'cal': cal, 47 | }) 48 | 49 | async def calendar_view(request, cal=None, year=None, month=None): 50 | cal = remember_cal(request, cal) 51 | now = timezone.localtime().date() 52 | 53 | if not year or not month: 54 | year, month = now.year, now.month 55 | 56 | first_day = date(year, month, 1) 57 | 58 | content = await render_calendar_html(request, year, month, cal=cal) 59 | 60 | previous_month = first_day - relativedelta(months=1) 61 | next_month = first_day + relativedelta(months=1) 62 | 63 | return render(request, 'calendar.html', context={ 64 | 'content': content, 65 | 'cal': cal, 66 | 'noindex': not is_indexable(first_day), 67 | 'this_month': first_day, 68 | 'previous_month': previous_month, 69 | 'previous_nofollow': not is_indexable(previous_month), 70 | 'next_month': next_month, 71 | 'next_nofollow': not is_indexable(next_month), 72 | }) 73 | 74 | async def calendar_embed_view(request, cal=Calendar.Gregorian, year=None, month=None): 75 | if not year or not month: 76 | now = timezone.localtime() 77 | year, month = now.year, now.month 78 | 79 | first_day = date(year, month, 1) 80 | 81 | content = await render_calendar_html(request, year, month, cal=cal) 82 | 83 | return render(request, 'calendar_embed.html', context={ 84 | 'content': content, 85 | 'cal': cal, 86 | 'this_month': first_day, 87 | 'previous_month': first_day - relativedelta(months=1), 88 | 'next_month': first_day + relativedelta(months=1), 89 | }) 90 | 91 | async def render_calendar_html(request, year, month, cal=Calendar.Gregorian, full_urls=False): 92 | class LiturgicalCalendar(calendar.HTMLCalendar): 93 | def formatday(self, day, weekday): 94 | if not day: 95 | return super().formatday(day, weekday) 96 | 97 | return render_to_string('calendar_day.html', request=request, context={ 98 | 'cal': cal, 99 | 'day_number': day, 100 | 'day': days[day-1], # days is 0-origin and day is 1-origin 101 | 'cell_class': self.cssclasses[weekday], 102 | 'full_urls': full_urls, 103 | }) 104 | 105 | days = [ 106 | d async for d in 107 | liturgics.amonth_of_days(year, month, calendar=cal) 108 | ] 109 | 110 | lcal = LiturgicalCalendar(firstweekday=6) 111 | content = lcal.formatmonth(year, month) 112 | 113 | return content 114 | 115 | # Helper functions 116 | 117 | def remember_cal(request, cal): 118 | if cal: 119 | if cal != request.session.get('cal', Calendar.Gregorian): 120 | request.session['cal'] = cal 121 | 122 | # Don't send vary on cookie header when we have an explicit cal. 123 | # In this case, the session does not actually impact the content. 124 | request.session.accessed = False 125 | else: 126 | cal = request.session.get('cal', Calendar.Gregorian) 127 | 128 | return cal 129 | 130 | def is_indexable(dt): 131 | now = timezone.localtime().date() 132 | return abs(dt - now) <= timedelta(days=5*365) 133 | -------------------------------------------------------------------------------- /alexa/tests/data/meeting_envelope.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "session": { 4 | "new": true, 5 | "sessionId": "amzn1.echo-api.session.fc45c40e-60d8-4657-8081-5b07f8b40f47", 6 | "application": { 7 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 8 | }, 9 | "attributes": {}, 10 | "user": { 11 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 12 | } 13 | }, 14 | "context": { 15 | "Viewports": [ 16 | { 17 | "type": "APL", 18 | "id": "main", 19 | "shape": "RECTANGLE", 20 | "dpi": 213, 21 | "presentationType": "STANDARD", 22 | "canRotate": false, 23 | "configuration": { 24 | "current": { 25 | "mode": "HUB", 26 | "video": { 27 | "codecs": [ 28 | "H_264_42", 29 | "H_264_41" 30 | ] 31 | }, 32 | "size": { 33 | "type": "DISCRETE", 34 | "pixelWidth": 1280, 35 | "pixelHeight": 800 36 | } 37 | } 38 | } 39 | } 40 | ], 41 | "Viewport": { 42 | "experiences": [ 43 | { 44 | "arcMinuteWidth": 346, 45 | "arcMinuteHeight": 216, 46 | "canRotate": false, 47 | "canResize": false 48 | } 49 | ], 50 | "mode": "HUB", 51 | "shape": "RECTANGLE", 52 | "pixelWidth": 1280, 53 | "pixelHeight": 800, 54 | "dpi": 213, 55 | "currentPixelWidth": 1280, 56 | "currentPixelHeight": 800, 57 | "touch": [ 58 | "SINGLE" 59 | ], 60 | "video": { 61 | "codecs": [ 62 | "H_264_42", 63 | "H_264_41" 64 | ] 65 | } 66 | }, 67 | "Extensions": { 68 | "available": { 69 | "aplext:backstack:10": {} 70 | } 71 | }, 72 | "System": { 73 | "application": { 74 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 75 | }, 76 | "user": { 77 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 78 | }, 79 | "device": { 80 | "deviceId": "amzn1.ask.device.AHJS3UNHASC2IYCUZLW7B7JFFK47EBNNDD3TY6WV5QKDI4SXVTMH5SYYTFJKBPU4NORLUJOCLRXGFHWL7WAAPCEGYBM6D2HB3RP6CGARAIXDXZVQQ5SLJVAEPIMXZDVGVY2JAAWKIXDI3ENCCQUWQUI6UPDQ", 81 | "supportedInterfaces": {} 82 | }, 83 | "apiEndpoint": "https://api.amazonalexa.com", 84 | "apiAccessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOiJodHRwczovL2FwaS5hbWF6b25hbGV4YS5jb20iLCJpc3MiOiJBbGV4YVNraWxsS2l0Iiwic3ViIjoiYW16bjEuYXNrLnNraWxsLmZhMWE3YWRhLTEwY2MtNGQ3MS1iOGFhLWQ3YzYwNGZmNTJjYSIsImV4cCI6MTY3NTM0NTc2MSwiaWF0IjoxNjc1MzQ1NDYxLCJuYmYiOjE2NzUzNDU0NjEsInByaXZhdGVDbGFpbXMiOnsiY29udGV4dCI6IkFBQUFBQUFBQUFDRWMvMlRyV3dGdWZ0STBMckowdjc2SXdFQUFBQUFBQUFtVDNFZVFFZyswYjNpaUxicCtma3FpOTdJM3I2WlV2KzZQcktxRHAyaFluOSt4OHA5cGsyMXN4VVVkZU0yc0hFdmtTU2NsNFZ4TFgyWm9zV2FiWS9LakI2VXV2UnVKeVlVcXYzckdYSnFoYmFpb2xseEw4UHNGTitQQ0hudkp2ZWZ5akxubm1pSG14Z1NrdnNOR1Z1OUN3S1RLZzNMckk4UUc4bUIxR1h6YVRSbmtKaHZJT281SW5HZlh3K1A4QTNCRHdyK2ZISWwzcHdRVy9mM2M4V0h5OXAxZWZRNFB0OUtYWEtBK0pqangzUVgxenBPQ3ZUeXhKWTNmS2hHbmNSMzAvc2R3NjhodUtjaU50Nm9wQzZDREZESkgwbXpicEt1eUVNWldHamxCcjBDS013bVpwY0FKSlhxa0pLNm53NGxreHZiZVZRaVlGOU9DelltcTlHSkI1TGN3a2JzNlVsNWxWYmFIWG1sSzFXK0FqM0N1YUhCaFkvN3dYQUxCenlwdU13PSIsImRldmljZUlkIjoiYW16bjEuYXNrLmRldmljZS5BSEpTM1VOSEFTQzJJWUNVWkxXN0I3SkZGSzQ3RUJOTkREM1RZNldWNVFLREk0U1hWVE1INVNZWVRGSktCUFU0Tk9STFVKT0NMUlhHRkhXTDdXQUFQQ0VHWUJNNkQySEIzUlA2Q0dBUkFJWERYWlZRUTVTTEpWQUVQSU1YWkRWR1ZZMkpBQVdLSVhESTNFTkNDUVVXUVVJNlVQRFEiLCJ1c2VySWQiOiJhbXpuMS5hc2suYWNjb3VudC5BRkNBSDJXNlk1WEgySUlCNlZVTjNHTktPR1BHVVBRQko1U0dNSjZSVllONEI3V0VSR1FVM1U1WFpVWU1ZUjVIMjdNSDVWUE1KQlpWRDRUTU1JRExaRVI1NlVFUk5FVjY0TllGVlkzQUg0TUdRREhZRFozSk9JU1I3N1E3UUxEWDI3VldSNEpBUTVVU0lSVEFIRlAzVE5XS1lNUEpLSEhIMzZNVDdJUExDRTNFV1A0R0tSUVBJUEE2R0tEVlJUR0NLVFZXUkxRTEVYTkZKVVEifX0.PcutipR_9seugNtZ2xUInlN79uf4vQOh0NlJ721k52UDnMODiUtR9D17d85slF0nHEK1gEubV1fh_-Df34XE0bW30VGh-q5xYE4nRMPOVOnG40_xKwv5UdHs9X55lyZViUnPsMYI0fRU82O8EKsIipGW2fUWDukDp2wxdjYHzah1B8M3-vXxNUCBRGM2fvLLsSyt3-iU1tNXtA_hvSKDetLbmYgJf9u_ExCOWU9ThkjKwylx8Qv4ZCa0GnWDKWq-KnBDELCl7H92BZso9c6dniVdobcvSskr-zi1torpcg1_JK0805FTbFgtp398IG_DPlhHl2ZnWmxxzjU0VoarTQ" 85 | } 86 | }, 87 | "request": { 88 | "type": "IntentRequest", 89 | "requestId": "amzn1.echo-api.request.0ca3b633-a26a-4252-a4a8-feae2ad25cf8", 90 | "locale": "en-US", 91 | "timestamp": "2023-02-02T13:44:21Z", 92 | "intent": { 93 | "name": "Commemorations", 94 | "confirmationStatus": "NONE", 95 | "slots": { 96 | "date": { 97 | "name": "date", 98 | "value": "2023-02-02", 99 | "confirmationStatus": "NONE", 100 | "source": "USER", 101 | "slotValue": { 102 | "type": "Simple", 103 | "value": "2023-02-02" 104 | } 105 | } 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /alexa/tests/data/scriptures_intent_envelope_long.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "session": { 4 | "new": true, 5 | "sessionId": "amzn1.echo-api.session.5403cf5a-7597-4f7b-a581-3a94c19e04a6", 6 | "application": { 7 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 8 | }, 9 | "attributes": {}, 10 | "user": { 11 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 12 | } 13 | }, 14 | "context": { 15 | "Viewports": [ 16 | { 17 | "type": "APL", 18 | "id": "main", 19 | "shape": "RECTANGLE", 20 | "dpi": 213, 21 | "presentationType": "STANDARD", 22 | "canRotate": false, 23 | "configuration": { 24 | "current": { 25 | "mode": "HUB", 26 | "video": { 27 | "codecs": [ 28 | "H_264_42", 29 | "H_264_41" 30 | ] 31 | }, 32 | "size": { 33 | "type": "DISCRETE", 34 | "pixelWidth": 1280, 35 | "pixelHeight": 800 36 | } 37 | } 38 | } 39 | } 40 | ], 41 | "Viewport": { 42 | "experiences": [ 43 | { 44 | "arcMinuteWidth": 346, 45 | "arcMinuteHeight": 216, 46 | "canRotate": false, 47 | "canResize": false 48 | } 49 | ], 50 | "mode": "HUB", 51 | "shape": "RECTANGLE", 52 | "pixelWidth": 1280, 53 | "pixelHeight": 800, 54 | "dpi": 213, 55 | "currentPixelWidth": 1280, 56 | "currentPixelHeight": 800, 57 | "touch": [ 58 | "SINGLE" 59 | ], 60 | "video": { 61 | "codecs": [ 62 | "H_264_42", 63 | "H_264_41" 64 | ] 65 | } 66 | }, 67 | "Extensions": { 68 | "available": { 69 | "aplext:backstack:10": {} 70 | } 71 | }, 72 | "System": { 73 | "application": { 74 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 75 | }, 76 | "user": { 77 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 78 | }, 79 | "device": { 80 | "deviceId": "amzn1.ask.device.AHJS3UNHASC2IYCUZLW7B7JFFK47EBNNDD3TY6WV5QKDI4SXVTMH5SYYTFJKBPU4NORLUJOCLRXGFHWL7WAAPCEGYBM6D2HB3RP6CGARAIXDXZVQQ5SLJVAEPIMXZDVGVY2JAAWKIXDI3ENCCQUWQUI6UPDQ", 81 | "supportedInterfaces": {} 82 | }, 83 | "apiEndpoint": "https://api.amazonalexa.com", 84 | "apiAccessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOiJodHRwczovL2FwaS5hbWF6b25hbGV4YS5jb20iLCJpc3MiOiJBbGV4YVNraWxsS2l0Iiwic3ViIjoiYW16bjEuYXNrLnNraWxsLmZhMWE3YWRhLTEwY2MtNGQ3MS1iOGFhLWQ3YzYwNGZmNTJjYSIsImV4cCI6MTY3MzU2MjQ2MiwiaWF0IjoxNjczNTYyMTYyLCJuYmYiOjE2NzM1NjIxNjIsInByaXZhdGVDbGFpbXMiOnsiY29udGV4dCI6IkFBQUFBQUFBQUFCZ2NKOGtoZUErSUhMTmJSQ0tRUm5lSXdFQUFBQUFBQURoWXdxQVMzb3YxZlBoNW92TjJTVGUwZDNhN0NiNjlrejVzajVCR0J6VjJrZXkwak4xUDRicjlTL1R1NE1HQVFGQ01SK0tDOEVmbzlmMmFmV0p1V0hTamE4SjlqQng5bUZ0cjBnY054bUFJMEJ6R2lFY1BGTElBNjh6VHBWbmdMaDd4ZWErOXVoODkxY0NxUm8xVGFzVzBuUFBENlBNNFVSNW9TUEFkUXRqRzlYMVhWcGJ3ditKRTRIQTJqTXpmT3JPTFlmQWltdTNRcG1iTk1XN3ZUSmhNbklVTmtxdVNUU3BwUHFvT2toSFduZ2F3aFRvL2Y3MGZsOEl0b3dSMnNGQ1JyR0tpNTdHK3FrZHRxeFkrLyt0T2VNZEhHQ1kyNkVzVjFhSkNuYW0yWDNhY2dWZ001ek5lb2k5aXNFc3VjdHRSTG50TDkxcFdUTXN2YnJFamQ2RFd0TzllSlVWSWQzNXVhck9IT2xrZWtURlVabzY0M3FNSG9sSmk0cEd3R0h0UEVJPSIsImRldmljZUlkIjoiYW16bjEuYXNrLmRldmljZS5BSEpTM1VOSEFTQzJJWUNVWkxXN0I3SkZGSzQ3RUJOTkREM1RZNldWNVFLREk0U1hWVE1INVNZWVRGSktCUFU0Tk9STFVKT0NMUlhHRkhXTDdXQUFQQ0VHWUJNNkQySEIzUlA2Q0dBUkFJWERYWlZRUTVTTEpWQUVQSU1YWkRWR1ZZMkpBQVdLSVhESTNFTkNDUVVXUVVJNlVQRFEiLCJ1c2VySWQiOiJhbXpuMS5hc2suYWNjb3VudC5BRkNBSDJXNlk1WEgySUlCNlZVTjNHTktPR1BHVVBRQko1U0dNSjZSVllONEI3V0VSR1FVM1U1WFpVWU1ZUjVIMjdNSDVWUE1KQlpWRDRUTU1JRExaRVI1NlVFUk5FVjY0TllGVlkzQUg0TUdRREhZRFozSk9JU1I3N1E3UUxEWDI3VldSNEpBUTVVU0lSVEFIRlAzVE5XS1lNUEpLSEhIMzZNVDdJUExDRTNFV1A0R0tSUVBJUEE2R0tEVlJUR0NLVFZXUkxRTEVYTkZKVVEifX0.gWtS7K7mBbIehsKCqT_yobvyPTAfu67veDsy7dTzHW-yKHuFZjS0nhoMeQ3Td-44gvIwuVilO2rjsCfbHRdU44wgv6c-y3zUS2OSlPFSNY-v6sn1Tb3QBVJP1-NdyXIlBLd0ntRotQqxjd2V83Ec76R-qOFFlCzlyNTBH0n64K_Kq2-tiejhj8kRR9GoaE_BhSJ45fs-Ip4RbClPytMYCOSPVa1o8Ni_QE0hhl4dVlceCTak2qbmhaAzS-GEuih-EUvjELj6kz-Ikcw0P186FVpuKNSPXnF8tZyvZ0PKkK3ncC5RuNDz3al3xdedDcOQDVSN0l4Kby0F91j9wE2Cbg" 85 | } 86 | }, 87 | "request": { 88 | "type": "IntentRequest", 89 | "requestId": "amzn1.echo-api.request.496968e4-c792-4ecb-9705-f1be0ab6cfbc", 90 | "locale": "en-US", 91 | "timestamp": "2023-01-12T22:22:42Z", 92 | "intent": { 93 | "name": "Scriptures", 94 | "confirmationStatus": "NONE", 95 | "slots": { 96 | "date": { 97 | "name": "date", 98 | "value": "2023-04-14", 99 | "confirmationStatus": "NONE", 100 | "source": "USER", 101 | "slotValue": { 102 | "type": "Simple", 103 | "value": "2023-04-14" 104 | } 105 | } 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /alexa/tests/data/ssml_escaping_envelope.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "session": { 4 | "new": true, 5 | "sessionId": "amzn1.echo-api.session.be3fa3d7-d8a4-497c-b1e7-0df524f577cd", 6 | "application": { 7 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 8 | }, 9 | "attributes": {}, 10 | "user": { 11 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 12 | } 13 | }, 14 | "context": { 15 | "Viewports": [ 16 | { 17 | "type": "APL", 18 | "id": "main", 19 | "shape": "RECTANGLE", 20 | "dpi": 213, 21 | "presentationType": "STANDARD", 22 | "canRotate": false, 23 | "configuration": { 24 | "current": { 25 | "mode": "HUB", 26 | "video": { 27 | "codecs": [ 28 | "H_264_42", 29 | "H_264_41" 30 | ] 31 | }, 32 | "size": { 33 | "type": "DISCRETE", 34 | "pixelWidth": 1280, 35 | "pixelHeight": 800 36 | } 37 | } 38 | } 39 | } 40 | ], 41 | "Viewport": { 42 | "experiences": [ 43 | { 44 | "arcMinuteWidth": 346, 45 | "arcMinuteHeight": 216, 46 | "canRotate": false, 47 | "canResize": false 48 | } 49 | ], 50 | "mode": "HUB", 51 | "shape": "RECTANGLE", 52 | "pixelWidth": 1280, 53 | "pixelHeight": 800, 54 | "dpi": 213, 55 | "currentPixelWidth": 1280, 56 | "currentPixelHeight": 800, 57 | "touch": [ 58 | "SINGLE" 59 | ], 60 | "video": { 61 | "codecs": [ 62 | "H_264_42", 63 | "H_264_41" 64 | ] 65 | } 66 | }, 67 | "Extensions": { 68 | "available": { 69 | "aplext:backstack:10": {} 70 | } 71 | }, 72 | "Advertising": { 73 | "advertisingId": "00000000-0000-0000-0000-000000000000", 74 | "limitAdTracking": true 75 | }, 76 | "System": { 77 | "application": { 78 | "applicationId": "amzn1.ask.skill.fa1a7ada-10cc-4d71-b8aa-d7c604ff52ca" 79 | }, 80 | "user": { 81 | "userId": "amzn1.ask.account.AFCAH2W6Y5XH2IIB6VUN3GNKOGPGUPQBJ5SGMJ6RVYN4B7WERGQU3U5XZUYMYR5H27MH5VPMJBZVD4TMMIDLZER56UERNEV64NYFVY3AH4MGQDHYDZ3JOISR77Q7QLDX27VWR4JAQ5USIRTAHFP3TNWKYMPJKHHH36MT7IPLCE3EWP4GKRQPIPA6GKDVRTGCKTVWRLQLEXNFJUQ" 82 | }, 83 | "device": { 84 | "deviceId": "amzn1.ask.device.AHJS3UNHASC2IYCUZLW7B7JFFK47EBNNDD3TY6WV5QKDI4SXVTMH5SYYTFJKBPU4NORLUJOCLRXGFHWL7WAAPCEGYBM6D2HB3RP6CGARAIXDXZVQQ5SLJVAEPIMXZDVGVY2JAAWKIXDI3ENCCQUWQUI6UPDQ", 85 | "supportedInterfaces": {} 86 | }, 87 | "apiEndpoint": "https://api.amazonalexa.com", 88 | "apiAccessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOiJodHRwczovL2FwaS5hbWF6b25hbGV4YS5jb20iLCJpc3MiOiJBbGV4YVNraWxsS2l0Iiwic3ViIjoiYW16bjEuYXNrLnNraWxsLmZhMWE3YWRhLTEwY2MtNGQ3MS1iOGFhLWQ3YzYwNGZmNTJjYSIsImV4cCI6MTY5NDcyMzAzNSwiaWF0IjoxNjk0NzIyOTc1LCJuYmYiOjE2OTQ3MjI5NzUsInByaXZhdGVDbGFpbXMiOnsiY29udGV4dCI6IkFBQUFBQUFBQUFCWjBGeHBER0MvcktvRGNKc043bEk2UmdFQUFBQUFBQUFxWUthK1FMK3dIa3d6VDNCNHJ4WkpwVUJTbzVYVnlvVStJRjUwYXFPWmFXRlkvV2tNVFVtdTJpQXdwNWZ2dUpUb24xU1A0MFBmSU94YjA1TFhDa1ptVUhsU2thSjVOVXQ3bDhBMVp5aWo3UDllTTY1dkJtRGNVcUdmdmVuRFY0aEdQb3AwRVRkOThpZGg2Z01JMGJFRVo0Q01xQTVPZVB1ZXJKckJqVkJCNlNrTjh6UTVkTEI5Zzh6L3cxWTlBcnBUL3BXbEpBdHhtcnE3Vkd1MENPNnFDbXA0Y1JpQVJRd0N6QXkwSUwxSDBCc3IzclhDak54d3JhMS81Yjh1NndnL000dlhEZEc2R01sbW05V0xFU0RqdjIrNlI3MCtkbHhxVWdhQUpzMndLOFdhZVJwMHVNVWFhdWZvcSs0bENRVGhwWE9vc0JnK2w4WDdzdGhOVWlITTJiZE5SNDNTY0lXSzJyb3BvSlVhVUVJTFVNdzBTbk1nYitkelQvRTdyU051UGhlUVplOGUwN0hUblplZEQybkxCVVZwYmNoSGY0bytkcXVLd2MzUGFhYzR4UTVNQkE9PSIsImRldmljZUlkIjoiYW16bjEuYXNrLmRldmljZS5BSEpTM1VOSEFTQzJJWUNVWkxXN0I3SkZGSzQ3RUJOTkREM1RZNldWNVFLREk0U1hWVE1INVNZWVRGSktCUFU0Tk9STFVKT0NMUlhHRkhXTDdXQUFQQ0VHWUJNNkQySEIzUlA2Q0dBUkFJWERYWlZRUTVTTEpWQUVQSU1YWkRWR1ZZMkpBQVdLSVhESTNFTkNDUVVXUVVJNlVQRFEiLCJ1c2VySWQiOiJhbXpuMS5hc2suYWNjb3VudC5BRkNBSDJXNlk1WEgySUlCNlZVTjNHTktPR1BHVVBRQko1U0dNSjZSVllONEI3V0VSR1FVM1U1WFpVWU1ZUjVIMjdNSDVWUE1KQlpWRDRUTU1JRExaRVI1NlVFUk5FVjY0TllGVlkzQUg0TUdRREhZRFozSk9JU1I3N1E3UUxEWDI3VldSNEpBUTVVU0lSVEFIRlAzVE5XS1lNUEpLSEhIMzZNVDdJUExDRTNFV1A0R0tSUVBJUEE2R0tEVlJUR0NLVFZXUkxRTEVYTkZKVVEifX0.AyXPb_p5iuwh0bwpBN5asmnTOYoVgcZhsULMWqIeSKBv_N5FqTB76RcNxZ98rVQPg3t8HBqbV8z6-2MR8I3lHAthZ8rfdOW2OzA3CpI6W9o0L9yReo50iAzW9FlG5eDEFq4YTU8Kq8hc4fSMiOVX4y49s8euwzbFDzuBvIv6zPI6bZrDHw6l57fXokrgobyd-mIxV-ENqfH5uS0rrbu_-ROSEC5EXkjW0OELmAHyqYygKra1S-SiNtl7YPXp4_qP9dOA2cLXPJIMMoBDkMWiEjwzhV6T9I9Luwu66PjRuHPL8kaPcKsqHck43w1rWPw2Ez0LljFpBV7yoDH6z4KW8A" 89 | } 90 | }, 91 | "request": { 92 | "type": "IntentRequest", 93 | "requestId": "amzn1.echo-api.request.e4d76009-51c4-4c94-bdf0-726d192aad88", 94 | "locale": "en-US", 95 | "timestamp": "2023-09-14T20:22:55Z", 96 | "intent": { 97 | "name": "Commemorations", 98 | "confirmationStatus": "NONE", 99 | "slots": { 100 | "date": { 101 | "name": "date", 102 | "value": "2023-08-04", 103 | "confirmationStatus": "NONE", 104 | "source": "USER", 105 | "slotValue": { 106 | "type": "Simple", 107 | "value": "2023-08-04" 108 | } 109 | } 110 | } 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /bible/books.py: -------------------------------------------------------------------------------- 1 | def normalize_book_name(name): 2 | cleaned = ' '.join(name.split()).replace('.', '').lower() 3 | return BOOK_NAMES.get(cleaned, '') 4 | 5 | def is_chapterless(book): 6 | return book in CHAPTERLESS_BOOKS 7 | 8 | CHAPTERLESS_BOOKS = {'2JN', '3JN', 'BEL', 'JUD', 'MAN', 'OBA', 'PHM', 'S3Y', 'SUS'} 9 | 10 | BOOK_NAMES = { 11 | # Old Testament 12 | 'gen': 'GEN', 13 | 'genesis': 'GEN', 14 | 'exod': 'EXO', 15 | 'exodus': 'EXO', 16 | 'lev': 'LEV', 17 | 'leviticus': 'LEV', 18 | 'num': 'NUM', 19 | 'numbers': 'NUM', 20 | 'deut': 'DEU', 21 | 'deuteronomy': 'DEU', 22 | 'josh': 'JOS', 23 | 'joshua': 'JOS', 24 | 'judges': 'JDG', 25 | 'judg': 'JDG', 26 | 'ruth': 'RUT', 27 | '1 kgs': '1SA', 28 | '1 kings': '1SA', 29 | '2 kgs': '2SA', 30 | '2 kings': '2SA', 31 | '3 kgs': '1KI', 32 | '3 kings': '1KI', 33 | '4 kgs': '2KI', 34 | '4 kings': '2KI', 35 | '1 chr': '1CH', 36 | '1 chronicles': '1CH', 37 | '2 chr': '2CH', 38 | '2 chronicles': '2CH', 39 | 'ezra': 'EZR', 40 | 'neh': 'NEH', 41 | 'nehemiah': 'NEH', 42 | 'esth': 'EST', 43 | 'esther': 'EST', 44 | 'job': 'JOB', 45 | 'ps': 'PSA', 46 | 'psalm': 'PSA', 47 | 'psalms': 'PSA', 48 | 'prov': 'PRO', 49 | 'proverbs': 'PRO', 50 | 'eccl': 'ECC', 51 | 'ecclesiastes': 'ECC', 52 | 'song': 'SNG', 53 | 'song of solomon': 'SNG', 54 | 'song of songs': 'SNG', 55 | 'isa': 'ISA', 56 | 'isaiah': 'ISA', 57 | 'jer': 'JER', 58 | 'jeremiah': 'JER', 59 | 'hos': 'HOS', 60 | 'hosea': 'HOS', 61 | 'joel': 'JOL', 62 | 'amos': 'AMO', 63 | 'obad': 'OBA', 64 | 'obadiah': 'OBA', 65 | 'jonah': 'JON', 66 | 'jon': 'JON', 67 | 'mic': 'MIC', 68 | 'micah': 'MIC', 69 | 'nah': 'NAM', 70 | 'nahum': 'NAM', 71 | 'hab': 'HAB', 72 | 'habakkuk': 'HAB', 73 | 'zech': 'ZEC', 74 | 'zechariah': 'ZEC', 75 | 'hag': 'HAG', 76 | 'hagai': 'HAG', 77 | 'lam': 'LAM', 78 | 'lamentations': 'LAM', 79 | 'ezek': 'EZK', 80 | 'ezekiel': 'EZK', 81 | 'dan': 'DAN', 82 | 'daniel': 'DAN', 83 | 'zeph': 'ZEP', 84 | 'zephaniah': 'ZEP', 85 | 'mal': 'MAL', 86 | 'malachi': 'MAL', 87 | 88 | # Deuterocanonical 89 | 'tobit': 'TOB', 90 | 'tob': 'TOB', 91 | 'judith': 'JDT', 92 | 'additions to esther': 'ESG', 93 | 'wis': 'WIS', 94 | 'wisdom': 'WIS', 95 | 'wisdom of solomon': 'WIS', 96 | 'sirach': 'SIR', 97 | 'ecclesiasticus': 'SIR', 98 | 'wisdom of sirach': 'SIR', 99 | 'baruch': 'BAR', 100 | 'letter of jeremiah': 'LJE', 101 | 'song of the three': 'S3Y', 102 | 'prayer of azariah': 'S3Y', 103 | 'susanna': 'SUS', 104 | 'bel and the dragon': 'BEL', 105 | '1 maccabees': '1MA', 106 | '2 maccabees': '2MA', 107 | '3 maccabees': '3MA', 108 | '4 maccabees': '4MA', 109 | '1 esdras': '1ES', 110 | '2 esdras': '2ES', 111 | 'manasseh': 'MAN', 112 | 'the prayer of manasseh': 'MAN', 113 | 114 | # New Testament 115 | 'matt': 'MAT', 116 | 'matthew': 'MAT', 117 | 'mt': 'MAT', 118 | 'mark': 'MRK', 119 | 'mk': 'MRK', 120 | 'luke': 'LUK', 121 | 'lk': 'LUK', 122 | 'john': 'JHN', 123 | 'jn': 'JHN', 124 | 'acts': 'ACT', 125 | 'rom': 'ROM', 126 | '1 cor': '1CO', 127 | '1 corinthians': '1CO', 128 | '2 cor': '2CO', 129 | '2 corinthians': '2CO', 130 | '1 thess': '1TH', 131 | '1 thessalonians': '1TH', 132 | '2 thess': '2TH', 133 | '2 thessalonians': '2TH', 134 | 'gal': 'GAL', 135 | 'galatians': 'GAL', 136 | 'eph': 'EPH', 137 | 'ephesians': 'EPH', 138 | 'phil': 'PHP', 139 | 'philippians': 'PHP', 140 | 'col': 'COL', 141 | 'colosians': 'COL', 142 | '1 tim': '1TI', 143 | '1 timothy': '1TI', 144 | '2 tim': '2TI', 145 | '2 timothy': '2TI', 146 | '1 john': '1JN', 147 | '1 jn': '1JN', 148 | '2 john': '2JN', 149 | '2 jn': '2JN', 150 | '3 john': '3JN', 151 | '3 jm': '3JN', 152 | '1 pet': '1PE', 153 | '1 peter': '1PE', 154 | '2 pet': '2PE', 155 | '2 peter': '2PE', 156 | 'heb': 'HEB', 157 | 'hebrews': 'HEB', 158 | 'titus': 'TIT', 159 | 'philemon': 'PHM', 160 | 'phlm': 'PHM', 161 | 'jas': 'JAS', 162 | 'james': 'JAS', 163 | 'jude': 'JUD', 164 | 'rev': 'REV', 165 | 'revelation': 'REV', 166 | } 167 | -------------------------------------------------------------------------------- /alexa/tests/test_speech.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from ask_sdk_core.response_helper import ResponseFactory 4 | from django.test import TestCase 5 | from django.utils import timezone 6 | 7 | from .. import speech 8 | from calendarium import liturgics 9 | 10 | 11 | class SpeechTestCase(TestCase): 12 | fixtures = ['calendarium.json', 'commemorations.json'] 13 | 14 | def test_human_join(self): 15 | items = 'Anthony', 'Athanasius', 'Cyril' 16 | expected = 'Anthony, Athanasius and Cyril' 17 | actual = speech.human_join(items) 18 | self.assertEqual(expected, actual) 19 | 20 | async def test_when_speech_tomorrow(self): 21 | now = timezone.localtime() 22 | tomorrow = now + timedelta(days=1) 23 | 24 | day = liturgics.Day(tomorrow.year, tomorrow.month, tomorrow.day) 25 | await day.ainitialize() 26 | actual = speech.when_speech(day) 27 | 28 | self.assertIn('Tomorrow', actual) 29 | self.assertNotIn('Today', actual) 30 | 31 | async def test_when_speech_today(self): 32 | now = timezone.localtime() 33 | later = now + timedelta(hours=2) 34 | 35 | day = liturgics.Day(later.year, later.month, later.day) 36 | await day.ainitialize() 37 | actual = speech.when_speech(day) 38 | 39 | self.assertIn('Today', actual) 40 | self.assertNotIn('Tomorrow', actual) 41 | 42 | def test_day_speech(self): 43 | builder = ResponseFactory() 44 | 45 | day = liturgics.Day(2023, 1, 7) 46 | day.initialize() 47 | 48 | speech_text, card_text = speech.day_speech(day) 49 | 50 | self.assertIn('Theophany', speech_text) 51 | self.assertIn('Theophany', card_text) 52 | 53 | def test_fasting_speech(self): 54 | day = liturgics.Day(2023, 1, 5) 55 | day.initialize() 56 | 57 | actual = speech.fasting_speech(day) 58 | self.assertIn('On this day there is a fast', actual) 59 | 60 | def test_fasting_speech_great(self): 61 | day = liturgics.Day(2023, 2, 28) 62 | day.initialize() 63 | 64 | actual = speech.fasting_speech(day) 65 | self.assertIn('This day is during', actual) 66 | 67 | def test_fasting_speech_no(self): 68 | day = liturgics.Day(2023, 1, 10) 69 | day.initialize() 70 | 71 | actual = speech.fasting_speech(day) 72 | self.assertIn('no fast', actual) 73 | 74 | def test_expand_abbreviations(self): 75 | data = [ 76 | ('Ss Cyril and Athanasius along with Ven. Bede', 'Ss Cyril and Athanasius along with Ven. Bede'), 77 | ('The most Holy Theotokos.', 'The most Holy Theotokos.'), 78 | ('The most Holytheotokos.', 'The most Holytheotokos.') 79 | ] 80 | for text, expected in data: 81 | with self.subTest(text): 82 | actual = speech.expand_abbreviations(text) 83 | self.assertEqual(expected, actual) 84 | 85 | def test_abbreviations(self): 86 | """Abbreviation keys should all be lowercase.""" 87 | for key in speech.ABBREVIATIONS: 88 | self.assertEqual(key, key.lower()) 89 | self.assertNotIn('.', key) 90 | 91 | def test_phonetics(self): 92 | """Phonetic keys should all be lowercase.""" 93 | for key in speech.PHONETICS: 94 | self.assertEqual(key, key.lower()) 95 | 96 | def test_estimate_group_size_long(self): 97 | day = liturgics.Day(2023, 4, 14) 98 | day.initialize() 99 | readings = day.get_readings() 100 | passage = readings[0].pericope.get_passage() 101 | size = speech.estimate_group_size(passage) 102 | 103 | # This passage should have 3 groups of 42 verses 104 | self.assertEqual(42, size) 105 | 106 | def test_estimate_group_size_short(self): 107 | day = liturgics.Day(2023, 1, 18) 108 | day.initialize() 109 | readings = day.get_readings() 110 | passage = readings[0].pericope.get_passage() 111 | 112 | size = speech.estimate_group_size(passage) 113 | self.assertIs(size, None) 114 | 115 | def test_reference_speech(self): 116 | data = [ 117 | (2023, 4, 14, 'The Holy Gospel according to Saint John, chapter 13'), 118 | (2023, 1, 11, 'Wisdom of Solomon, chapter 3'), 119 | (2023, 1, 18, 'The Catholic letter of Saint James, chapter 3'), 120 | (2023, 1, 21, 'Saint Paul\'s 1 letter to the Thessalonians, chapter 5'), 121 | ] 122 | 123 | for year, month, day, expected in data: 124 | day = liturgics.Day(year, month, day) 125 | day.initialize() 126 | readings = day.get_readings() 127 | reading = readings[0] 128 | 129 | with self.subTest(day): 130 | actual = speech.reference_speech(reading) 131 | self.assertEqual(expected, actual) 132 | 133 | def test_reading_speech(self): 134 | day = liturgics.Day(2023, 1, 18) 135 | day.initialize() 136 | readings = day.get_readings() 137 | 138 | speech_text = speech.reading_speech(readings[0]) 139 | self.assertIn('The spirit that dwelleth in us lusteth to envy', speech_text) 140 | -------------------------------------------------------------------------------- /skill-package/skill.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest": { 3 | "publishingInformation": { 4 | "locales": { 5 | "en-US": { 6 | "summary": "Alexa can read the daily scripture readings and commemorations for you as well as keep you informed about the fasting guidelines for the day.", 7 | "examplePhrases": [ 8 | "Alexa, open orthodox daily", 9 | "Alexa, ask orthodox daily about fasting tomorrow", 10 | "Alexa, ask orthodox daily to read the scriptures for yesterday" 11 | ], 12 | "keywords": [ 13 | "orthodox", 14 | "lectionary", 15 | "scriptures", 16 | "fasting", 17 | "christian", 18 | "readings", 19 | "bible", 20 | "oca" 21 | ], 22 | "name": "Orthodox Daily", 23 | "description": "Orthodox Daily makes it easy to access the daily scripture readings and lives of the saints. Simply ask Alexa to open Orthodox Daily, and she will provide you with a few details about today, including the fasting rules, and then read the prescribed scriptures and commemorations out loud. The scriptures are read from the King James Version and at present, follow OCA rubrics. The lives of the saints are from abbamoses.com.\n\nYou can also ask Alexa directly about a particular day. For instance you can say, \"Alexa, ask Orthodox Daily about tomorrow.\" Or you could say, \"Alexa, ask Orthodox daily about the fast on Friday,\" or \"Alexa, ask Orthodox Daily about saints on August 10.\"\n\nIf you want to skip all the extra details and go straight to the scriptures, just say, \"Alexa, ask Orthodox Daily to read the scriptures.\" If you missed the scriptures yesterday, you can say, \"Alexa, ask Orthodox Daily to read the scriptures for yesterday. Or if you want to jump to the saints you can say, \"Alexa, ask Orthodox Daily about the saints.\"\n\nThere are some known bugs. Orthodox Daily runs in Pacific timezone. If you live on the east coast and open Orthodox daily at 1am, you will get the information for the previous day. Also, because the readings are specified using Septuagint versification, an incorrect reading may on rare occasions be provided (mostly in the Proverbs).", 24 | "smallIconUri": "file://assets/images/en-US_smallIconUri.png", 25 | "updatesDescription": "The lives of the saints (i.e. commemorations) are now available. These are provided by abbamoses.com.", 26 | "largeIconUri": "file://assets/images/en-US_largeIconUri.png" 27 | }, 28 | "en-CA": { 29 | "summary": "Alexa can read the daily scripture readings for you as well as keep you informed about the fasting guidelines for the day.", 30 | "examplePhrases": [ 31 | "Alexa, open orthodox daily", 32 | "Alexa, ask orthodox daily about fasting tomorrow", 33 | "Alexa, ask orthodox daily to read the scriptures for yesterday" 34 | ], 35 | "keywords": [ 36 | "orthodox", 37 | "lectionary", 38 | "scriptures", 39 | "fasting", 40 | "christian", 41 | "readings", 42 | "bible", 43 | "oca" 44 | ], 45 | "name": "Orthodox Daily", 46 | "description": "Orthodox Daily makes it easy to access the daily scripture readings. Simply ask Alexa to open Orthodox Daily, and she will provide you with a few details about today, including the fasting rules, and then read the prescribed scriptures out loud. The scriptures are read from the King James Version and at present, follow OCA rubrics.\n\nYou can also ask Alexa directly about a particular day. For instance you can say, \"Alexa, ask Orthodox Daily about tomorrow.\" Or you could say, \"Alexa, ask Orthodox daily about the fast on Friday,\" or \"Alexa, ask Orthodox Daily about saints on August 10.\"\n\nIf you want to skip all the extra details and go straight to the scriptures, just say, \"Alexa, ask Orthodox Daily to read the scriptures.\" If you missed the scriptures yesterday, you can say, \"Alexa, ask Orthodox Daily to read the scriptures for yesterday.\n\nThere are some known bugs. Orthodox Daily runs in Pacific timezone. If you live on the east coast and open Orthodox daily at 1am, you will get the information for the previous day. Orthodox Daily\u0027s scripture database does not contain the so-called deuterocanonical books, so Alexa will fail to find some of the readings. Also, because the readings are specified using Septuagint versification, an incorrect reading may on rare occasions be provided (mostly in the Proverbs).", 47 | "smallIconUri": "file://assets/images/en-CA_smallIconUri.png", 48 | "largeIconUri": "file://assets/images/en-CA_largeIconUri.png" 49 | } 50 | }, 51 | "automaticDistribution": { 52 | "sourceLocaleForLanguages": [], 53 | "isActive": false 54 | }, 55 | "isAvailableWorldwide": false, 56 | "testingInstructions": "This is a single-interaction app. The example phrases should suffice for testing. Some phrases use the Amazon.DATE slot.", 57 | "category": "RELIGION_AND_SPIRITUALITY", 58 | "distributionMode": "PUBLIC", 59 | "distributionCountries": [ 60 | "US", 61 | "CA" 62 | ] 63 | }, 64 | "apis": { 65 | "custom": { 66 | "endpoint": { 67 | "sslCertificateType": "Wildcard", 68 | "uri": "https://orthocal-dev-6czswbhara-uc.a.run.app/echo/" 69 | }, 70 | "interfaces": [] 71 | } 72 | }, 73 | "manifestVersion": "1.0", 74 | "privacyAndCompliance": { 75 | "allowsPurchases": false, 76 | "locales": { 77 | "en-US": {} 78 | }, 79 | "containsAds": false, 80 | "isExportCompliant": true, 81 | "isChildDirected": false, 82 | "shoppingKit": { 83 | "isShoppingActionsEnabled": false, 84 | "isAmazonAssociatesOnAlexaEnabled": false 85 | }, 86 | "usesPersonalInfo": false 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /calendarium/tests/test_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from pathlib import Path 4 | from unittest import skip 5 | 6 | from dateutil.rrule import rrule, DAILY 7 | from django.test import RequestFactory, TestCase 8 | from django.urls import reverse 9 | from django.utils import timezone 10 | 11 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 12 | BASE_DIR = Path(__file__).resolve().parent 13 | 14 | 15 | class DayAPITestCase(TestCase): 16 | fixtures = ['calendarium.json', 'commemorations.json'] 17 | 18 | def test_get_day(self): 19 | with open(BASE_DIR / 'data/last_bday.json') as f: 20 | expected = json.loads(f.read()) 21 | 22 | url = reverse('api:get_calendar_day', kwargs={ 23 | 'cal': 'gregorian', 24 | 'year': 2022, 25 | 'month': 1, 26 | 'day': 7, 27 | }) 28 | response = self.client.get(url, format='json') 29 | actual = response.json() 30 | 31 | self.assertEqual(expected, actual) 32 | 33 | def test_get_day_invalid(self): 34 | with open(BASE_DIR / 'data/last_bday.json') as f: 35 | expected = json.loads(f.read()) 36 | 37 | url = reverse('api:get_calendar_day', kwargs={ 38 | 'cal': 'gregorian', 39 | 'year': 2022, 40 | 'month': 2, 41 | 'day': 29, 42 | }) 43 | response = self.client.get(url, format='json') 44 | self.assertEqual(404, response.status_code) 45 | 46 | def test_get_day_default(self): 47 | url = reverse('api:get_calendar_default', kwargs={'cal': 'gregorian'}) 48 | response = self.client.get(url, format='json') 49 | dt = timezone.localtime() 50 | actual = response.json() 51 | self.assertEqual(dt.year, actual['year']) 52 | self.assertEqual(dt.month, actual['month']) 53 | self.assertEqual(dt.day, actual['day']) 54 | 55 | def test_list_days(self): 56 | with open(BASE_DIR / 'data/january.json') as f: 57 | expected = json.loads(f.read()) 58 | 59 | url = reverse('api:get_calendar_month', kwargs={ 60 | 'cal': 'gregorian', 61 | 'year': 2022, 62 | 'month': 1, 63 | }) 64 | response = self.client.get(url, format='json') 65 | actual = response.json() 66 | 67 | self.assertEqual(expected, actual) 68 | 69 | def test_theophany(self): 70 | with open(BASE_DIR / 'data/theophany.json') as f: 71 | expected = json.loads(f.read()) 72 | 73 | url = reverse('api:get_calendar_day', kwargs={ 74 | 'cal': 'gregorian', 75 | 'year': 2023, 76 | 'month': 1, 77 | 'day': 6, 78 | }) 79 | response = self.client.get(url, format='json') 80 | actual = response.json() 81 | 82 | self.assertEqual(expected, actual) 83 | 84 | def test_nativity_julian(self): 85 | url = reverse('api:get_calendar_day', kwargs={ 86 | 'cal': 'julian', 87 | 'year': 2023, 88 | 'month': 1, 89 | 'day': 7, 90 | }) 91 | response = self.client.get(url, format='json') 92 | actual = response.json() 93 | 94 | self.assertIn('Nativity of Christ', actual['feasts']) 95 | 96 | def test_calendar_month_julian(self): 97 | url = reverse('api:get_calendar_month', kwargs={ 98 | 'cal': 'julian', 99 | 'year': 2023, 100 | 'month': 1, 101 | }) 102 | response = self.client.get(url, format='json') 103 | actual = response.json() 104 | nativity = actual[6] 105 | 106 | self.assertIn('Nativity of Christ', nativity['feasts']) 107 | 108 | def test_calendar_month_julian_range_error_low(self): 109 | url = reverse('api:get_calendar_month', kwargs={ 110 | 'cal': 'julian', 111 | 'year': 1583, 112 | 'month': 1, 113 | }) 114 | response = self.client.get(url, format='json') 115 | self.assertEqual(response.status_code, 404) 116 | 117 | @skip 118 | def test_julian_range_error(self): 119 | """A Gregorian leap year is not always a Julian leap year.""" 120 | url = reverse('api:get_calendar_month', kwargs={ 121 | 'cal': 'julian', 122 | 'year': 2100, 123 | 'month': 3, 124 | }) 125 | response = self.client.get(url, format='json') 126 | self.assertEqual(response.status_code, 200) 127 | 128 | def test_calendar_month_julian_range_error_high(self): 129 | url = reverse('api:get_calendar_month', kwargs={ 130 | 'cal': 'julian', 131 | 'year': 4100, 132 | 'month': 12, 133 | }) 134 | response = self.client.get(url, format='json') 135 | self.assertEqual(response.status_code, 404) 136 | 137 | def test_errors(self): 138 | """We shouldn't have any errors in the API.""" 139 | 140 | # This is just a brute force test to make sure we don't have any 141 | # errors in the API. 142 | start_dt = timezone.datetime(2023, 1, 1) 143 | end_dt = timezone.datetime(2029, 12, 31) 144 | for dt in rrule(DAILY, dtstart=start_dt, until=end_dt): 145 | with self.subTest(dt=dt): 146 | url = reverse('api:get_calendar_day', kwargs={ 147 | 'cal': 'gregorian', 148 | 'year': dt.year, 149 | 'month': dt.month, 150 | 'day': dt.day, 151 | }) 152 | response = self.client.get(url, format='json') 153 | self.assertEqual(response.status_code, 200) 154 | 155 | def test_oembed_calendar(self): 156 | """The oEmbed endpoint should return a response with a status of 200.""" 157 | 158 | # We need this to build an absolute url 159 | request = RequestFactory().get('/') 160 | 161 | calendar_path = reverse('calendar', kwargs={ 162 | 'cal': 'gregorian', 163 | 'year': 2023, 164 | 'month': 1, 165 | }) 166 | calendar_url = request.build_absolute_uri(calendar_path) 167 | 168 | url = reverse('api:get_calendar_embed') 169 | 170 | response = self.client.get(url, {'url': calendar_url}, format='json') 171 | self.assertEqual(response.status_code, 200) 172 | -------------------------------------------------------------------------------- /orthocal/static/main.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: 20px; 3 | font-family: 'EB Garamond', Garamond, 'Times New Roman', Georgia, serif; 4 | /* Prevent page shifting when scrollbar disappears on short pages */ 5 | overflow-y: scroll; 6 | } 7 | body { 8 | margin: 0; 9 | } 10 | h1, h2, h3, h4 { 11 | text-align: center; 12 | } 13 | .rubric { 14 | color: #a00; 15 | } 16 | 17 | /* Site title and navigation */ 18 | body > header { 19 | margin: 0; 20 | padding: 1.75em 0 0.25em 0; 21 | text-align: center; 22 | font-size: 2.5em; 23 | color: #666; 24 | font-weight: bold; 25 | font-variant: small-caps; 26 | } 27 | body > header a { 28 | text-decoration: none; 29 | color: #666; 30 | text-shadow: 2px 2px 4px #ddd; 31 | } 32 | body > nav { 33 | margin: 0; 34 | padding: 0; 35 | text-align: center; 36 | } 37 | body > nav ul { 38 | list-style: none; 39 | margin: 0 0 1.5em 0; 40 | padding: 0; 41 | } 42 | body > nav li { 43 | display: inline; 44 | border-right: 3px solid #ddd; 45 | padding: 8px; 46 | margin-left: -4px; 47 | text-transform: lowercase; 48 | } 49 | body > nav li:last-child { 50 | border-right: none; 51 | } 52 | body > nav a:link, body > nav a:visited { 53 | text-decoration: none; 54 | color: #777; 55 | transition-property: color, text-shadow; 56 | transition-duration: 0.3s; 57 | } 58 | body > nav a:hover { 59 | color: black; 60 | text-shadow: 2px 2px 3px #bbb; 61 | } 62 | 63 | /* Readings and Calendar navigation */ 64 | body > nav a.cal-nav { 65 | display: inline-block; 66 | padding: 2px 6px; 67 | margin: 2px; 68 | text-decoration: none; 69 | transition-property: color, border, text-shadow; 70 | transition-duration: 0.4s; 71 | } 72 | body > nav a.cal-nav:hover { 73 | color: #666; 74 | cursor: pointer; 75 | } 76 | a.active { 77 | background-color: #ddd; 78 | } 79 | 80 | main#orthocal { 81 | margin: 1em auto 4em auto; 82 | border-top: 8px solid #eee; 83 | width: 75%; 84 | min-height: 100%; 85 | } 86 | main#orthocal p { 87 | text-indent: 1.5em; 88 | margin-top: 0; 89 | margin-bottom: 0; 90 | } 91 | main#orthocal iframe { 92 | margin: 4% auto; 93 | width: 100%; 94 | } 95 | main#orthocal > header { 96 | padding-top: 1.5em; 97 | padding-bottom: 1em; 98 | } 99 | main#orthocal > header h1 { 100 | margin-top: 0; 101 | color: #555; 102 | font-size: 1.5em; 103 | } 104 | main#orthocal > header h1 span { 105 | margin-top: 0; 106 | color: black; 107 | font-size: 1.1em; 108 | } 109 | 110 | #content { 111 | text-align: justify; 112 | -webkit-hyphens: auto; 113 | -moz-hyphens: auto; 114 | -ms-hyphens: auto; 115 | hyphens: auto; 116 | } 117 | #content a { 118 | color: #a00; 119 | text-decoration: none; 120 | } 121 | #content a:hover { 122 | text-decoration: underline; 123 | } 124 | 125 | section.readings { 126 | border-top: 3px solid #ddd; 127 | padding-top: 1.5em; 128 | margin-top: 1.5em; 129 | } 130 | section.readings h2 { 131 | color: #a00; 132 | } 133 | 134 | .fasting { 135 | font-size: 1.4em; 136 | color: #555; 137 | text-align: center; 138 | text-indent: 0 !important; 139 | color: #a00; 140 | } 141 | .day { 142 | margin-top: 1em; 143 | padding-top: 1.5em; 144 | border-top: 5px solid #eee; 145 | } 146 | .commemorations li, .feasts li, .day > p { 147 | color: #555; 148 | } 149 | .passage { 150 | columns: 3 20em; 151 | column-gap: 2em; 152 | column-rule: 1px solid #ddd; 153 | padding-top: 2em; 154 | widows: 3; 155 | orphans: 3; 156 | text-align: justify; 157 | -webkit-hyphens: auto; 158 | -moz-hyphens: auto; 159 | -ms-hyphens: auto; 160 | hyphens: auto; 161 | } 162 | .passage h2 { 163 | margin-top: 0; 164 | font-size: 1.1em; 165 | break-inside: avoid; 166 | } 167 | .passage p .verse-number { 168 | vertical-align: super; 169 | font-size: 0.70em; 170 | color: #999; 171 | margin-right: 1px; 172 | } 173 | .day>p { 174 | text-align: center; 175 | } 176 | .day section { 177 | text-align: left; 178 | } 179 | label { 180 | font-size: 0.75em; 181 | } 182 | label input[type="radio"] { 183 | accent-color: #888; 184 | } 185 | .service-notes ul, .commemorations ul, .feasts ul { 186 | text-align: center; 187 | margin: 0; 188 | padding: 0; 189 | list-style-type: none; 190 | } 191 | .service-notes h2, .feasts h2, .commemorations h2 { 192 | margin-bottom: 0em; 193 | font-size: 1.2em; 194 | } 195 | 196 | table.month { 197 | font-size: 1.65vw; 198 | margin: 2em auto; 199 | table-layout: fixed; 200 | border-collapse: collapse; 201 | } 202 | table.month td { 203 | margin: 0; 204 | padding: 0; 205 | border: 1px solid #ccc; 206 | font-size: 0.75em; 207 | vertical-align: top; 208 | background-color: #f9f9f9; 209 | text-align: left; 210 | width: 14.28%; 211 | } 212 | table.month td.fast { 213 | background-color: #eee; 214 | } 215 | table.month td.fast:hover { 216 | background-color: #ddd !important; 217 | } 218 | table.month td.noday { 219 | border: 0; 220 | background-color: white; 221 | } 222 | table.month tr td p { 223 | text-indent: 0 !important; 224 | text-align: left; 225 | margin-bottom: 1em !important; 226 | } 227 | table.month tr th { 228 | text-align: center; 229 | color: gray; 230 | } 231 | table.month tr:first-child th { 232 | font-size: 1.5em; 233 | color: black; 234 | padding-top: 0; 235 | padding-bottom: 1em; 236 | } 237 | 238 | .commemorations li, .feasts li, .service-notes li { 239 | display: inline; 240 | color: #a00; 241 | } 242 | .commemorations li + li:before, .feasts li + li:before { 243 | content: "●"; 244 | color: black; 245 | padding: 0.5em; 246 | } 247 | 248 | p.feast { 249 | font-weight: bold; 250 | color: black; 251 | } 252 | 253 | table.month ul { 254 | list-style-type: none; 255 | margin: 0 0 1em 0; 256 | padding: 0; 257 | } 258 | table.month li { 259 | display: inline; 260 | } 261 | table.month li + li:before { 262 | content: "●"; 263 | padding: 0 0.5vw; 264 | } 265 | ul.feasts li { 266 | font-weight: bold; 267 | color: black; 268 | } 269 | ul.saints li { 270 | font-size: 0.75em; 271 | } 272 | 273 | p.day-number { 274 | font-size: 1.5em; 275 | } 276 | table.month td { 277 | transition-property: background-color; 278 | transition-duration: 0.25s; 279 | } 280 | table.month td a { 281 | display: block; 282 | padding: 1vw; 283 | text-decoration: none; 284 | color: black; 285 | height: 100%; 286 | } 287 | table.month td.sun:hover, 288 | table.month td.mon:hover, 289 | table.month td.tue:hover, 290 | table.month td.wed:hover, 291 | table.month td.thu:hover, 292 | table.month td.fri:hover, 293 | table.month td.sat:hover 294 | { 295 | background-color: #eee; 296 | cursor: pointer; 297 | } 298 | -------------------------------------------------------------------------------- /alexa/tests/test_intents.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from pathlib import Path 4 | from unittest import mock 5 | 6 | from django.test import TestCase 7 | from django.utils import timezone 8 | 9 | from ask_sdk_core.response_helper import ResponseFactory 10 | from ask_sdk_model import RequestEnvelope 11 | 12 | from .. import skills, speech 13 | 14 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 15 | BASE_DIR = Path(__file__).resolve().parent 16 | 17 | 18 | class IntentTestCase(TestCase): 19 | fixtures = ['calendarium.json', 'commemorations.json'] 20 | 21 | def test_launch_intent(self): 22 | with open(BASE_DIR / 'data/launch_intent_envelope.json') as f: 23 | envelope = f.read() 24 | 25 | today = timezone.localtime() 26 | 27 | skill = skills.orthodox_daily_skill 28 | 29 | request_envelope = skill.serializer.deserialize(payload=envelope, obj_type=RequestEnvelope) 30 | response = skill.invoke(request_envelope=request_envelope, context=None) 31 | 32 | self.assertEqual(['scriptures', 'commemorations'], response.session_attributes['task_queue']) 33 | self.assertEqual(today.strftime('%Y-%m-%d'), response.session_attributes['date']) 34 | self.assertIsNone(response.session_attributes['current_task']) 35 | self.assertIn('Would you like to hear the readings?', response.response.output_speech.ssml) 36 | 37 | def test_scriptures_intent_long(self): 38 | with open(BASE_DIR / 'data/scriptures_intent_envelope_long.json') as f: 39 | envelope = f.read() 40 | 41 | skill = skills.orthodox_daily_skill 42 | 43 | request_envelope = skill.serializer.deserialize(payload=envelope, obj_type=RequestEnvelope) 44 | response = skill.invoke(request_envelope=request_envelope, context=None) 45 | 46 | self.assertLess(len(response.response.output_speech.ssml), speech.MAX_SPEECH_LENGTH) 47 | 48 | def test_next_intent(self): 49 | with open(BASE_DIR / 'data/next_intent_envelope.json') as f: 50 | envelope = f.read() 51 | 52 | skill = skills.orthodox_daily_skill 53 | 54 | request_envelope = skill.serializer.deserialize(payload=envelope, obj_type=RequestEnvelope) 55 | response = skill.invoke(request_envelope=request_envelope, context=None) 56 | 57 | self.assertIn('deceiveth his own heart', response.response.output_speech.ssml) 58 | self.assertIn('Would you like to hear the next reading?', response.response.output_speech.ssml) 59 | self.assertEqual(1, response.session_attributes['next_reading']) 60 | self.assertEqual(['commemorations'], response.session_attributes['task_queue']) 61 | self.assertEqual('scriptures', response.session_attributes['current_task']) 62 | self.assertEqual('2023-01-12', response.session_attributes['date']) 63 | 64 | def test_next_intent_followup(self): 65 | with open(BASE_DIR / 'data/next_intent_envelope_followup.json') as f: 66 | envelope = f.read() 67 | 68 | skill = skills.orthodox_daily_skill 69 | 70 | request_envelope = skill.serializer.deserialize(payload=envelope, obj_type=RequestEnvelope) 71 | response = skill.invoke(request_envelope=request_envelope, context=None) 72 | 73 | self.assertIn('Would you like me to continue?', response.response.output_speech.ssml) 74 | self.assertEqual(1, response.session_attributes['next_reading']) 75 | self.assertEqual(37, response.session_attributes['next_verse']) 76 | self.assertEqual('scriptures', response.session_attributes['current_task']) 77 | self.assertEqual([], response.session_attributes['task_queue']) 78 | 79 | def test_next_intent_commemorations(self): 80 | with open(BASE_DIR / 'data/next_intent_commemorations_envelope.json') as f: 81 | envelope = f.read() 82 | 83 | skill = skills.orthodox_daily_skill 84 | 85 | request_envelope = skill.serializer.deserialize(payload=envelope, obj_type=RequestEnvelope) 86 | response = skill.invoke(request_envelope=request_envelope, context=None) 87 | 88 | self.assertIn('Our Father among the Saints Gregory the Theologian', response.response.output_speech.ssml) 89 | self.assertNotIn('', response.response.output_speech.ssml) 90 | 91 | def test_next_intent_multiple_commemorations(self): 92 | with open(BASE_DIR / 'data/next_intent_commemorations_multiple_envelope.json') as f: 93 | envelope = f.read() 94 | 95 | skill = skills.orthodox_daily_skill 96 | 97 | request_envelope = skill.serializer.deserialize(payload=envelope, obj_type=RequestEnvelope) 98 | response = skill.invoke(request_envelope=request_envelope, context=None) 99 | 100 | self.assertFalse(response.response.should_end_session) 101 | self.assertEqual(1, response.session_attributes['next_commemoration']) 102 | self.assertIn('Xenophon', response.response.output_speech.ssml) 103 | 104 | def test_help_intent(self): 105 | with open(BASE_DIR / 'data/help_intent_envelope.json') as f: 106 | envelope = f.read() 107 | 108 | skill = skills.orthodox_daily_skill 109 | 110 | request_envelope = skill.serializer.deserialize(payload=envelope, obj_type=RequestEnvelope) 111 | response = skill.invoke(request_envelope=request_envelope, context=None) 112 | 113 | self.assertIn('Orthodox Daily makes it easy', response.response.output_speech.ssml) 114 | self.assertFalse(response.response.should_end_session) 115 | 116 | def test_stop_intent(self): 117 | with open(BASE_DIR / 'data/no_intent_envelope.json') as f: 118 | envelope = f.read() 119 | 120 | skill = skills.orthodox_daily_skill 121 | 122 | request_envelope = skill.serializer.deserialize(payload=envelope, obj_type=RequestEnvelope) 123 | response = skill.invoke(request_envelope=request_envelope, context=None) 124 | 125 | self.assertTrue(response.response.should_end_session) 126 | self.assertEqual(0, len(response.session_attributes)) 127 | 128 | def test_commemorations_without_saints(self): 129 | with open(BASE_DIR / 'data/meeting_envelope.json') as f: 130 | envelope = f.read() 131 | 132 | skill = skills.orthodox_daily_skill 133 | 134 | request_envelope = skill.serializer.deserialize(payload=envelope, obj_type=RequestEnvelope) 135 | response = skill.invoke(request_envelope=request_envelope, context=None) 136 | 137 | self.assertIn('The commemoration is for The Meeting', response.response.card.content) 138 | self.assertTrue(response.response.should_end_session) 139 | 140 | def test_ssml_escaping(self): 141 | """SSML should not have illegal characters in it""" 142 | 143 | with open(BASE_DIR / 'data/ssml_escaping_envelope.json') as f: 144 | envelope = f.read() 145 | 146 | skill = skills.orthodox_daily_skill 147 | 148 | request_envelope = skill.serializer.deserialize(payload=envelope, obj_type=RequestEnvelope) 149 | response = skill.invoke(request_envelope=request_envelope, context=None) 150 | 151 | self.assertNotIn(' & ', response.response.output_speech.ssml) 152 | self.assertTrue(response.response.should_end_session) 153 | --------------------------------------------------------------------------------