Add an entry for "Notes from Small Planets"
[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 bs4
14 import cssmin
15 import frontmatter
16 from jinja2 import Environment, FileSystemLoader, select_autoescape
17 import markdown
18 from markdown.extensions.smarty import SmartyExtension
19 import smartypants
20
21 from generate_bookshelf import create_shelf_data_uri
22 from tint_colors import get_tint_colors, store_tint_color
23
24
25 def rsync(dir1, dir2):
26     subprocess.check_call(["rsync", "--recursive", "--delete", dir1, dir2])
27
28
29 def git(*args):
30     return subprocess.check_output(["git"] + list(args)).strip().decode("utf8")
31
32
33 def set_git_timestamps():
34     """
35     For everything in the covers/ directory, set the last modified timestamp to
36     the last time it was modified in Git.  This should make tint colour computations
37     stable across machines.
38     """
39     root = git("rev-parse", "--show-toplevel")
40
41     now = datetime.datetime.now().timestamp()
42
43     for f in os.listdir("src/covers"):
44         path = os.path.join("src/covers", f)
45
46         if not os.path.isfile(path):
47             continue
48
49         stat = os.stat(path)
50
51         # If the modified time is >7 days ago, skip setting the modified time.  This means
52         # the script stays pretty fast when doing a regular sync.
53         if now - stat.st_mtime > 7 * 24 * 60 * 60 and "--reset" not in sys.argv:
54             continue
55
56         revision = git("rev-list", "--max-count=1", "HEAD", path)
57
58         timestamp, *_ = git("show", "--pretty=format:%ai", "--abbrev-commit", revision).splitlines()
59         modified_time = datetime.datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S %z").timestamp()
60
61         access_time = stat.st_atime
62
63         os.utime(path, times=(access_time, modified_time))
64
65
66 @attr.s
67 class Book:
68     title = attr.ib()
69     author = attr.ib()
70     publication_year = attr.ib()
71     cover_image = attr.ib(default="")
72     cover_desc = attr.ib(default="")
73
74     isbn10 = attr.ib(default="")
75     isbn13 = attr.ib(default="")
76
77
78 @attr.s
79 class Review:
80     date_read = attr.ib()
81     text = attr.ib()
82     format = attr.ib(default=None)
83     rating = attr.ib(default=None)
84     did_not_finish = attr.ib(default=False)
85
86
87 @attr.s
88 class ReviewEntry:
89     path = attr.ib()
90     book = attr.ib()
91     review = attr.ib()
92
93     def out_path(self):
94         name = self.path.with_suffix("").name
95         return pathlib.Path(f"reviews/{name}")
96
97
98 def get_review_entry_from_path(path):
99     post = frontmatter.load(path)
100
101     kwargs = {}
102     for attr_name in Book.__attrs_attrs__:
103         try:
104             kwargs[attr_name.name] = post["book"][attr_name.name]
105         except KeyError:
106             pass
107
108     book = Book(**kwargs)
109
110     review = Review(**post["review"], text=post.content)
111
112     return ReviewEntry(path=path, book=book, review=review)
113
114
115 @attr.s
116 class CurrentlyReading:
117     text = attr.ib()
118
119
120 @attr.s
121 class CurrentlyReadingEntry:
122     path = attr.ib()
123     book = attr.ib()
124     reading = attr.ib()
125
126
127 def get_reading_entry_from_path(path):
128     post = frontmatter.load(path)
129
130     book = Book(**post["book"])
131     reading = CurrentlyReading(text=post.content)
132
133     return CurrentlyReadingEntry(path=path, book=book, reading=reading)
134
135
136 def _parse_date(value):
137     if isinstance(value, datetime.date):
138         return value
139     else:
140         return datetime.datetime.strptime(value, "%Y-%m-%d").date()
141
142
143 @attr.s
144 class Plan:
145     text = attr.ib()
146     date_added = attr.ib(converter=_parse_date)
147
148
149 @attr.s
150 class PlanEntry:
151     path = attr.ib()
152     book = attr.ib()
153     plan = attr.ib()
154
155
156 def get_plan_entry_from_path(path):
157     post = frontmatter.load(path)
158
159     book = Book(**post["book"])
160     plan = Plan(date_added=post["plan"]["date_added"], text=post.content)
161
162     return PlanEntry(path=path, book=book, plan=plan)
163
164
165 def get_entries(dirpath, constructor):
166     for dirpath, _, filenames in os.walk(dirpath):
167         for f in filenames:
168             if not f.endswith(".md"):
169                 continue
170
171             path = pathlib.Path(dirpath) / f
172
173             try:
174                 yield constructor(path)
175             except Exception:
176                 print(f"Error parsing {path}", file=sys.stderr)
177                 raise
178
179
180 def render_markdown(text):
181     return markdown.markdown(text, extensions=[SmartyExtension()])
182
183
184 def render_date(date_value):
185     if isinstance(date_value, datetime.date):
186         return date_value.strftime("%-d %B %Y")
187
188     date_match = re.match(
189         r"^(?P<year>\d{4})-(?P<month>\d{2})(?:-(?P<day>\d{2}))?$", date_value
190     )
191     assert date_match is not None, date_value
192
193     date_obj = datetime.datetime(
194         year=int(date_match.group("year")),
195         month=int(date_match.group("month")),
196         day=int(date_match.group("day") or "1"),
197     )
198
199     if date_match.group("day"):
200         return render_date(date_obj)
201     else:
202         return date_obj.strftime("%B %Y")
203
204
205 def save_html(template, out_name="", **kwargs):
206     html = template.render(**kwargs)
207     out_path = pathlib.Path("_html") / out_name / "index.html"
208     out_path.parent.mkdir(exist_ok=True, parents=True)
209
210     soup = bs4.BeautifulSoup(html, "html.parser")
211
212     # Minify the CSS in all inline <style> tags.
213     for style_tag in soup.find_all("style"):
214         style_tag.string = cssmin.cssmin(style_tag.string)
215
216     # Remove any comments
217     for comment in soup(text=lambda text: isinstance(text, bs4.Comment)):
218         comment.extract()
219
220     out_path.write_text(str(soup))
221
222
223 def _create_new_thumbnail(src_path, dst_path):
224     dst_path.parent.mkdir(exist_ok=True, parents=True)
225
226     # Thumbnails are 240x240 max, then 2x for retina displays
227     subprocess.check_call([
228         "convert", src_path, "-resize", "480x480>", dst_path
229     ])
230
231
232 def thumbnail_1x(name):
233     pth = pathlib.Path(name)
234     return pth.stem + "_1x" + pth.suffix
235
236
237 def _create_new_square(src_path, square_path):
238     square_path.parent.mkdir(exist_ok=True, parents=True)
239
240     subprocess.check_call([
241         "convert",
242         src_path, "-resize", "240x240", "-gravity", "center", "-background", "white", "-extent", "240x240", square_path
243     ])
244
245
246 def create_thumbnails():
247     for image_name in os.listdir("src/covers"):
248         if image_name == ".DS_Store":
249             continue
250
251         src_path = pathlib.Path("src/covers") / image_name
252         dst_path = pathlib.Path("_html/thumbnails") / image_name
253
254         if not dst_path.exists():
255             _create_new_thumbnail(src_path, dst_path)
256         elif src_path.stat().st_mtime > dst_path.stat().st_mtime:
257             _create_new_thumbnail(src_path, dst_path)
258
259         square_path = pathlib.Path("_html/squares") / image_name
260
261         if not square_path.exists():
262             _create_new_square(src_path, square_path)
263         elif src_path.stat().st_mtime > square_path.stat().st_mtime:
264             _create_new_square(src_path, square_path)
265
266         store_tint_color(dst_path)
267
268
269 CSS_HASH = hashlib.md5(open('static/style.css', 'rb').read()).hexdigest()
270
271
272 def css_hash(_):
273     return f"md5:{CSS_HASH}"
274
275
276 def main():
277     set_git_timestamps()
278
279     env = Environment(
280         loader=FileSystemLoader("templates"),
281         autoescape=select_autoescape(["html", "xml"]),
282     )
283
284     env.filters["render_markdown"] = render_markdown
285     env.filters["render_date"] = render_date
286     env.filters["smartypants"] = smartypants.smartypants
287     env.filters["thumbnail_1x"] = thumbnail_1x
288     env.filters["css_hash"] = css_hash
289     env.filters["create_shelf_data_uri"] = create_shelf_data_uri
290
291     create_thumbnails()
292
293     tint_colors = get_tint_colors()
294
295     rsync("src/covers/", "_html/covers/")
296     rsync("static/", "_html/static/")
297
298     # Render the "all reviews page"
299
300     all_reviews = list(
301         get_entries(dirpath="src/reviews", constructor=get_review_entry_from_path)
302     )
303     all_reviews = sorted(
304         all_reviews, key=lambda rev: str(rev.review.date_read), reverse=True
305     )
306
307     for review_entry in all_reviews:
308         save_html(
309             template=env.get_template("review.html"),
310             out_name=review_entry.out_path(),
311             review_entry=review_entry,
312             title=f"My review of {review_entry.book.title}",
313             tint_colors=tint_colors
314         )
315
316     save_html(
317         template=env.get_template("list_reviews.html"),
318         out_name="reviews",
319         all_reviews=[
320             (year, list(reviews))
321             for (year, reviews) in itertools.groupby(
322                 all_reviews, key=lambda rev: str(rev.review.date_read)[:4]
323             )
324         ],
325         title="books i’ve read",
326         this_year=str(datetime.datetime.now().year),
327         tint_colors=tint_colors
328     )
329
330     # Render the "currently reading" page
331
332     all_reading = list(
333         get_entries(
334             dirpath="src/currently_reading", constructor=get_reading_entry_from_path
335         )
336     )
337
338     save_html(
339         template=env.get_template("list_reading.html"),
340         out_name="reading",
341         all_reading=all_reading,
342         title="books i’m currently reading",
343         tint_colors=tint_colors
344     )
345
346     # Render the "want to read" page
347
348     all_plans = list(
349         get_entries(dirpath="src/plans", constructor=get_plan_entry_from_path)
350     )
351
352     all_plans = sorted(all_plans, key=lambda plan: plan.plan.date_added, reverse=True)
353
354     save_html(
355         template=env.get_template("list_plans.html"),
356         out_name="to-read",
357         all_plans=all_plans,
358         title="books i want to read",
359         tint_colors=tint_colors,
360     )
361
362     # Render the "never going to read this page"
363
364     all_retired = list(
365         get_entries(dirpath="src/will_never_read", constructor=get_plan_entry_from_path)
366     )
367
368     all_retired = sorted(
369         all_retired, key=lambda plan: plan.plan.date_added, reverse=True
370     )
371
372     save_html(
373         template=env.get_template("list_will_never_read.html"),
374         out_name="will-never-read",
375         all_retired=all_retired,
376         title="books i&rsquo;m never going to read",
377         tint_colors=tint_colors
378     )
379
380     # Render the front page
381
382     save_html(
383         template=env.get_template("index.html"),
384         text=open("src/index.md").read(),
385         reviews=all_reviews[:5],
386         tint_colors=tint_colors
387     )
388
389     print("✨ Rendered HTML files to _html ✨")
390
391
392 if __name__ == "__main__":
393     main()