Catalog your books with Go Templates
Have been spending a lot of time cataloging my books during Christmas. The effort has led to a system where I just gather all my books into a database and, after that, a webpage automatically lists them out (in an orderly fashion) with their title, subtitle and author under the section of their category. The webpage also comes with a snappy search engine.
The hope is that this “e-shelving” helps keep my bookshelves organized and my books easy to locate. This hasn’t remotely come into reality (lol) but anyways the process is a lot of fun. See the result here.
I’m using Library of Congress Classification. The database is just a JSON file. An example:
{
"title": "The Desert Fathers",
"subtitle": "Sayings of the Early Christian Monks",
"publishyear": "2003",
"publisher": "penguin",
"author": "Benedicta Ward (tr.) ",
"lccnumber": "BR1705-A2-V525-2003",
"_comment": "BR1705.A2 V525 2003",
"booktags": [
"Orthodoxy",
"monasticisim",
"desert fathers",
"penguin"
]
}
The webpage sorts and lists out books based on their lccnumber
, title
,
subtitle
and author
values. The search engine accepts keywords from
title
, author
and booktags
. Though in principle, all other keys can be
used for the search engine. More on this later.
Note that the actual LCC identifier is in the _comment
key while
lccnumber
stores its reformatted version. This is because the search
engine is CSS-based and uses the identifier as the #id
selector for a book.
The heavy lifting is done by a Go/Hugo template (see the last section for its download link). And here is how a new book gets catalogued:
- It first tries to go under the main LCC (Library of Congress Classification) class, i.e., the first letter of the identifier (“B” for The Desert Fathers example above);
- If the main class has too many books already, goes to the subclass instead (which is “BR” for The Desert Fathers);
- After being classified, it’s sorted according to the author name;
- For the same author, it’s sorted again according to the subtitle. This is so that multiple volumes of a book are displayed orderly.
I’ll discuss some of the logic and mechanics of the template below.
Class v.s. subclass
Why step 2? Here is the consideration: let’s say I have only three books under class P (Language and Literature):
- C. S. Lewis, The Chronicles of Narnia, PZ (subclass Z: Fiction and juvenile belles letters);
- Fyodor Dostoevsky, The Brothers Karamazov, PG (subclass G: Slavic, Baltic, and Albanian languages);
- H. P. Lovecraft, At the Mountains of Madness, PS (subclass S: American literature).
They belong to different subclasses. Until there are a decent amount of books in each subclass, it’s nicer to list them under the same heading (the main class P) so that the heading-book ratio is lower.
In the Hugo template, this is done manually where I define a dictionary made up of only those classes/subclasses I want to use as headings. Books are then classified based on this dictionary.
The dictionary is named lcc_dict
and you can see that it only has P (as of
now) but not subclasses PZ, PG or PS:
{{ $lcc_dict := dict
"A" "General works"
"B" "Philosophy (General)"
"BC" "Logic"
"BD" "Speculative philosophy"
"BF" "Psychology"
"BH" "Aesthetics"
[...]
"P" "Language and Literature"
"Q" "Science"
"R" "Medicine"
"S" "Agriculture"
"T" "Technology"
"U" "Military Science"
"V" "Naval Science"
"Z" "Bibliography, Library Science, and General Information Resources" }}
Classification
The classification therefore goes like this. When a book entry is being
processed, the first letter and, if existing, the subclass letters of its LCC
identifier are extracted. If this corresponds to an entry in lcc_dict
, its added
to a data slice lccs
, which builds up to the set of all classes (which are
eventually headings) for the current library. And if not, only the first
letter, the main class, is accepted into lccs
.
Furthermore, lccs
is sorted alphabetically.
{{ $lccs := slice }}
{{ range .Site.Data.library }}
{{ $lcc_this_book := (index (findRE `^[A-Z]+` .subject) 0) }}
{{ if isset $lcc_dict $lcc_this_book }}
{{ $lccs = $lccs | append $lcc_this_book }}
{{ else }}
{{ $lccs = $lccs | append (substr $lcc_this_book 0 1) }}
{{ end }}
{{ end }}
{{ $lccs = $lccs | uniq | sort }}
Sorting and listing
As mentioned earlier, each entry in lccs
corresponds to a heading:
{{ range $lccs }}
<h2>{{ print . }} - {{ index $lcc_dict . }}</h2>
{{ $lcc_list_now := . }}
[...]
{{ end }}
For each existing author (a data slice authors
is also defined but
omitted here), we find their books in the library and sort them by the
subtitle. Again, this is so that multiple volumes of a book are displayed
in the correct order.
{{ range $authors }}
{{ range where (sort $.Site.Data.library "subtitle" "asc") "author" . }}
[...]
{{ end }}
{{ end }}
After sorting, we extract the class/subclass letters of the current book. We
assign all the letters to lcc_this_book
and the first letter to
lcc_this_book_main
. The decision remains as to whether it belongs to the
current heading (lcc_list_now
) or not.
Potential scenarios:
lcc_this_book
matcheslcc_list_now
: obviously, the book should be listed here;lcc_this_book
does not matcheslcc_list_now
. The book can only be accepted now if, at least, its main class matcheslcc_list_now
, the latter of which must also be a main class. An example would be The Chronicles of Narnia being accepted under P (since we don’t want its subclass PZ to be a heading yet).- However, we might run into a situation where, for example, B and BL are both existing headings and hence we do not list BL books under B.
The following code results:
{{ $lcc_this_book := (index (findRE `^[A-Z]+` .subject) 0) }}
{{ $lcc_this_book_main := (substr $lcc_this_book 0 1) }}
{{ $lcc_this_book_isset := (isset $lcc_dict $lcc_this_book) }}
{{ if or (eq $lcc_this_book $lcc_list_now) (and (not $lcc_this_book_isset) (eq $lcc_this_book_main $lcc_list_now)) }}
<li>
{{ with .author }}
{{ . }}
{{ end }}
-
<b>{{ .title }}</b>
<i>
{{ with .subtitle }}
: {{ . }}
{{ end }}
</i>
</li>
{{ end }}
The search engine
The CSS-based search engine is inspired by this blog post. The mechanics is thoroughly explained there.
Essentially, the string you enter in the search box sets the value
attribute
of the input
element on the fly, which is done by the Javascript code
this.setAttribute('value', this.value)
. This is also the only piece of
Javascript needed.
<input type="search" value="" placeholder="Search a book here"
oninput="this.setAttribute('value', this.value)" />
Then an ul
element lists out all the books.
<ul id="booksearchresults">
{{ range $authors }}
{{ range where (sort $.Site.Data.library "subtitle" "asc") "author" . }}
<li id="{{ .lccnumber }}">
[...]
</li>
{{ end }}
{{ end }}
</ul>
The keyword you entered in the search box (i.e., the value
attribute you just
set in the input
element) will now determine whether a specific book (an li
element) is displayed or not. By default, obviously, all li
elements are
hidden. If there is a keyword match for a book, using CSS’s selector engine,
the display of the corresponding li
element is turned on.
The CSS code for The Desert Fathers book:
#booksearchresults li { display: none }
input[value='benedicta' i] ~ #booksearchresults #BR1705-A2-V525-2003,
input[value='desert' i] ~ #booksearchresults #BR1705-A2-V525-2003,
input[value='fathers' i] ~ #booksearchresults #BR1705-A2-V525-2003,
input[value='monasticisim' i] ~ #booksearchresults #BR1705-A2-V525-2003,
input[value='orthodoxy' i] ~ #booksearchresults #BR1705-A2-V525-2003,
input[value='penguin' i] ~ #booksearchresults #BR1705-A2-V525-2003,
input[value='the' i] ~ #booksearchresults #BR1705-A2-V525-2003,
input[value='tr' i] ~ #booksearchresults #BR1705-A2-V525-2003,
input[value='ward' i] ~ #booksearchresults #BR1705-A2-V525-2003
{ display: list-item }
As mentioned earlier, the currently accepted keywords of a book are extracted
from title
, author
and booktags
. A bash script achieves this:
#!/bin/sh
LEN=$(jq '. | length' < $1)
echo "#booksearchresults li { display: none }"
for (( i=0; i<$LEN; i++ )) ; do
ID=$(jq -r ".[$i].lccnumber" < $1)
jq -r ".[$i].author, .[$i].title, .[$i].booktags[]" < $1\
| tr -cs '[:alpha:]' '\n' \
| sed '/^.$/d' \
| tr '[:upper:]' '[:lower:]' | sort | uniq \
| sed -e "$ ! s/^\(.*\)$/input[value='\1' i] ~ #booksearchresults #${ID},/" \
| sed -e "$ s/^\(.*\)$/input[value='\1' i] ~ #booksearchresults #${ID}\n{ display: list-item }/"
done
Technically, one should be able to find The Desert Fathers by, say, only typing ‘deser’ or ‘des’. Then we would need two more lines in the CSS file:
[...]
input[value='deser' i] ~ #booksearchresults #BR1705-A2-V525-2003,
input[value='des' i] ~ #booksearchresults #BR1705-A2-V525-2003,
[...]
The bash script above is not doing this however. See my more recent post with an improved solution.
Finally, downloadable template file
The full template file is here.