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