Tracking the Books I've Read Using Svelte, XState and Quagga
I read a fair amount of books, but I’ve never kept track of them except in my brain because the friction to track books is just too high. Recently, I wondered how easy it would be if I could just use my phone to scan the barcode on the back of the book, and that would automagically insert the book into a list.
The theory
Each book is identified by a ISBN, a 13-digit number that is usually printed on the back of the book using a barcode. I would need some way to scan the barcode and get the number. Then I would need a way to lookup the title and author of the book from the number. Finally, those details should be saved somewhere.
There are some paid APIs that offer an ISBN-lookup service, but they are all subscription APIs. That didn’t make sense for something I’ll only use a few times a month 1. So I resorted to using the Google Books API. This was fine because I was about to use some other Google APIs too.
I wanted my list of books to be somewhere where it could be easily updated both via an API, but also manually. Although Goodreads is considered a social network for books, it has long deprecated API access. In addition, I didn’t really want my book list to be public. Using a spreadsheet seemed like the best option. It allows me to quickly view books I’ve read, add columns with notes if I want to, and export and manipulate it as required. In addition, if I used Google Sheets, I would get authentication and sharing controls for free.
Additional requirements
I knew I wanted this software to be a web-app, so I could quickly call it up on any device with a camera. Most of the time I would use my phone, but it would be easier to test on a laptop. It had to run on Android, ChromeOS and Linux at a minimum. I knew that camera access was pretty easy on the web platform these days, and all the APIs could be used in the browser directly, so I would just need a static host to serve the files. I did some research and it seemed like there was a decent library called Quagga to scan barcodes from images in JS. In addition Google has an easy way to serve it’s API from a CDN and use it directly. I also wanted to use this as an opportunity to try out two other technologies.
One is Svelte, which is a framework for writing reactive web-apps. I am not a web developer, and I’m very disappointed with the direction web development has taken in recent years (and I’m about to hit some of that complexity below). I don’t have any skin in the React vs Vue vs Svelte vs XYZ game. I picked Svelte because:
- It promised to make all these decisions about building and packaging and deploying for me, and hide it behind two commands: One to run a development version, and one to produce a production version.
- It allows for reactive UI, without the code having to look like reactive UI.
- I really did not care about styling this app, and I wanted to write the minimum amount of HTML to accomplish the job, and then hook it up to the relevant event handlers. It seemed like Svelte had the basic templating required to accomplish that.
The other is XState. I was really excited about this as I have long considered state machines (and their extension – state charts) as a very good way to model software that must react to user interaction. It also lends itself well to reactive UIs due to the isomorphism of events that affect the state, and then state that affects presentation. XState is a very usable implementation of state charts that I’ve admired for its documentation.
Modeling the problem using XState
I hope you have a good sense of how this is going to work:
- When I visit the page, if I’m not signed in to the app, I should be asked to sign in. This is accomplished by using my Google ID for authentication, and using the sign-in step to acquire the necessary spreadsheet and books permissions.
- If I am signed in, the page should immediately allow me to start scanning a book.
- When it detects a barcode, it should perform a lookup using the Books API.
- If it found a book, then it should use the Sheets API to add an entry to the spreadsheet.
This leads to the following state machine:
export const mainMachine = {
id: 'main',
initial: 'signed_out',
strict: true,
states: {
signed_out: {
on: {
SIGNED_IN: 'signed_in'
}
},
signed_in: {
on: {
SIGNED_OUT: 'signed_out',
},
...scanningMachine,
},
}
};
const scanningMachine = {
id: 'scan',
initial: 'initialize_scanner',
context: {
isbn: null,
message: null,
},
states: {
initialize_scanner: {
entry: ['createQuagga', assign({ isbn: null, message: null })],
on: {
INITIALIZED: 'wait_for_input',
},
},
wait_for_input: {
entry: ['startScanning'],
exit: ['stopScanning'],
on: {
ISBN_ENTERED: {
target: 'processing_isbn',
actions: assign({
isbn: (context, event) => event.isbn,
}),
}
},
},
processing_isbn: {
invoke: {
id: 'process-isbn',
src: 'processISBN',
onDone: {
target: 'success',
actions: assign({ message: (_, event) => `Added book ${event.data.title}`}),
},
onError: {
target: 'error',
actions: assign({ message: (_, event) => event.data }),
},
},
},
error: {
on: {
REPEAT: 'initialize_scanner',
}
},
success: {
on: {
REPEAT: 'initialize_scanner',
}
},
}
};
This sets up the state machine describing the possible flows. It captures the signed-in state, and then loops through getting an ISBN as well as processing (lookup+append) it. Regardless of success/error, I can add another book by sending a REPEAT event which kick starts the whole process again. You can see how most of the “business logic” is conducted via the state machine. This allows us to test the behavior of the machine without involving any third-party libraries like the Google API.
Barcode scanning
The Quagga library works quite well, recognizing barcodes easily from my cellphone camera (Pixel 4a) and a dedicated 1080p webcam. It has trouble with the builtin Pixelbook webcam, which can’t focus enough to allow Quagga to detect edges.
There are some edge cases with the API, where calling Quagga.start()
doesn’t start rendering on the canvas element again, so the machine has to enter the initialize_scanner
state again instead of wait_for_input
.
Svelte
It was pretty easy to use Svelte and do the things I needed. The fact that it handles refreshing the DOM whenever stores change is really nice. This works really well with the XState add-on that presents the state machine as a store. I am not entirely sure how I feel about the production version being just one bundled JS file. It does take all the readability out of my code. That is fine for now, because I’m not sharing the actual code with anyone.
Google APIs
The Google APIs are where I had to do quite a lot of trial-and-error. First, it isn’t clear if the API Key is required if the Client ID is present. It is also not clear if it is safe to include the Client ID directly in the JS source, since anybody could copy and use it. I am guessing the fact that each ID has a whitelist of domains from which requests are allowed may make this acceptable, but I couldn’t find out for sure. This is actually the primary reason I’m not able to make the code public.
If one uses the gapi
object that is part of the SDK, but also uses OAuth, then lookup requests fail if the user has not given “Manage my books” permission. This was silly because I was doing public lookups. So I switched the lookup to do a direct fetch()
to the HTTP endpoint, which doesn’t even require an API key.
I’ve chosen to hard-code the document ID for my spreadsheet in the code, as authentication is still required to modify it, and this way I don’t have to keep any local state to preserve the ID. That said, Google does offer a file-picker based API that would let someone select a specific file instead. Since this is situated software, I can simply add an association of specific emails to specific document IDs.
Deployment
Deploying this is fairly simple because I already use a similar process for my blog. I use Netlify, set up the relevant Github integration, and the command to build on a push. The app is served on a sub-domain of this site.
Miscellanea
ISBN lookups are fickle
Books sometimes have multiple ISBNs, out of which the correct one may not be listed on the back cover. Other times, the ISBN is not found on Google. Sometimes bookstores will stick their own sticker on top of the ISBN barcode, which means I’ve to type the ISBN in!
ChromeOS port forwarding
If you want to access a dev server running inside Crostini, inside ChromeOS, and you want to access it from another device such as a cell phone, you need to enable port forwarding.
Conclusion
This was a fun exercise to build something that is useful for me. Svelte is really nice, and if I need to build other such small web-apps, I will definitely reach for it. XState is fantastic! I wish there were good, ergonomic, statechart libraries for other languages. Having one for Python would have definitely helped at work2 recently.