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