Start adding known good books
[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     book = Book(**post["book"])
60     review = Review(**post["review"], text=post.content)
61
62     return ReviewEntry(path=path, book=book, review=review)
63
64
65 @attr.s
66 class CurrentlyReading:
67     text = attr.ib()
68
69
70 @attr.s
71 class CurrentlyReadingEntry:
72     path = attr.ib()
73     book = attr.ib()
74     reading = attr.ib()
75
76
77 def get_reading_entry_from_path(path):
78     post = frontmatter.load(path)
79
80     book = Book(**post["book"])
81     reading = CurrentlyReading(text=post.content)
82
83     return CurrentlyReadingEntry(path=path, book=book, reading=reading)
84
85
86 def _parse_date(value):
87     if isinstance(value, datetime.date):
88         return value
89     else:
90         return datetime.datetime.strptime(value, "%Y-%m-%d").date()
91
92
93 @attr.s
94 class Plan:
95     text = attr.ib()
96     date_added = attr.ib(converter=_parse_date)
97
98
99 @attr.s
100 class PlanEntry:
101     path = attr.ib()
102     book = attr.ib()
103     plan = attr.ib()
104
105
106 def get_plan_entry_from_path(path):
107     post = frontmatter.load(path)
108
109     book = Book(**post["book"])
110     plan = Plan(**post["plan"], text=post.content)
111
112     return PlanEntry(path=path, book=book, plan=plan)
113
114
115 def get_entries(dirpath, constructor):
116     for dirpath, _, filenames in os.walk(dirpath):
117         for f in filenames:
118             if not f.endswith(".md"):
119                 continue
120
121             path = pathlib.Path(dirpath) / f
122
123             try:
124                 yield constructor(path)
125             except Exception:
126                 print(f"Error parsing {path}", file=sys.stderr)
127                 raise
128
129
130 def render_markdown(text):
131     return markdown.markdown(text, extensions=[SmartyExtension()])
132
133
134 def render_date(date_value):
135     if isinstance(date_value, datetime.date):
136         return date_value.strftime("%d %B %Y")
137
138     date_match = re.match(
139         r"^(?P<year>\d{4})-(?P<month>\d{2})(?:-(?P<day>\d{2}))?$", date_value
140     )
141     assert date_match is not None, date_value
142
143     date_obj = datetime.datetime(
144         year=int(date_match.group("year")),
145         month=int(date_match.group("month")),
146         day=int(date_match.group("day") or "1"),
147     )
148
149     if date_match.group("day"):
150         return date_obj.strftime("%-d %B %Y")
151     else:
152         return date_obj.strftime("%B %Y")
153
154
155 def render_individual_review(env, *, review_entry):
156     template = env.get_template("review.html")
157     html = template.render(
158         review_entry=review_entry, title=f"My review of {review_entry.book.title}"
159     )
160
161     out_name = review_entry.out_path() / "index.html"
162     out_path = pathlib.Path("_html") / out_name
163     out_path.parent.mkdir(exist_ok=True, parents=True)
164     out_path.write_text(html)
165
166
167 def _create_new_thumbnail(src_path, dst_path):
168     dst_path.parent.mkdir(exist_ok=True)
169
170     im = Image.open(src_path)
171
172     if im.width > 240 and im.height > 240:
173         im.thumbnail((240, 240))
174     im.save(dst_path)
175
176     if im.width > 120 and im.height > 120:
177         im.thumbnail((120, 120))
178     im.save(dst_path.with_name(thumbnail_1x(dst_path)))
179
180
181 def thumbnail_1x(name):
182     pth = pathlib.Path(name)
183     return pth.stem + "_1x" + pth.suffix
184
185
186 def create_thumbnails():
187     for image_name in os.listdir("src/covers"):
188         if image_name == ".DS_Store":
189             continue
190
191         src_path = pathlib.Path("src/covers") / image_name
192         dst_path = pathlib.Path("_html/thumbnails") / image_name
193
194         if not dst_path.exists():
195             _create_new_thumbnail(src_path, dst_path)
196         elif src_path.stat().st_mtime > dst_path.stat().st_mtime:
197             _create_new_thumbnail(src_path, dst_path)
198
199
200 def main():
201     env = Environment(
202         loader=FileSystemLoader("templates"),
203         autoescape=select_autoescape(["html", "xml"]),
204     )
205
206     env.filters["render_markdown"] = render_markdown
207     env.filters["render_date"] = render_date
208     env.filters["smartypants"] = smartypants.smartypants
209     env.filters["thumbnail_1x"] = thumbnail_1x
210
211     create_thumbnails()
212
213     rsync("src/covers/", "_html/covers/")
214     rsync("static/", "_html/static/")
215
216     # Render the "all reviews page"
217
218     all_reviews = list(
219         get_entries(dirpath="src/reviews", constructor=get_review_entry_from_path)
220     )
221     all_reviews = sorted(
222         all_reviews, key=lambda rev: str(rev.review.date_read), reverse=True
223     )
224
225     for review_entry in all_reviews:
226         render_individual_review(env, review_entry=review_entry)
227
228     template = env.get_template("list_reviews.html")
229     html = template.render(
230         all_reviews=[
231             (year, list(reviews))
232             for (year, reviews) in itertools.groupby(
233                 all_reviews, key=lambda rev: str(rev.review.date_read)[:4]
234             )
235         ],
236         title="books i’ve read",
237         this_year=str(datetime.datetime.now().year),
238     )
239
240     out_path = pathlib.Path("_html") / "reviews/index.html"
241     out_path.write_text(html)
242
243     # Render the "currently reading" page
244
245     all_reading = list(
246         get_entries(
247             dirpath="src/currently_reading", constructor=get_reading_entry_from_path
248         )
249     )
250
251     template = env.get_template("list_reading.html")
252     html = template.render(all_reading=all_reading, title="books i’m currently reading")
253
254     out_path = pathlib.Path("_html") / "reading/index.html"
255     out_path.parent.mkdir(exist_ok=True, parents=True)
256     out_path.write_text(html)
257
258     # Render the "want to read" page
259
260     all_plans = list(
261         get_entries(dirpath="src/plans", constructor=get_plan_entry_from_path)
262     )
263
264     all_plans = sorted(all_plans, key=lambda plan: plan.plan.date_added)
265
266     template = env.get_template("list_plans.html")
267     html = template.render(all_plans=all_plans, title="books i want to read")
268
269     out_path = pathlib.Path("_html") / "to-read/index.html"
270     out_path.parent.mkdir(exist_ok=True, parents=True)
271     out_path.write_text(html)
272
273     # Render the front page
274
275     index_template = env.get_template("index.html")
276     html = index_template.render(text=open("src/index.md").read())
277
278     index_path = pathlib.Path("_html") / "index.html"
279     index_path.write_text(html)
280
281     print("✨ Rendered HTML files to _html ✨")
282
283
284 if __name__ == "__main__":
285     main()