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