Be explicit that these scripts require Python 3
[books.alexwlchan.net] / scripts / render_html.py
1 #!/usr/bin/env python3
2
3 import datetime
4 import hashlib
5 import itertools
6 import os
7 import pathlib
8 import re
9 import subprocess
10 import sys
11
12 import attr
13 import frontmatter
14 from jinja2 import Environment, FileSystemLoader, select_autoescape
15 import markdown
16 from markdown.extensions.smarty import SmartyExtension
17 from PIL import Image
18 import smartypants
19
20
21 def rsync(dir1, dir2):
22     subprocess.check_call(["rsync", "--recursive", "--delete", dir1, dir2])
23
24
25 @attr.s
26 class Book:
27     title = attr.ib()
28     author = attr.ib()
29     publication_year = attr.ib()
30     cover_image = attr.ib(default="")
31     cover_desc = attr.ib(default="")
32
33     isbn10 = attr.ib(default="")
34     isbn13 = attr.ib(default="")
35
36
37 @attr.s
38 class Review:
39     date_read = attr.ib()
40     text = attr.ib()
41     format = attr.ib(default=None)
42     rating = attr.ib(default=None)
43     did_not_finish = attr.ib(default=False)
44
45
46 @attr.s
47 class ReviewEntry:
48     path = attr.ib()
49     book = attr.ib()
50     review = attr.ib()
51
52     def out_path(self):
53         name = self.path.with_suffix("").name
54         return pathlib.Path(f"reviews/{name}")
55
56
57 def get_review_entry_from_path(path):
58     post = frontmatter.load(path)
59
60     kwargs = {}
61     for attr_name in Book.__attrs_attrs__:
62         try:
63             kwargs[attr_name.name] = post["book"][attr_name.name]
64         except KeyError:
65             pass
66
67     book = Book(**kwargs)
68
69     review = Review(**post["review"], text=post.content)
70
71     return ReviewEntry(path=path, book=book, review=review)
72
73
74 @attr.s
75 class CurrentlyReading:
76     text = attr.ib()
77
78
79 @attr.s
80 class CurrentlyReadingEntry:
81     path = attr.ib()
82     book = attr.ib()
83     reading = attr.ib()
84
85
86 def get_reading_entry_from_path(path):
87     post = frontmatter.load(path)
88
89     book = Book(**post["book"])
90     reading = CurrentlyReading(text=post.content)
91
92     return CurrentlyReadingEntry(path=path, book=book, reading=reading)
93
94
95 def _parse_date(value):
96     if isinstance(value, datetime.date):
97         return value
98     else:
99         return datetime.datetime.strptime(value, "%Y-%m-%d").date()
100
101
102 @attr.s
103 class Plan:
104     text = attr.ib()
105     date_added = attr.ib(converter=_parse_date)
106
107
108 @attr.s
109 class PlanEntry:
110     path = attr.ib()
111     book = attr.ib()
112     plan = attr.ib()
113
114
115 def get_plan_entry_from_path(path):
116     post = frontmatter.load(path)
117
118     book = Book(**post["book"])
119     plan = Plan(date_added=post["plan"]["date_added"], text=post.content)
120
121     return PlanEntry(path=path, book=book, plan=plan)
122
123
124 def get_entries(dirpath, constructor):
125     for dirpath, _, filenames in os.walk(dirpath):
126         for f in filenames:
127             if not f.endswith(".md"):
128                 continue
129
130             path = pathlib.Path(dirpath) / f
131
132             try:
133                 yield constructor(path)
134             except Exception:
135                 print(f"Error parsing {path}", file=sys.stderr)
136                 raise
137
138
139 def render_markdown(text):
140     return markdown.markdown(text, extensions=[SmartyExtension()])
141
142
143 def render_date(date_value):
144     if isinstance(date_value, datetime.date):
145         return date_value.strftime("%-d %B %Y")
146
147     date_match = re.match(
148         r"^(?P<year>\d{4})-(?P<month>\d{2})(?:-(?P<day>\d{2}))?$", date_value
149     )
150     assert date_match is not None, date_value
151
152     date_obj = datetime.datetime(
153         year=int(date_match.group("year")),
154         month=int(date_match.group("month")),
155         day=int(date_match.group("day") or "1"),
156     )
157
158     if date_match.group("day"):
159         return render_date(date_obj)
160     else:
161         return date_obj.strftime("%B %Y")
162
163
164 def render_individual_review(env, *, review_entry):
165     template = env.get_template("review.html")
166     html = template.render(
167         review_entry=review_entry, title=f"My review of {review_entry.book.title}"
168     )
169
170     out_name = review_entry.out_path() / "index.html"
171     out_path = pathlib.Path("_html") / out_name
172     out_path.parent.mkdir(exist_ok=True, parents=True)
173     out_path.write_text(html)
174
175
176 def _create_new_thumbnail(src_path, dst_path):
177     dst_path.parent.mkdir(exist_ok=True, parents=True)
178
179     im = Image.open(src_path)
180
181     if im.width > 240 and im.height > 240:
182         im.thumbnail((240, 240))
183     im.save(dst_path)
184
185
186 def thumbnail_1x(name):
187     pth = pathlib.Path(name)
188     return pth.stem + "_1x" + pth.suffix
189
190
191 def _create_new_square(src_path, square_path):
192     square_path.parent.mkdir(exist_ok=True, parents=True)
193
194     im = Image.open(src_path)
195     im.thumbnail((240, 240))
196
197     dimension = max(im.size)
198
199     new = Image.new("RGB", size=(dimension, dimension), color=(255, 255, 255))
200
201     if im.height > im.width:
202         new.paste(im, box=((dimension - im.width) // 2, 0))
203     else:
204         new.paste(im, box=(0, (dimension - im.height) // 2))
205
206     new.save(square_path)
207
208
209 def create_thumbnails():
210     for image_name in os.listdir("src/covers"):
211         if image_name == ".DS_Store":
212             continue
213
214         src_path = pathlib.Path("src/covers") / image_name
215         dst_path = pathlib.Path("_html/thumbnails") / image_name
216
217         if not dst_path.exists():
218             _create_new_thumbnail(src_path, dst_path)
219         elif src_path.stat().st_mtime > dst_path.stat().st_mtime:
220             _create_new_thumbnail(src_path, dst_path)
221
222         square_path = pathlib.Path("_html/squares") / image_name
223
224         if not square_path.exists():
225             _create_new_square(src_path, square_path)
226         elif src_path.stat().st_mtime > square_path.stat().st_mtime:
227             _create_new_square(src_path, square_path)
228
229
230 CSS_HASH = hashlib.md5(open('static/style.css', 'rb').read()).hexdigest()
231
232
233 def css_hash(_):
234     return f"md5:{CSS_HASH}"
235
236
237 def main():
238     env = Environment(
239         loader=FileSystemLoader("templates"),
240         autoescape=select_autoescape(["html", "xml"]),
241     )
242
243     env.filters["render_markdown"] = render_markdown
244     env.filters["render_date"] = render_date
245     env.filters["smartypants"] = smartypants.smartypants
246     env.filters["thumbnail_1x"] = thumbnail_1x
247     env.filters["css_hash"] = css_hash
248
249     create_thumbnails()
250
251     rsync("src/covers/", "_html/covers/")
252     rsync("static/", "_html/static/")
253
254     # Render the "all reviews page"
255
256     all_reviews = list(
257         get_entries(dirpath="src/reviews", constructor=get_review_entry_from_path)
258     )
259     all_reviews = sorted(
260         all_reviews, key=lambda rev: str(rev.review.date_read), reverse=True
261     )
262
263     for review_entry in all_reviews:
264         render_individual_review(env, review_entry=review_entry)
265
266     template = env.get_template("list_reviews.html")
267     html = template.render(
268         all_reviews=[
269             (year, list(reviews))
270             for (year, reviews) in itertools.groupby(
271                 all_reviews, key=lambda rev: str(rev.review.date_read)[:4]
272             )
273         ],
274         title="books i’ve read",
275         this_year=str(datetime.datetime.now().year),
276     )
277
278     out_path = pathlib.Path("_html") / "reviews/index.html"
279     out_path.write_text(html)
280
281     # Render the "currently reading" page
282
283     all_reading = list(
284         get_entries(
285             dirpath="src/currently_reading", constructor=get_reading_entry_from_path
286         )
287     )
288
289     template = env.get_template("list_reading.html")
290     html = template.render(all_reading=all_reading, title="books i’m currently reading")
291
292     out_path = pathlib.Path("_html") / "reading/index.html"
293     out_path.parent.mkdir(exist_ok=True, parents=True)
294     out_path.write_text(html)
295
296     # Render the "want to read" page
297
298     all_plans = list(
299         get_entries(dirpath="src/plans", constructor=get_plan_entry_from_path)
300     )
301
302     all_plans = sorted(all_plans, key=lambda plan: plan.plan.date_added, reverse=True)
303
304     template = env.get_template("list_plans.html")
305     html = template.render(all_plans=all_plans, title="books i want to read")
306
307     out_path = pathlib.Path("_html") / "to-read/index.html"
308     out_path.parent.mkdir(exist_ok=True, parents=True)
309     out_path.write_text(html)
310
311     # Render the "never going to read this page"
312
313     all_retired = list(
314         get_entries(dirpath="src/will_never_read", constructor=get_plan_entry_from_path)
315     )
316
317     all_retired = sorted(
318         all_retired, key=lambda plan: plan.plan.date_added, reverse=True
319     )
320
321     template = env.get_template("list_will_never_read.html")
322     html = template.render(
323         all_retired=all_retired, title="books i&rsquo;m never going to read"
324     )
325
326     out_path = pathlib.Path("_html") / "will-never-read/index.html"
327     out_path.parent.mkdir(exist_ok=True, parents=True)
328     out_path.write_text(html)
329
330     # Render the front page
331
332     index_template = env.get_template("index.html")
333     html = index_template.render(
334         text=open("src/index.md").read(),
335         reviews=all_reviews[:5]
336     )
337
338     index_path = pathlib.Path("_html") / "index.html"
339     index_path.write_text(html)
340
341     print("✨ Rendered HTML files to _html ✨")
342
343
344 if __name__ == "__main__":
345     main()