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:

  1. 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);
  2. If the main class has too many books already, goes to the subclass instead (which is “BR” for The Desert Fathers);
  3. After being classified, it’s sorted according to the author name;
  4. 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 matches lcc_list_now: obviously, the book should be listed here;
  • lcc_this_book does not matches lcc_list_now. The book can only be accepted now if, at least, its main class matches lcc_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.