For some years I have been tracking data on a SQLite database, often manually writing SQL statements to add or update the information contained in the file. After sometime I upgraded the interface to a tiny Python script that wrote data into the SQLite file, although I had to open the file to specify the table and row values anytime I wanted to make a change - the script consisted of about 3 lines of code.
The setup worked fine (in fact, it still does) because most of the values didn't change often. Book information was the most likely to change, and in any case I haven't been very good at keeping track of this (so far I use my bullet journal).
This project's raison d'etre is primarily to practice implementing software development patterns and architectures, based largely on the Gang of Four design patterns and the architectural principles described in Architecture Patterns with Python. More generally, it is an attempt to:
- apply SOLID principles
- use GoF design patterns
- practice architecture design
- create a usable CLI app for myself
- learn how to use LLMs and agents
The expected result is an over-engineered but usable and extensible CLI app to do what I could do before with a simple script.
One-liner (requires uv)
uv tool install git+https://github.com/adnanvaldes/folio.gitRequires Python 3.13+ and uv:
git clone https://github.com/adnanvaldes/folio.git
cd folio
uv sync
source .venv/bin/activateThis installs the folio command and all dev dependencies into a local virtual environment.
uv run folio <domain> <action> [args]folio [--config <path>] [--db <path>] [--json] <domain> <action> [args]
Global flags:
| Flag | Description |
|---|---|
--config <path> |
Path to a YAML config file (overrides XDG default) |
--db <path> |
Path to the SQLite database file (overrides XDG default) |
--json |
Output results as JSON instead of a formatted table |
folio <domain> <action> [args]
Domains: travel, work, book, employment, address.
Actions: add, list, find, remove, replace.
Convention: Identity fields are positional args; optional fields use --flags. find uses optional flags for all filters.
folio travel add MEX USA 2024-01-01 --notes "vacation"
folio work add "Tolkien" "The Hobbit" --year 1937 --genre fantasy
folio book add "Tolkien" "The Hobbit" --format print --isbn 9780547928227
folio address add "123 Main" Springfield US 12345 2020-01-01 --province IL
folio employment add "Acme" 2023-01-01 --supervisor "Boss" --address "123 Main" --phone "555-0100"
folio travel find --origin MEX
folio work find --author "Tolkien"
folio travel replace MEX USA 2024-01-01 --new-destination CAN
folio work replace "Tolkien" "The Hobbit" --year 1937 --genre "Fantasy" --is-read yes
folio travel list
folio book list --json
Replace falls back to an interactive prompt when no change flags are given.
The first two models are Work and Book. These two are related, in that a Book is a concrete version of a Work; the Work, on the other hand, is the product of a literary effort.
This distinction is used to connect multiple Books (such as those in different formats or editions) to a single, unifying Work. The use-case for this is that I often have both digital and physical copies of a book (and sometimes audiobooks, too); however, when thinking about whether a book was been read or not we often refer to the work - the specific instance of that work is less important in conversation (unless you are an academic, for example, and need bibliographic data).
Because of this, a Work is defined as a title, author with optional year and genre information. Editions of that work may be published at different times, but there is (usually) only a single year that an author finished a work. Likewise, a work encodes is_read as a boolean: in most cases, I would consider a book "read" regardless of which edition or format I read it it.
On the other hand, a Book is defined by a Work, page count, format, and ISBN. In theory it should be possible to simply use ISBN to specify the book, since those are distinct numbers assigned to each edition or a book. However, the domain model of this application allows for None (or null, I suppose) ISBN values, and compares books based on their Work, the Format, and the ISBN. This is done primarily as a convenience - I often don't actually know what the ISBN is, or digital management applications, such as Calibre, often grab an ISBN for a different edition. Accurate ISBNs are far less important to me than the record of a Work. I could have simply defined a book as an object that contains a Work and a Format.
The presence of a Book indicates ownership of that edition; this is to manage books I have borrowed but do not own myself.
| Title | Author | Year | Genre | is_read |
|---|---|---|---|---|
| Hyperion | Dan Simmons | 1989 | Scifi | True |
| The Histories | Herodotus | -430 | History | True |
Each row represents a literary Work, which contains :
titleof the workauthor(if multiple or unknown, then simply the colloquial use, since this is ultimately designed to be human-readable. Simply put, if I describe a work to someone, the author that I mention in conversation is the one that goes in this column).yearthe book was written. Negative numbers used for BCE datesgenreas the primary genre when described to someone else. For instance, Hyperion is described as "SciFi, Fantasy, Space Opera", etc. These coalesce into "SciFi".is_readdefines whether I have read the work
| Work | Pages | Duration (min) | Format | ISBN | Narrator |
|---|---|---|---|---|---|
| Hyperion - Dan Simmons | 482 | 9780385249492 | |||
| The Histories - Herodotus | 784 | Ebook | 9780140449082 |
Each row represents a Book, such that:
workis the work the book containspagesis the page number of this edition (required for Print/Ebook formats)duration_minutesis the runtime in minutes (required for Audio format)formatis one of"Audio","Print", or"Ebook"isbnis the ISBN-13 of the editionnarratoris the narrator (relevant for Audio format)
I started tracking my travel a few years ago, when I was preparing a citizenship application. The requirement was to calculate the total time present in the country, and every exit delayed the process. As a result, I began to simply keep a record of international travel in the following format:
| Origin | Destination | Date | Notes |
|---|---|---|---|
| CAN | USA | 1900-01-01 | Entered via Houston |
| USA | MEX | 1900-02-25 | Entered via Mexico City, vacation |
| MEX | CAN | 1901-11-01 |
Each row represents a unit of travel across an international border where date is the date of entry into a new country. While it is possible to keep track of exit dates, that much granularity seems unnecessary for my purposes.
Country codes are written using ISO 3166-1 alpha-3, dates with ISO 8601, and notes are simply text fields.
There are a few - but often critical - situations where having a detailed history of addresses is necessary. Just like with travel, government-related services can require this kind of detail (often to the previous 5-year period). Nonetheless, I find it useful to simply keep a record with basic fields, such as start, end, address, etc.
| Start | End | Street Address | Province | Country | Postal Code |
|---|---|---|---|---|---|
| 1900-01-1 | 1902-01-25 | Apt 1 - 123 Street Name | Province Name | CAN | V0V V0V |
| 1902-01-26 | 2025-01-01 | 456 Road Name | MEX | 10000 |
Each row represents a primary address, such that:
startis the ISO 8601 date I started living thereendis the ISO 8601 I left (or the current date if I still live there)street addressis the street address of the place, including apartment number if requiredprovinceis the sub-division of a nation into state, province, or what have you. Some countries don't use it, so it can be blankcountryis the ISO 3166-1 alpha-3 country codepostal codeis the postal code of the location.
Due to variability in addresses between countries, most of these fields are simply text fields with no validation whatsoever.
Much like addresses, I keep track of places I've worked. In the data I include relevant phone numbers, addresses, and employment periods. The Employment data includes the following fields:
| Start | End | Company | Supervisor | Address | Phone number |
|---|---|---|---|---|---|
| 1900-01-01 | 1902-01-01 | ABC Company | Jane Smith | Unit 1 - 123 Street Name, Country, Postal Code | 123456789 |
| 1900-01-01 | 2025-01-01 | DEF Organization | John Doe | 456 Address, Province, Country, Postal code | 987654321 |
Each row represents a period of employment where:
startis the ISO 8601 date when employment beganendis the ISO 8601 date when employment ended (or the current date if still employed there)companyis the name of the organizationsupervisoris the last supervisor I hadaddressis the address of the companyphone numberis the company official phone number. Does not include country codes.
Note that address here does not map to the Addresses object. The former represents places I have lived and care to track, whereas address here is simply the official business address.