summaryrefslogtreecommitdiff
path: root/build-concerts.py
diff options
context:
space:
mode:
Diffstat (limited to 'build-concerts.py')
-rwxr-xr-xbuild-concerts.py358
1 files changed, 358 insertions, 0 deletions
diff --git a/build-concerts.py b/build-concerts.py
new file mode 100755
index 0000000..3581421
--- /dev/null
+++ b/build-concerts.py
@@ -0,0 +1,358 @@
+#!/usr/bin/env python3
+
+from contextlib import contextmanager
+from dataclasses import dataclass
+from datetime import datetime
+import locale
+from operator import attrgetter
+from pathlib import Path
+import re
+from sys import argv
+from typing import Iterator, Optional
+
+from helpers import relative_path
+
+
+# TODO: change some jargon:
+# - event => concert
+# - canceled => warning
+
+
+LICENSE_URLS = {
+ 'CC0': 'https://creativecommons.org/publicdomain/zero',
+ 'CC BY': 'https://creativecommons.org/licenses/by',
+ 'CC BY-SA': 'https://creativecommons.org/licenses/by-sa',
+}
+
+LICENSE_RE = re.compile(
+ '('+'|'.join(LICENSE_URLS.keys())+')' + ' ([0-9.]+)'
+)
+
+
+@dataclass
+class LicenseInfo:
+ tag: str
+ version: str
+
+ @classmethod
+ def deserialize(cls, info):
+ if info is None:
+ return None
+ return cls(*LICENSE_RE.fullmatch(info).groups())
+
+ def format(self):
+ url = f'{LICENSE_URLS[self.tag]}/{self.version}/'
+
+ return f'<a href="{url}" target="_blank">{self.tag}</a>'
+
+
+@dataclass
+class Illustration:
+ file: str
+ alt_text: str
+ source_name: str
+ source_link: Optional[str]
+ license_info: Optional[LicenseInfo]
+
+ @classmethod
+ def deserialize(cls, d):
+ return cls(d['pic_file'],
+ d['pic_alt'],
+ d['pic_src'],
+ d['pic_link'],
+ LicenseInfo.deserialize(d['pic_license']))
+
+
+@dataclass
+class Concert:
+ time: datetime
+ place: str
+ address: str
+ pieces: Iterator[str]
+ instructions: str
+ illustration: Illustration
+ warning: Optional[str]
+
+ @classmethod
+ def deserialize(cls, d):
+ return cls(
+ time=datetime.strptime(d['time'], '%d/%m/%Y %Hh%M'),
+ place=d['place'],
+ address=d['address'],
+ pieces=d['pieces'],
+ instructions=d['instructions'],
+ illustration=Illustration.deserialize(d),
+ warning=d['warning']
+ )
+
+
+def optional(line):
+ return f'(?:{line})?'
+
+
+CONCERT_LINES = (
+ r'QUAND : (?P<time>[^\n]+)\n',
+ r'O[UÙ] : (?P<place>[^\n]+)\n',
+ 'ADRESSE :\n',
+ '(?P<address>.+?)\n',
+ 'PROGRAMME :\n',
+ '(?P<pieces>.+?)\n',
+ 'INSTRUCTIONS :\n',
+ '(?P<instructions>.+?)\n',
+ 'ILLUSTRATION :\n',
+ r'fichier : (?P<pic_file>[^\n]+)\n',
+ r'légende : (?P<pic_alt>[^\n]+)\n',
+ r'source : (?P<pic_src>[^\n]+)\n',
+ optional(r'lien : (?P<pic_link>[^\n]+)\n'),
+ optional(r'licence : (?P<pic_license>[^\n]+)\n'),
+ optional(r'AVERTISSEMENT : (?P<warning>[^\n]+)\n'),
+)
+
+CONCERT_RE = re.compile(''.join(CONCERT_LINES), flags=re.DOTALL)
+
+
+def guess_language(filename):
+ parent = str(Path(filename).parent)
+ if parent == '.':
+ return 'fr'
+ return parent
+
+
+def read_concerts(filename):
+ with open(filename) as f:
+ concerts = (
+ Concert.deserialize(match)
+ for match in re.finditer(CONCERT_RE, f.read())
+ )
+ return tuple(sorted(concerts, key=attrgetter('time')))
+
+
+def split_concerts(concerts, threshold):
+ for i, c in enumerate(concerts):
+ if c.time > threshold:
+ return concerts[:i], concerts[i:]
+
+ return concerts, ()
+
+
+LOCALIZED_TEXT = {
+ 'en': {
+ 'past': 'Past concerts',
+ 'next': 'Next concerts',
+ 'alt': 'Illustration:',
+ 'hint': 'Click on a concert to obtain more information.',
+ },
+ 'fr': {
+ 'past': 'Concerts passés',
+ 'next': 'Prochains concerts',
+ 'alt': 'Illustration :',
+ 'hint': "Cliquez sur un concert pour obtenir plus d'informations.",
+ }
+}
+
+THUMBNAILS_TEMPLATE = '''\
+ <h1>{heading}</h1>
+ <div class="events {time}">
+{thumbnails}
+ </div>\
+'''
+
+THUMBNAIL_TEMPLATE = '''\
+ <div class="{eventclasses}">
+ <a class="thumbnail" href="#{eventid}">
+ <img src="{pic_file}" alt="{pic_alt}">
+ <p class="summary">
+ {summary}
+ </p>
+ </a>
+ <div class="credits">
+ <span>
+ {credits}
+ </span>
+ </div>
+ </div>\
+'''
+
+
+@contextmanager
+def tmplocale(lang):
+ old_lang, encoding = locale.getlocale()
+ try:
+ locale.setlocale(locale.LC_TIME, (lang, encoding))
+ yield
+ finally:
+ locale.setlocale(locale.LC_TIME, (old_lang, encoding))
+
+
+def format_credits(illustration):
+ credits = illustration.source_name
+
+ if illustration.source_link is not None:
+ credits = (f'<a href="{illustration.source_link}" target="_blank">'
+ f'{illustration.source_name}'
+ '</a>')
+
+ if illustration.license_info is not None:
+ credits += ' / ' + illustration.license_info.format()
+
+ return credits
+
+
+def format_thumbnail(concert, imgdir, lang):
+ eventclasses = ('event',)
+ with tmplocale(lang):
+ day = f'{concert.time.day} {concert.time.strftime("%B %Y")}'
+ summary = f'{concert.place}<br>{day}'
+
+ if concert.warning is not None:
+ eventclasses += ('canceled',)
+ summary = (f'<span class="canceled">{concert.warning}</span>\n'
+ f' {summary}')
+
+ alt_prefix = LOCALIZED_TEXT[lang]['alt']
+
+ return THUMBNAIL_TEMPLATE.format_map({
+ 'eventclasses': ' '.join(eventclasses),
+ 'eventid': f'concert-{concert.time.strftime("%F")}',
+ 'pic_file': Path(imgdir, 'concerts', concert.illustration.file),
+ 'pic_alt': f'{alt_prefix} {concert.illustration.alt_text}',
+ 'summary': summary,
+ 'credits': format_credits(concert.illustration)
+ })
+
+
+def print_thumbnails_section(concerts, imgdir, section, lang):
+ if not concerts:
+ return
+
+ thumbnails = '\n'.join(
+ format_thumbnail(c, imgdir, lang) for c in concerts
+ )
+
+ print(THUMBNAILS_TEMPLATE.format(heading=LOCALIZED_TEXT[lang][section],
+ time=section,
+ thumbnails=thumbnails))
+
+
+def print_thumbnails(concerts, imgdir, lang):
+ today = datetime.fromordinal(
+ datetime.today().date().toordinal()
+ )
+ past_concerts, next_concerts = split_concerts(concerts, today)
+
+ print('<div id="event-list">')
+ print_thumbnails_section(next_concerts, imgdir, 'next', lang)
+ print_thumbnails_section(past_concerts, imgdir, 'past', lang)
+ print('</div>')
+
+
+DETAILS_TEMPLATE = '''\
+ <div class="{concertclasses}" id="{concertid}">
+{details}
+ </div>\
+'''
+
+
+DATE_FORMATTERS = {
+ 'en': {
+ 'date': lambda d: d.strftime('%A %B %-d, %Y'),
+ 'time': lambda d: d.strftime('%I:%M %p'),
+ },
+ 'fr': {
+ 'date': lambda d: d.strftime('%A %-d %B %Y').capitalize(),
+ 'time': lambda d: d.strftime('%Hh%M'),
+ },
+}
+
+
+def detail_block(tag, classes, content):
+ opener = f'<{tag} class="{" ".join(classes)}">'
+ closer = f'</{tag}>'
+
+ if isinstance(content, str):
+ return f' {opener}{content}{closer}'
+
+ return '\n'.join((
+ ' '+opener,
+ *(' '+line for line in content),
+ ' '+closer,
+ ))
+
+
+def break_lines(lines):
+ return tuple(line+'<br>' for line in lines[:-1]) + (lines[-1],)
+
+
+TOUCHUPS = (
+ (re.compile('([0-9])(st|nd|rd|th|er|ère|nde|ème)'), r'\1<sup>\2</sup>'),
+ (re.compile('(https://[^ ]+)'), r'<a href="\1" target="_blank">\1</a>'),
+ (re.compile('([^ ]+@[^ ]+)'), r'<a href="mailto:\1">\1</a>'),
+)
+
+
+def touchup_plaintext(plaintext):
+ text = plaintext
+ for regexp, repl in TOUCHUPS:
+ text = regexp.sub(repl, text)
+ return text
+
+
+def print_concert_details(concert, lang):
+ concert_id = f'concert-{concert.time.strftime("%F")}'
+ classes = ('details',)
+ blocks = []
+
+ if concert.warning is not None:
+ classes += ('canceled',)
+ blocks.append(
+ detail_block('p', ('canceled',), concert.warning)
+ )
+
+ with tmplocale(lang):
+ day = DATE_FORMATTERS[lang]['date'](concert.time)
+ hour = DATE_FORMATTERS[lang]['time'](concert.time)
+
+ address_lines = break_lines(concert.address.splitlines())
+ piece_list = tuple(
+ f'<li>{touchup_plaintext(line)}</li>'
+ for line in concert.pieces.splitlines()
+ )
+
+ blocks.extend((
+ detail_block('p', ('detail', 'date'), day),
+ detail_block('p', ('detail', 'time'), hour),
+ detail_block('p', ('detail', 'place'), address_lines),
+ detail_block('ol', ('detail', 'program'), piece_list),
+ ))
+
+ instructions = [
+ f' <p>{touchup_plaintext(line)}</p>'
+ for line in concert.instructions.splitlines()
+ ]
+
+ print(f' <div class="{" ".join(classes)}" id="{concert_id}">')
+ print('\n'.join(blocks+instructions))
+ print(' </div>')
+
+
+def print_details(concerts, lang):
+ print('<div id="event-details">')
+ print(f' <p class="hint">{LOCALIZED_TEXT[lang]["hint"]}</p>')
+
+ for c in concerts:
+ print_concert_details(c, lang)
+
+ print('</div>')
+
+
+def main(concerts_src):
+ imgdir = relative_path(to='images', ref=concerts_src)
+ lang = guess_language(concerts_src)
+
+ concerts = read_concerts(concerts_src)
+ print_thumbnails(concerts, imgdir, lang)
+ print_details(concerts, lang)
+
+
+if __name__ == '__main__':
+ main(argv[1])