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