From 9cd5959dac23162dc38b5fb984bf1f280b4dc2e6 Mon Sep 17 00:00:00 2001 From: Marcus Kazmierczak Date: Sun, 17 May 2026 12:29:43 -0700 Subject: [PATCH 01/15] Init commit of desktop app --- .gitignore | 21 + AGENTS.md | 256 ---------- CLAUDE.md | 141 ++++++ electron/db/books.ts | 128 +++++ electron/db/client.ts | 60 +++ electron/db/lists.ts | 228 +++++++++ electron/db/migrate.ts | 41 ++ electron/db/reports.ts | 76 +++ electron/ipc.ts | 34 ++ electron/main.ts | 52 ++ electron/preload.ts | 27 + eslint.config.js | 29 ++ index.html | 12 + package.json | 67 +++ public/favicon.svg | 1 + public/icons.svg | 24 + pyproject.toml | 73 --- readme.md | 463 +++-------------- shared/types.ts | 132 +++++ src/App.tsx | 73 +++ src/features/add/AddBookReviewForm.tsx | 203 ++++++++ src/features/books/BooksByYearView.tsx | 121 +++++ src/features/lists/ListsView.tsx | 368 ++++++++++++++ src/features/reports/ReportsView.tsx | 201 ++++++++ src/index.css | 597 ++++++++++++++++++++++ src/lib/api.ts | 36 ++ src/libro/actions/db.py | 88 ---- src/libro/actions/importer.py | 276 ----------- src/libro/actions/lists.py | 564 --------------------- src/libro/actions/modify.py | 654 ------------------------- src/libro/actions/report.py | 136 ----- src/libro/actions/show.py | 479 ------------------ src/libro/config.py | 220 --------- src/libro/main.py | 136 ----- src/libro/models.py | 449 ----------------- src/libro/py.typed | 0 src/main.tsx | 10 + src/vite-env.d.ts | 9 + tests/__init__.py | 1 - tests/conftest.py | 91 ---- tests/test_actions.py | 325 ------------ tests/test_cli.py | 247 ---------- tests/test_models.py | 206 -------- tsconfig.app.json | 20 + tsconfig.electron.json | 19 + tsconfig.json | 7 + tsconfig.node.json | 19 + tsup.config.ts | 19 + vite.config.ts | 7 + 49 files changed, 2854 insertions(+), 4592 deletions(-) delete mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 electron/db/books.ts create mode 100644 electron/db/client.ts create mode 100644 electron/db/lists.ts create mode 100644 electron/db/migrate.ts create mode 100644 electron/db/reports.ts create mode 100644 electron/ipc.ts create mode 100644 electron/main.ts create mode 100644 electron/preload.ts create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 public/icons.svg delete mode 100644 pyproject.toml create mode 100644 shared/types.ts create mode 100644 src/App.tsx create mode 100644 src/features/add/AddBookReviewForm.tsx create mode 100644 src/features/books/BooksByYearView.tsx create mode 100644 src/features/lists/ListsView.tsx create mode 100644 src/features/reports/ReportsView.tsx create mode 100644 src/index.css create mode 100644 src/lib/api.ts delete mode 100644 src/libro/actions/db.py delete mode 100644 src/libro/actions/importer.py delete mode 100644 src/libro/actions/lists.py delete mode 100644 src/libro/actions/modify.py delete mode 100644 src/libro/actions/report.py delete mode 100644 src/libro/actions/show.py delete mode 100644 src/libro/config.py delete mode 100644 src/libro/main.py delete mode 100644 src/libro/models.py delete mode 100644 src/libro/py.typed create mode 100644 src/main.tsx create mode 100644 src/vite-env.d.ts delete mode 100644 tests/__init__.py delete mode 100644 tests/conftest.py delete mode 100644 tests/test_actions.py delete mode 100644 tests/test_cli.py delete mode 100644 tests/test_models.py create mode 100644 tsconfig.app.json create mode 100644 tsconfig.electron.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 tsup.config.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index 492790f..165e318 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,28 @@ data/ libro-test.db libro.db libro.bak +node_modules/ +dist/ +dist-electron/ .ruff_cache +package-lock.json uv.lock .venv/ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +.DS_Store diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index de09b93..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,256 +0,0 @@ -# AGENTS.md - -This file provides guidance to LLMs working with code in this repository. - -## Project Overview - -Libro is a terminal-based reading tracker written in Python that stores data in a local SQLite database. It is a pure CLI tool, interactive input uses `prompt_toolkit` for field prompts with validation and autocomplete. Output is formatted with `rich` Python library. - -## Development Commands - -### Running - -```bash -# Run the application -uv run libro # Show books read this year -uv run libro add # Add a book + review -uv run libro report # Same as default -uv run libro --db ./libro.db report # Use specific database file -``` - -### Using Justfile - -```bash -just run # Run the app -just test # Run pytest tests -just lint # Check with ruff -just lint-fix # Fix lint issues -just type-check # Run mypy -just ci # Run all checks (lint, type-check, test) -just install # Install dependencies with uv sync -just dev-install # Install dev dependencies -just build # Build the package -``` - -### Testing and Quality - -```bash -uv run python -m pytest tests/ -v -uv run python -m ruff check src/libro/ -uv run python -m mypy --package libro -``` - -## Code Architecture - -### Project Structure - -``` -libro/ -├── src/libro/ -│ ├── main.py # Entry point, command routing via match/case -│ ├── config.py # Argument parsing and database location logic -│ ├── models.py # Dataclasses: Book, Review, BookReview, ReadingList, ReadingListBook -│ └── actions/ -│ ├── db.py # Database init and migration -│ ├── show.py # Display functions (rich tables) -│ ├── report.py # Report views (author stats, yearly charts) -│ ├── modify.py # Add/edit books and reviews (prompt_toolkit input) -│ ├── lists.py # Reading list management -│ └── importer.py # CSV import (Goodreads format and generic CSV) -└── tests/ -``` - -### Key Technologies - -- **CLI Parsing**: argparse with subparsers -- **Interactive Input**: prompt_toolkit (PromptSession with validators and completers) -- **Terminal Output**: rich (tables, progress bars, styled text) -- **Database**: SQLite3 (stdlib) -- **Package Manager**: uv - -## Data Model - -### Books vs Reviews - -The application separates **books** from **reviews** to support: -- Books without reviews (unread books on a reading list) -- Multiple reviews per book (re-reads) - -**Tables:** -1. `books` - Book metadata (title, author, pub_year, pages, genre) -2. `reviews` - Reading records (book_id, date_read, rating, review) -3. `reading_lists` - Curated book collections -4. `reading_list_books` - Many-to-many join table (with priority and added_date) - -**Models** (all dataclasses in `models.py`): -- `Book` - Single book entity with `insert()`, `get_by_id()`, `find_by_title_author()` -- `Review` - Single review entity with `insert()` -- `BookReview` - Combined book + review, most commonly used for display; has `get_by_id()` -- `ReadingList` - List entity with full CRUD methods -- `ReadingListBook` - Association with `get_books_in_list()`, `get_list_stats()` - -### Database Schema - -```sql -CREATE TABLE books ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - author TEXT NOT NULL, - pub_year INTEGER, - pages INTEGER, - genre TEXT -); - -CREATE TABLE reviews ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - book_id INTEGER, - date_read DATE, - rating INTEGER, - review TEXT, - FOREIGN KEY(book_id) REFERENCES books(id) -); - -CREATE TABLE reading_lists ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - description TEXT, - created_date DATE DEFAULT CURRENT_DATE -); - -CREATE TABLE reading_list_books ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - list_id INTEGER NOT NULL, - book_id INTEGER NOT NULL, - added_date DATE DEFAULT CURRENT_DATE, - priority INTEGER DEFAULT 0, - FOREIGN KEY(list_id) REFERENCES reading_lists(id) ON DELETE CASCADE, - FOREIGN KEY(book_id) REFERENCES books(id) ON DELETE CASCADE, - UNIQUE(list_id, book_id) -); -``` - -## Application Flow - -### Entry Point - -`main.py` calls `init_args()` from `config.py`, connects to the database, runs `migrate_db()`, then routes to the correct action via `match args["command"]`. - -### Database Location Priority - -1. `--db` command-line flag -2. `libro.db` in current directory -3. `LIBRO_DB` environment variable -4. Platform-specific data directory (via `appdirs`): - - Linux: `~/.local/share/Libro/mkaz/libro.db` - - macOS: `~/Library/Application Support/Libro/mkaz/libro.db` - - Windows: `%APPDATA%\mkaz\Libro\libro.db` - -## CLI Commands - -```bash -# Default - show books read this year -libro -libro report - -# Year chart of books read -libro report --chart - -# Author stats (all authors with counts) -libro report --author -# Books by specific author -libro report --author "Stephen King" - -# Show book/review detail by review ID -libro report - -# Filter by year -libro report --year 2024 - -# Add book + review (interactive prompts) -libro add - -# Book management -libro book # Show recent books -libro book # Show specific book -libro book add # Add book only (no review) -libro book edit # Edit book details -libro book --author "King" # Filter by author -libro book --title "Found" # Filter by title -libro book --year 2024 # Filter by year - -# Review management -libro review # Show recent reviews -libro review # Show review detail -libro review add # Add review to existing book -libro review edit # Edit review -libro review --author "King" # Filter by author -libro review --year 2024 # Filter by year - -# Reading list management -libro list create "Name" --description "..." -libro list show # All lists -libro list show # Specific list -libro list add [book_ids...] # Add books to list (interactive if no IDs) -libro list remove -libro list stats # All list stats -libro list stats # Specific list stats -libro list edit --name "New Name" -libro list delete -libro list import --name "List Name" - -# Import from Goodreads CSV -libro import -``` - -## Important Patterns - -### Interactive Input (modify.py) - -`add_book_review()`, `add_book()`, `add_review()`, `edit_book()`, `edit_review()` all use `prompt_toolkit.PromptSession` with: -- **Validators**: `NonEmptyValidator`, `IntValidator`, `RatingValidator` (1-5), `DateValidator` (YYYY-MM-DD) -- **Completers**: `AuthorCompleter` (sorted by frequency), `GenreCompleter` -- Tab completion works in any field with a completer attached - -### Date Handling - -Dates are stored as ISO 8601 strings. `models.py` registers a SQLite adapter/converter: - -```python -sqlite3.register_adapter(date, adapt_date_iso) # date -> "YYYY-MM-DD" -sqlite3.register_converter("date", convert_date) # bytes -> date -``` - -The connection must be opened with `detect_types=sqlite3.PARSE_DECLTYPES` for converters to fire, but the current code uses `db.row_factory = sqlite3.Row` without `detect_types`. Date fields come back as strings; convert with `date.fromisoformat()` if needed. - -### Adding a New Action - -1. Add argument parsing to `config.py` (argparse subparser or flag) -2. Create or update a function in the appropriate `actions/` file -3. Add a case in `main.py`'s `match args["command"]` block -4. Use `rich.Table` or `rich.console` for output; use `PromptSession` for interactive input - -### Adding a New Store Query - -SQL lives directly in the model methods (`models.py`) and action files. There is no separate store layer. Keep SELECT queries in model classmethods when returning typed objects; put display-only queries in `show.py` or `report.py`. - -## Troubleshooting - -### Duplicate Book on Import - -`importer.py` calls `Book.find_by_title_author()` before inserting. If a book already exists, it skips the insert and adds a review to the existing book. - -### Date Shows Wrong Value - -Check that the date string is valid ISO format (`YYYY-MM-DD`). The `DateValidator` in `modify.py` enforces this on input. For database reads without `detect_types`, call `date.fromisoformat(row["date_read"])` explicitly. - -### Reading List Migration - -Old databases without reading list tables are handled by `migrate_db()` in `actions/db.py`, which runs on every startup and creates missing tables idempotently. - -## Code Style - -- Python 3.10+ (`match/case`, `list[str]` type hints) -- Dataclasses for models -- Type hints throughout; mypy enforced via `just type-check` -- ruff for linting and formatting -- No global state; `db` connection passed explicitly to every function diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0f88914 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md + +This file provides guidance to LLMs working with code in this repository. + +## Project Overview + +Libro is a desktop reading tracker built with Electron + React + Vite. It uses a local SQLite database. + +## Development Commands + +```bash +npm run dev # Start Vite renderer + Electron (hot-reload) +npm run build # Typecheck, build renderer, build Electron main/preload +npm run package # Full build + electron-builder → .dmg +npm run typecheck # tsc on both renderer and Electron tsconfigs +npm run lint # eslint +``` + +## Code Architecture + +### Project Structure + +``` +libro/ +├── electron/ +│ ├── main.ts # Electron main process, BrowserWindow setup +│ ├── preload.ts # Context bridge — exposes LibroApi to renderer +│ ├── ipc.ts # IPC handler registration +│ └── db/ +│ ├── client.ts # Database singleton, path resolution, migration call +│ ├── migrate.ts # Schema migrations +│ ├── books.ts # Book/review queries +│ ├── reports.ts # Year counts, author counts, review filters +│ └── lists.ts # Reading list CRUD +├── shared/ +│ └── types.ts # Shared TypeScript interfaces (LibroApi, all input/output types) +└── src/ # React renderer + ├── App.tsx # Shell with nav (Books, Reports, Lists, Add Book) + ├── lib/api.ts # Typed wrapper around window.libro (the context bridge) + └── features/ + ├── books/BooksByYearView.tsx + ├── reports/ReportsView.tsx + ├── lists/ListsView.tsx + └── add/AddBookReviewForm.tsx +``` + +### Key Technologies + +- **Framework**: Electron 33 + React 19 + Vite 5 +- **Database**: better-sqlite3 (synchronous, runs in main process only) +- **Styling**: Halfmoon CSS +- **Build**: tsup for Electron main/preload, Vite for renderer +- **Packaging**: electron-builder (targets macOS DMG) +- **Package Manager**: npm + +## Data Model + +### Books vs Reviews + +The application separates **books** from **reviews** to support: +- Books without reviews (unread books on a reading list) +- Multiple reviews per book (re-reads) + +**Tables:** +1. `books` - Book metadata (title, author, pub_year, pages, genre) +2. `reviews` - Reading records (book_id, date_read, rating, review) +3. `reading_lists` - Curated book collections +4. `reading_list_books` - Many-to-many join table (with priority and added_date) + +**Database Schema:** + +```sql +CREATE TABLE books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + author TEXT NOT NULL, + pub_year INTEGER, + pages INTEGER, + genre TEXT +); + +CREATE TABLE reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + book_id INTEGER, + date_read DATE, + rating INTEGER, + review TEXT, + FOREIGN KEY(book_id) REFERENCES books(id) +); + +CREATE TABLE reading_lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_date DATE DEFAULT CURRENT_DATE +); + +CREATE TABLE reading_list_books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + list_id INTEGER NOT NULL, + book_id INTEGER NOT NULL, + added_date DATE DEFAULT CURRENT_DATE, + priority INTEGER DEFAULT 0, + FOREIGN KEY(list_id) REFERENCES reading_lists(id) ON DELETE CASCADE, + FOREIGN KEY(book_id) REFERENCES books(id) ON DELETE CASCADE, + UNIQUE(list_id, book_id) +); +``` + +## IPC Architecture + +All database access happens in the main process. The renderer never touches the database directly. + +- `preload.ts` exposes a typed `LibroApi` object on `window.libro` via `contextBridge` +- `src/lib/api.ts` re-exports this as `api` for use in React components +- `ipc.ts` registers `ipcMain.handle` handlers that call into `electron/db/` functions + +IPC channel naming: `namespace:verb-noun` (e.g. `books:add-book-review`, `lists:get-by-id`). + +## Database Location + +Priority order: + +1. `libro.db` in cwd (if it exists) +2. `LIBRO_DB` environment variable +3. `{appData}/Libro/mkaz/libro.db` + +## Adding a New Feature + +1. Add types to `shared/types.ts` (input, output, and the `LibroApi` interface entry) +2. Add the query function in the appropriate `electron/db/*.ts` file +3. Register an IPC handler in `electron/ipc.ts` +4. Wire it up in `preload.ts` and `src/lib/api.ts` +5. Build the React component in `src/features/` + +## Code Style + +- Strict TypeScript; all types defined in `shared/types.ts` +- Two separate tsconfig targets: `tsconfig.app.json` (renderer) and `tsconfig.electron.json` (main/preload) +- No ORM; raw SQL via better-sqlite3 synchronous API +- eslint with react-hooks and react-refresh plugins diff --git a/electron/db/books.ts b/electron/db/books.ts new file mode 100644 index 0000000..9d0687e --- /dev/null +++ b/electron/db/books.ts @@ -0,0 +1,128 @@ +import type Database from 'better-sqlite3' + +import type { + AddBookReviewInput, + AddBookReviewResult, + SearchBookResult, +} from '../../shared/types' + +function normalizeOptionalText(value: string | null): string | null { + if (value === null) { + return null + } + + const normalized = value.trim() + return normalized.length > 0 ? normalized : null +} + +export function addBookReview( + db: Database.Database, + input: AddBookReviewInput, +): AddBookReviewResult { + const title = input.title.trim() + const author = input.author.trim() + const genre = normalizeOptionalText(input.genre)?.toLowerCase() ?? null + const reviewText = normalizeOptionalText(input.review) + + if (!title || !author) { + throw new Error('Title and author are required.') + } + + const existingBook = db + .prepare( + `SELECT id + FROM books + WHERE LOWER(title) = LOWER(?) AND LOWER(author) = LOWER(?)`, + ) + .get(title, author) as { id: number } | undefined + + const insertBook = db.prepare( + `INSERT INTO books (title, author, pub_year, pages, genre) + VALUES (?, ?, ?, ?, ?)`, + ) + const insertReview = db.prepare( + `INSERT INTO reviews (book_id, date_read, rating, review) + VALUES (?, ?, ?, ?)`, + ) + + const transaction = db.transaction(() => { + let bookId = existingBook?.id + + if (bookId === undefined) { + const bookResult = insertBook.run( + title, + author, + input.pubYear, + input.pages, + genre, + ) + bookId = Number(bookResult.lastInsertRowid) + } + + const reviewResult = insertReview.run( + bookId, + input.dateRead, + input.rating, + reviewText, + ) + + return { + bookId, + reviewId: Number(reviewResult.lastInsertRowid), + usedExistingBook: existingBook !== undefined, + } + }) + + return transaction() +} + +export function searchBooks( + db: Database.Database, + term: string, + listId?: number, +): SearchBookResult[] { + const searchTerm = term.trim() + + if (!searchTerm) { + return db + .prepare( + `SELECT + b.id, + b.title, + b.author, + b.pub_year as pubYear, + b.pages, + b.genre, + CASE WHEN rlb.book_id IS NOT NULL THEN 1 ELSE 0 END as inList + FROM books b + LEFT JOIN reading_list_books rlb + ON rlb.book_id = b.id AND rlb.list_id = ? + ORDER BY b.id DESC + LIMIT 20`, + ) + .all(listId ?? -1) as SearchBookResult[] + } + + return db + .prepare( + `SELECT + b.id, + b.title, + b.author, + b.pub_year as pubYear, + b.pages, + b.genre, + CASE WHEN rlb.book_id IS NOT NULL THEN 1 ELSE 0 END as inList + FROM books b + LEFT JOIN reading_list_books rlb + ON rlb.book_id = b.id AND rlb.list_id = ? + WHERE LOWER(b.title) LIKE LOWER(?) OR LOWER(b.author) LIKE LOWER(?) + ORDER BY LOWER(b.title), LOWER(b.author) + LIMIT 25`, + ) + .all(listId ?? -1, `%${searchTerm}%`, `%${searchTerm}%`) + .map((row) => ({ + ...(row as Omit), + inList: Boolean((row as { inList: number }).inList), + })) +} diff --git a/electron/db/client.ts b/electron/db/client.ts new file mode 100644 index 0000000..4dd8a23 --- /dev/null +++ b/electron/db/client.ts @@ -0,0 +1,60 @@ +import Database from 'better-sqlite3' +import { app } from 'electron' +import { existsSync, mkdirSync } from 'node:fs' +import path from 'node:path' + +import { migrateDatabase } from './migrate' + +let dbInstance: Database.Database | null = null +let dbPathCache: string | null = null + +function resolveDbPath(): string { + const currentDirDb = path.resolve(process.cwd(), 'libro.db') + if (existsSync(currentDirDb)) { + return currentDirDb + } + + const envDb = process.env.LIBRO_DB + if (envDb) { + return envDb + } + + return path.join(app.getPath('appData'), 'Libro', 'mkaz', 'libro.db') +} + +export function getDbPath(): string { + if (dbPathCache === null) { + dbPathCache = resolveDbPath() + } + + return dbPathCache +} + +export function getDatabase(): Database.Database { + if (dbInstance !== null) { + return dbInstance + } + + const dbPath = getDbPath() + mkdirSync(path.dirname(dbPath), { recursive: true }) + + const db = new Database(dbPath) + db.pragma('foreign_keys = ON') + migrateDatabase(db) + + dbInstance = db + return db +} + +export function getDbInfo() { + const dbPath = getDbPath() + return { + path: dbPath, + exists: existsSync(dbPath), + } +} + +export function closeDatabase(): void { + dbInstance?.close() + dbInstance = null +} diff --git a/electron/db/lists.ts b/electron/db/lists.ts new file mode 100644 index 0000000..334c93f --- /dev/null +++ b/electron/db/lists.ts @@ -0,0 +1,228 @@ +import type Database from 'better-sqlite3' + +import type { + AddBooksToListInput, + AddBooksToListResult, + CreateListInput, + ReadingListBookRow, + ReadingListDetail, + ReadingListSummary, +} from '../../shared/types' + +type ReadingListRow = { + id: number + name: string + description: string | null + createdDate: string | null +} + +type ReadingListBookStats = { + totalBooks: number + booksRead: number + booksUnread: number + completionPercentage: number +} + +function hydrateStats( + totalBooks: number, + booksRead: number, +): ReadingListBookStats { + const booksUnread = totalBooks - booksRead + return { + totalBooks, + booksRead, + booksUnread, + completionPercentage: totalBooks > 0 ? (booksRead / totalBooks) * 100 : 0, + } +} + +export function getAllLists(db: Database.Database): ReadingListSummary[] { + const rows = db + .prepare( + `SELECT + rl.id, + rl.name, + rl.description, + rl.created_date as createdDate, + COUNT(DISTINCT rlb.book_id) as totalBooks, + COUNT(DISTINCT CASE WHEN EXISTS ( + SELECT 1 FROM reviews r WHERE r.book_id = rlb.book_id + ) THEN rlb.book_id END) as booksRead + FROM reading_lists rl + LEFT JOIN reading_list_books rlb ON rl.id = rlb.list_id + GROUP BY rl.id + ORDER BY rl.created_date DESC, LOWER(rl.name) ASC`, + ) + .all() as Array + + return rows.map((row) => { + const stats = hydrateStats(row.totalBooks, row.booksRead) + return { + id: row.id, + name: row.name, + description: row.description, + createdDate: row.createdDate, + ...stats, + } + }) +} + +export function getListById( + db: Database.Database, + listId: number, +): ReadingListDetail { + const list = db + .prepare( + `SELECT id, name, description, created_date as createdDate + FROM reading_lists + WHERE id = ?`, + ) + .get(listId) as ReadingListRow | undefined + + if (!list) { + throw new Error(`Reading list with ID ${listId} not found.`) + } + + const books = db + .prepare( + `SELECT + b.id as bookId, + b.title, + b.author, + b.genre, + b.pub_year as pubYear, + b.pages, + rlb.added_date as addedDate, + rlb.priority, + CASE WHEN EXISTS ( + SELECT 1 FROM reviews r WHERE r.book_id = b.id + ) THEN 1 ELSE 0 END as isRead, + ( + SELECT r.date_read + FROM reviews r + WHERE r.book_id = b.id + ORDER BY COALESCE(r.date_read, '') DESC, r.id DESC + LIMIT 1 + ) as dateRead, + ( + SELECT r.rating + FROM reviews r + WHERE r.book_id = b.id + ORDER BY COALESCE(r.date_read, '') DESC, r.id DESC + LIMIT 1 + ) as rating + FROM reading_list_books rlb + JOIN books b ON rlb.book_id = b.id + WHERE rlb.list_id = ? + ORDER BY isRead ASC, rlb.priority DESC, rlb.added_date ASC, LOWER(b.title) ASC`, + ) + .all(listId) + .map((row) => ({ + ...(row as Omit), + isRead: Boolean((row as { isRead: number }).isRead), + })) as ReadingListBookRow[] + + const booksRead = books.filter((book) => book.isRead).length + const stats = hydrateStats(books.length, booksRead) + + return { + id: list.id, + name: list.name, + description: list.description, + createdDate: list.createdDate, + books, + stats, + } +} + +export function createList( + db: Database.Database, + input: CreateListInput, +): ReadingListSummary { + const name = input.name.trim() + const description = input.description?.trim() || null + + if (!name) { + throw new Error('List name is required.') + } + + const existing = db + .prepare('SELECT id FROM reading_lists WHERE name = ?') + .get(name) as { id: number } | undefined + + if (existing) { + throw new Error(`A reading list named "${name}" already exists.`) + } + + const result = db + .prepare( + `INSERT INTO reading_lists (name, description, created_date) + VALUES (?, ?, CURRENT_DATE)`, + ) + .run(name, description) + + const createdId = Number(result.lastInsertRowid) + return getAllLists(db).find((list) => list.id === createdId) ?? { + id: createdId, + name, + description, + createdDate: null, + ...hydrateStats(0, 0), + } +} + +export function addBooksToList( + db: Database.Database, + input: AddBooksToListInput, +): AddBooksToListResult { + const list = db + .prepare('SELECT id FROM reading_lists WHERE id = ?') + .get(input.listId) as { id: number } | undefined + + if (!list) { + throw new Error(`Reading list with ID ${input.listId} not found.`) + } + + const existingBookIds = new Set( + ( + db + .prepare( + `SELECT id + FROM books + WHERE id IN (${input.bookIds.map(() => '?').join(', ') || 'NULL'})`, + ) + .all(...input.bookIds) as Array<{ id: number }> + ).map((row) => row.id), + ) + + const insert = db.prepare( + `INSERT OR IGNORE INTO reading_list_books (list_id, book_id, added_date, priority) + VALUES (?, ?, CURRENT_DATE, 0)`, + ) + + let addedCount = 0 + const skippedBookIds: number[] = [] + + const transaction = db.transaction(() => { + for (const bookId of input.bookIds) { + if (!existingBookIds.has(bookId)) { + skippedBookIds.push(bookId) + continue + } + + const result = insert.run(input.listId, bookId) + if (result.changes === 1) { + addedCount += 1 + } else { + skippedBookIds.push(bookId) + } + } + }) + + transaction() + + return { + addedCount, + skippedBookIds, + } +} diff --git a/electron/db/migrate.ts b/electron/db/migrate.ts new file mode 100644 index 0000000..a84a857 --- /dev/null +++ b/electron/db/migrate.ts @@ -0,0 +1,41 @@ +import type Database from 'better-sqlite3' + +export function migrateDatabase(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + author TEXT NOT NULL, + pub_year INTEGER, + pages INTEGER, + genre TEXT + ); + + CREATE TABLE IF NOT EXISTS reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + book_id INTEGER, + date_read DATE, + rating INTEGER, + review TEXT, + FOREIGN KEY (book_id) REFERENCES books(id) + ); + + CREATE TABLE IF NOT EXISTS reading_lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_date DATE DEFAULT CURRENT_DATE + ); + + CREATE TABLE IF NOT EXISTS reading_list_books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + list_id INTEGER NOT NULL, + book_id INTEGER NOT NULL, + added_date DATE DEFAULT CURRENT_DATE, + priority INTEGER DEFAULT 0, + FOREIGN KEY (list_id) REFERENCES reading_lists(id) ON DELETE CASCADE, + FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE, + UNIQUE(list_id, book_id) + ); + `) +} diff --git a/electron/db/reports.ts b/electron/db/reports.ts new file mode 100644 index 0000000..c45b72e --- /dev/null +++ b/electron/db/reports.ts @@ -0,0 +1,76 @@ +import type Database from 'better-sqlite3' + +import type { AuthorCount, ReviewFilters, ReviewRow, YearCount } from '../../shared/types' + +export function getYearCounts(db: Database.Database): YearCount[] { + return db + .prepare( + `SELECT strftime('%Y', date_read) as year, COUNT(*) as count + FROM reviews + WHERE date_read IS NOT NULL + GROUP BY year + ORDER BY year`, + ) + .all() as YearCount[] +} + +export function getAuthorCounts( + db: Database.Database, + minimumBooks = 3, +): AuthorCount[] { + return db + .prepare( + `SELECT b.author as author, COUNT(*) as count + FROM reviews r + JOIN books b ON r.book_id = b.id + WHERE r.date_read IS NOT NULL + GROUP BY b.author + HAVING count >= ? + ORDER BY count DESC, LOWER(b.author) ASC`, + ) + .all(minimumBooks) as AuthorCount[] +} + +export function getReviews( + db: Database.Database, + filters: ReviewFilters = {}, +): ReviewRow[] { + let query = ` + SELECT + r.id as reviewId, + b.id as bookId, + b.title, + b.author, + b.genre, + r.rating, + r.date_read as dateRead + FROM reviews r + JOIN books b ON r.book_id = b.id + ` + + const whereClauses: string[] = [] + const params: Array = [] + + if (filters.year !== undefined) { + whereClauses.push(`strftime('%Y', r.date_read) = ?`) + params.push(String(filters.year)) + } + + if (filters.author !== undefined && filters.author.trim()) { + whereClauses.push('LOWER(b.author) LIKE LOWER(?)') + params.push(`%${filters.author.trim()}%`) + } + + if (filters.rating !== undefined) { + whereClauses.push('r.rating = ?') + params.push(filters.rating) + } + + if (whereClauses.length > 0) { + query += ` WHERE ${whereClauses.join(' AND ')}` + } + + query += ' ORDER BY r.date_read DESC, r.id DESC' + + return db.prepare(query).all(...params) as ReviewRow[] +} diff --git a/electron/ipc.ts b/electron/ipc.ts new file mode 100644 index 0000000..43abc74 --- /dev/null +++ b/electron/ipc.ts @@ -0,0 +1,34 @@ +import { ipcMain } from 'electron' + +import { getDatabase, getDbInfo } from './db/client' +import { addBookReview, searchBooks } from './db/books' +import { getAuthorCounts, getReviews, getYearCounts } from './db/reports' +import { addBooksToList, createList, getAllLists, getListById } from './db/lists' + +export function registerIpcHandlers(): void { + ipcMain.handle('app:get-db-info', () => getDbInfo()) + + ipcMain.handle('books:add-book-review', (_, input) => + addBookReview(getDatabase(), input), + ) + ipcMain.handle('books:search', (_, term: string, listId?: number) => + searchBooks(getDatabase(), term, listId), + ) + + ipcMain.handle('reports:get-year-counts', () => getYearCounts(getDatabase())) + ipcMain.handle('reports:get-author-counts', (_, minimumBooks?: number) => + getAuthorCounts(getDatabase(), minimumBooks), + ) + ipcMain.handle('reports:get-reviews', (_, filters) => + getReviews(getDatabase(), filters), + ) + + ipcMain.handle('lists:get-all', () => getAllLists(getDatabase())) + ipcMain.handle('lists:get-by-id', (_, listId: number) => + getListById(getDatabase(), listId), + ) + ipcMain.handle('lists:create', (_, input) => createList(getDatabase(), input)) + ipcMain.handle('lists:add-books', (_, input) => + addBooksToList(getDatabase(), input), + ) +} diff --git a/electron/main.ts b/electron/main.ts new file mode 100644 index 0000000..712aef5 --- /dev/null +++ b/electron/main.ts @@ -0,0 +1,52 @@ +import { app, BrowserWindow } from 'electron' +import path from 'node:path' + +import { closeDatabase, getDatabase } from './db/client' +import { registerIpcHandlers } from './ipc' + +const rendererDevUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://127.0.0.1:5173' + +function createWindow(): void { + const mainWindow = new BrowserWindow({ + width: 1440, + height: 940, + minWidth: 1100, + minHeight: 760, + backgroundColor: '#111827', + title: 'Libro Desktop', + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + nodeIntegration: false, + }, + }) + + if (app.isPackaged) { + mainWindow.loadFile(path.resolve(__dirname, '../dist/index.html')) + } else { + mainWindow.loadURL(rendererDevUrl) + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } +} + +app.whenReady().then(() => { + getDatabase() + registerIpcHandlers() + createWindow() + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow() + } + }) +}) + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } +}) + +app.on('before-quit', () => { + closeDatabase() +}) diff --git a/electron/preload.ts b/electron/preload.ts new file mode 100644 index 0000000..7b2e469 --- /dev/null +++ b/electron/preload.ts @@ -0,0 +1,27 @@ +import { contextBridge, ipcRenderer } from 'electron' + +import type { LibroApi } from '../shared/types' + +const api: LibroApi = { + app: { + getDbInfo: () => ipcRenderer.invoke('app:get-db-info'), + }, + books: { + addBookReview: (input) => ipcRenderer.invoke('books:add-book-review', input), + searchBooks: (term, listId) => ipcRenderer.invoke('books:search', term, listId), + }, + reports: { + getYearCounts: () => ipcRenderer.invoke('reports:get-year-counts'), + getAuthorCounts: (minimumBooks) => + ipcRenderer.invoke('reports:get-author-counts', minimumBooks), + getReviews: (filters) => ipcRenderer.invoke('reports:get-reviews', filters), + }, + lists: { + getAll: () => ipcRenderer.invoke('lists:get-all'), + getById: (listId) => ipcRenderer.invoke('lists:get-by-id', listId), + create: (input) => ipcRenderer.invoke('lists:create', input), + addBooks: (input) => ipcRenderer.invoke('lists:add-books', input), + }, +} + +contextBridge.exposeInMainWorld('libro', api) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..b50cdb7 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist', 'dist-electron']), + { + files: ['electron/**/*.ts'], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + globals: globals.node, + }, + }, + { + files: ['src/**/*.{ts,tsx}', 'shared/**/*.ts'], + extends: [ + js.configs.recommended, + ...tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..ddce5e7 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Libro Desktop + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..efb136d --- /dev/null +++ b/package.json @@ -0,0 +1,67 @@ +{ + "name": "libro-desktop", + "private": true, + "version": "0.1.0", + "description": "Desktop app for the Libro reading tracker", + "author": "mkaz", + "type": "module", + "main": "dist-electron/main.cjs", + "scripts": { + "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron:build\" \"npm:dev:electron\"", + "dev:renderer": "vite", + "dev:electron:build": "tsup --config tsup.config.ts --watch", + "dev:electron": "wait-on tcp:5173 dist-electron/main.cjs && electronmon dist-electron/main.cjs", + "build": "npm run typecheck && vite build && npm run build:electron", + "build:electron": "tsup --config tsup.config.ts", + "typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.electron.json --noEmit", + "lint": "eslint .", + "preview": "vite preview", + "package": "npm run build && electron-builder --mac", + "postinstall": "electron-builder install-app-deps" + }, + "dependencies": { + "better-sqlite3": "^11.8.1", + "halfmoon": "^2.0.2", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.2", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.1.2", + "electron": "^33.2.1", + "electron-builder": "^25.1.8", + "electronmon": "^2.0.3", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "tsup": "^8.3.5", + "typescript": "~5.6.3", + "typescript-eslint": "^8.18.1", + "vite": "^5.4.11", + "wait-on": "^8.0.1" + }, + "build": { + "appId": "com.mkaz.libro.desktop", + "productName": "Libro Desktop", + "files": [ + "dist/**/*", + "dist-electron/**/*", + "package.json" + ], + "asarUnpack": [ + "node_modules/better-sqlite3/**/*" + ], + "mac": { + "category": "public.app-category.productivity", + "target": [ + "dmg" + ] + } + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index dca9243..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,73 +0,0 @@ -[project] -name = "libro-book" -version = "0.6.3" -description = "A command-line tool to track books read" -authors = [ {name = "Marcus Kazmierczak", email = "marcus@mkaz.com"} ] -license = "MIT" -license-files = ["LICENSE"] -readme = "readme.md" -keywords = ["books", "reading", "library"] -classifiers = [ - "Development Status :: 4 - Beta", - "Environment :: Console", - "Intended Audience :: End Users/Desktop", - "Natural Language :: English", - "Programming Language :: Python :: 3", - "Operating System :: OS Independent", -] -requires-python = ">=3.10" -dependencies = [ - "appdirs>=1.4", - "prompt-toolkit>=3.0.52", - "rich>=13.3", -] - -[project.urls] -Documentation = "https://github.com/mkaz/libro" -Repository = "https://github.com/mkaz/libro" -Issues = "https://github.com/mkaz/libro/issues" - -[project.scripts] -libro = "libro.main:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/libro"] - - -[tool.ruff] -target-version = "py310" - -[tool.mypy] -python_version = "3.10" -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = false -disallow_incomplete_defs = false -check_untyped_defs = true -disallow_untyped_decorators = false -no_implicit_optional = true -warn_redundant_casts = true -warn_unused_ignores = true -warn_no_return = true -warn_unreachable = true -strict_equality = true -disallow_untyped_calls = false -namespace_packages = true -explicit_package_bases = true - -[[tool.mypy.overrides]] -module = "appdirs.*" -ignore_missing_imports = true - -[dependency-groups] -dev = [ - "build>=1.3.0", - "mypy>=1.17.1", - "pytest>=8.4.1", - "ruff>=0.12.10", - "twine>=6.1.0", -] diff --git a/readme.md b/readme.md index 9f879aa..7dbf7eb 100644 --- a/readme.md +++ b/readme.md @@ -1,392 +1,73 @@ -# Libro - -📚 Libro: A terminal-based tool to track your reading history, with your data stored locally in a SQLite database. - -## Core Concepts - -Libro separates **books** and **reviews** to give you flexibility in how you track your reading: - -- **Books**: Store information about the book itself (title, author, pages, genre, publication year) -- **Reviews**: Track your personal reading experience (date read, rating, review text) - -This separation allows you to: -- Add books to your database without having read them yet -- Add multiple reviews for the same book (re-reads) -- Maintain a clean library of books separate from your reading history - -## Interface - -**Command Line Interface:** -- `libro report` - Reading history table -- `libro report --author` - Author statistics (most read authors) -- `libro report --author "Name"` - Books/reviews by specific author -- `libro report --chart` - Yearly reading chart -- `libro report 123` - Book/review details for review id -- `libro add` - Add book + review -- `libro book` - Book management (show, add, edit) -- `libro review` - Review management (show, add, edit) -- `libro list` - Reading list management - -## Usage - -**Add books and review:**: `libro add` - - -### Book Management - -Add book only (no review): `libro book add` - -Show recent books: `libro book` (shows latest 20 books by default) - -Show specific book: `libro book 42` - -Edit book details only: `libro book edit 42` - -**Search and filter books:** - -Show books by author: `libro book --author "Stephen King"` - -Show books by title: `libro book --title "Foundation"` - -Show books published in specific year: `libro book --year 2024` - -All search options support partial matching, so `--author "King"` will find "Stephen King", "Tabitha King", etc. - - -### Review Management - -Show recent reviews: `libro review` (shows latest 20 reviews by default) - -Show specific review: `libro review 123` - -Add review to existing book: `libro review add 42` - -Edit review details only: `libro review edit 123` - -**Search and filter reviews:** - -Show reviews by author: `libro review --author "Stephen King"` (searches book authors) - -Show reviews by book title: `libro review --title "Foundation"` (searches book titles) - -Show reviews from specific year: `libro review --year 2024` (searches by date_read) - -All search options support partial matching, and year filtering uses the date the review was made, not the book's publication year. - -### Reading Lists - -Create a reading list: `libro list create "My Reading List" --description "Books to read"` - -Show all reading lists: `libro list show` - -Show specific list: `libro list show 1` - -Import books to a new list: `libro list import books.csv --name "Sci-Fi Classics" --description "Science fiction must-reads"` - -See: `libro --help` for more information. - -### Examples - -#### Report View - -Access the table view with `libro report`: - -``` -❯ libro report - Books Read in 2025 -┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┓ -┃ ID ┃ Title ┃ Author ┃ Rating ┃ Date Read ┃ -┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━┩ -│ Fiction │ │ │ │ │ -│ 1 │ Cujo │ Stephen King │ 3 │ Jan 05, 2025 │ -│ 585 │ The Midnight Library │ Matt Haig │ 5 │ Jan 13, 2025 │ -│ 587 │ The Maid │ Nita Prose │ 4 │ Jan 20, 2025 │ -│ 589 │ Into the Water │ Paula Hawkins │ 2 │ Feb 02, 2025 │ -│ 584 │ Salem's Lot │ Stephen King │ 3 │ Mar 12, 2025 │ -│ 595 │ The Thursday Murder Club │ Richard Osman │ 3 │ Mar 20, 2025 │ -│ 596 │ Remarkably Bright Creatures │ Shelby Van Pelt │ 5 │ Mar 27, 2025 │ -│ 598 │ Colorless Tsukuru Tazaki │ Haruki Murakami │ 3 │ Apr 09, 2025 │ -│ 599 │ Ten │ Gretchen McNeil │ 3 │ Apr 16, 2025 │ -│ │ │ │ │ │ -│ Nonfiction │ │ │ │ │ -│ 586 │ The Art Thief │ Michael Finkel │ 4 │ Jan 14, 2025 │ -│ 588 │ All the Pieces Matter │ Jonathan Abrams │ 3 │ Jan 27, 2025 │ -│ 590 │ Supercommunicators │ Charles Duhigg │ 4 │ Feb 04, 2025 │ -│ 593 │ Leonardo da Vinci │ Walter Isaacson │ 3 │ Mar 02, 2025 │ -│ 594 │ The Leap to Leader │ Adam Bryant │ 3 │ Mar 08, 2025 │ -│ 597 │ Team of Rivals │ Doris Kearns Goodwin │ 3 │ Apr 06, 2025 │ -└────────────┴──────────────────────────────┴──────────────────────┴────────┴──────────────┘ -``` - - -#### Books by Year Chart - -``` -❯ libro report --chart - - Books Read by Year - - Year Count Bar - ─────────────────────────────────────────────────────────────────── - 2013 3 ▄▄▄▄ - 2014 4 ▄▄▄▄▄▄ - 2015 11 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - 2016 30 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - 2017 21 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - 2018 27 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - 2019 29 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - 2020 27 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - 2021 28 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - 2022 27 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - 2023 32 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - 2024 30 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - 2025 17 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ -``` - -#### Author Report and Filtering - -Show author statistics (most read authors): +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) ``` -❯ libro report --author - - Most Read Authors - - Author Books Read - ────────────────────────────────── - Stephen King 15 - George R.R. Martin 5 - Timothy Zahn 4 - Grady Hendrix 4 - Andy Weir 4 - William Zinsser 3 - Roald Dahl 3 - Riley Sager 3 - Philip K. Dick 3 - Neil Gaiman 3 - Natalie D. Richards 3 - Lucy Foley 3 - Cory Doctorow 3 -``` - -Show books/reviews by specific author: -``` -❯ libro report --author "Stephen King" - - Books by Stephen King -┏━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━┓ -┃ ID ┃ Title ┃ Author ┃ Rating ┃ Date Read ┃ -┡━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━┩ -│ 1 │ Cujo │ Stephen King │ 3 │ Jan 05, 2025 │ -│ 584│ Salem's Lot │ Stephen King │ 3 │ Mar 12, 2025 │ -└────┴──────────────────────────────┴───────────────┴────────┴──────────────┘ -``` - -## Reading Lists - -Reading lists allow you to organize books into curated collections. You can create lists for different genres, themes, or reading goals. - -### Creating and Managing Lists - -Create a new reading list: -```bash -libro list create "2025 Reading Goals" --description "Books I want to read this year" -``` - -View all your reading lists: -```bash -❯ libro list show - - Reading Lists -┏━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓ -┃ ID ┃ Name ┃ Description ┃ Total Books ┃ Read ┃ Unread ┃ Progress ┃ -┡━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩ -│ 1 │ Sci-Fi Classics │ Science fiction must-reads │ 50 │ 12 │ 38 │ ██░░░░░░░░ 24.0% │ -│ 2 │ Horror Collection │ Spine-tingling tales │ 30 │ 8 │ 22 │ ███░░░░░░░ 26.7% │ -│ 3 │ Literary Classics │ Timeless masterpieces │ 45 │ 15 │ 30 │ ███░░░░░░░ 33.3% │ -└────┴────────────────────┴──────────────────────────────────┴─────────────┴──────┴────────┴──────────────────┘ - -Use 'libro list show ' to see books in a specific list -``` - -View books in a specific list: -```bash -❯ libro list show 1 - - 📚 Sci-Fi Classics - Science fiction must-reads -┏━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━┓ -┃ ID ┃ Status ┃ Title ┃ Author ┃ Genre ┃ Rating ┃ Date Read ┃ -┡━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━┩ -│ 42 │ 📖 │ Foundation │ Isaac Asimov │ science fiction │ — │ — │ -│ 43 │ 📖 │ Dune │ Frank Herbert │ science fiction │ — │ — │ -│ 44 │ ✅ │ The Left Hand of Darkness │ Ursula K. Le Guin │ science fiction │ 5 │ 2024-12-15 │ -│ 45 │ ✅ │ Neuromancer │ William Gibson │ science fiction │ 4 │ 2024-11-20 │ -└────┴────────┴──────────────────────────────────────────┴────────────────────────┴─────────────────┴────────┴────────────┘ - -📊 Progress: 12 read, 38 unread (24.0% complete) -``` - -### Adding Books to Lists - -Add a new book to an existing list: -```bash -libro list add 1 -``` - -This will prompt you to enter book details interactively. - -### Importing Books to Lists - -Import books from a CSV file and create a new list at the same time: -```bash -libro list import books.csv --name "Mystery Novels" --description "Page-turners and whodunits" -``` - -Import books to an existing list: -```bash -libro list import more-books.csv --id 1 -``` - -**CSV Format**: The CSV file should have the following columns in order: -- Title -- Author -- Publication Year (optional) -- Pages (optional) -- Genre (optional) - -Example CSV: -```csv -Title,Author,Publication Year,Pages,Genre -The Martian,Andy Weir,2011,369,science fiction -Klara and the Sun,Kazuo Ishiguro,2021,303,literary fiction -``` - -### List Management - -Edit a list's name or description: -```bash -libro list edit 1 --name "Updated Name" --description "New description" -``` - -Add a book to a list: -```bash -libro list add 1 42 -``` - -Remove a book from a list: -```bash -libro list remove 1 42 -``` - -Delete an entire list: -```bash -libro list delete 1 -``` - -View statistics for all lists: -```bash -libro list stats -``` - -View statistics for a specific list: -```bash -libro list stats 1 -``` - -## Install - -Libro is packaged as `libro-book` on PyPI. - -``` -pip install libro-book -``` - -You can also clone this repository and install it locally: - -``` -git clone https://github.com/mkaz/libro.git -cd libro -pip install -e . -``` - -## Setup - -On first run, libro will create a `libro.db` database file based on database location. It will prompt for confirmation to proceed which also shows the location where the file will be created. - -**Database locations:** - -The following order is used to determine the database location: - -1. Using the `--db` flag on command-line. - -2. `libro.db` in current directory - -3. Environment variable `LIBRO_DB` to specify custom file/location - -4. Finally, the user's platform-specific data directory - * Linux: `~/.local/share/libro/libro.db` - * macOS: `~/Library/Application Support/libro/libro.db` - * Windows: `%APPDATA%\libro\libro.db` - - -For example, if you want to create a new database file in the current directory, you can use the following command: - -``` -libro --db ./libro.db -``` - -### Import from Goodreads - -Libro can import your reading history from a Goodreads export CSV file. - -``` -libro import goodreads_library_export.csv -``` - -There is a `genre` field that accepts any string value, but this data is not available in the Goodreads export. You can edit books to add or change the genre after import. - -# Database Schema - -## Books table - -| Field | Type | Description | -|-------|------|-------------| -| id | primary key | Unique identifier | -| title | string | Book title | -| author | string | Book author | -| pages | int | Number of pages in book | -| pub_year | int | Year book was published | -| genre | string | Genre (any string value) | - -## Reviews table - -| Field | Type | Description | -|-------|------|-------------| -| id | primary key | Unique identifier | -| book_id | foreign key | Book identifier | -| date_read | date | Date book was read | -| rating | float | Number between 0 and 5 | -| review | text | Review of book | - -## Reading Lists table - -| Field | Type | Description | -|-------|------|-------------| -| id | primary key | Unique identifier | -| name | string | Reading list name (unique) | -| description | string | Optional description | -| created_date | date | Date the list was created | - -## Reading List Books table - -| Field | Type | Description | -|-------|------|-------------| -| id | primary key | Unique identifier | -| list_id | foreign key | Reading list identifier | -| book_id | foreign key | Book identifier | -| added_date | date | Date book was added to list | -| priority | int | Priority/order in list (default: 0) | - -# Changelog - -See [GitHub Releases](https://github.com/mkaz/libro/releases) for the changelog. - diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..64d4ffa --- /dev/null +++ b/shared/types.ts @@ -0,0 +1,132 @@ +export interface DbInfo { + path: string + exists: boolean +} + +export interface AddBookReviewInput { + title: string + author: string + pubYear: number | null + pages: number | null + genre: string | null + dateRead: string | null + rating: number | null + review: string | null +} + +export interface AddBookReviewResult { + bookId: number + reviewId: number + usedExistingBook: boolean +} + +export interface ReviewRow { + reviewId: number + bookId: number + title: string + author: string + genre: string | null + rating: number | null + dateRead: string | null +} + +export interface YearCount { + year: string + count: number +} + +export interface AuthorCount { + author: string + count: number +} + +export interface ReviewFilters { + year?: number + author?: string + rating?: number +} + +export interface SearchBookResult { + id: number + title: string + author: string + pubYear: number | null + pages: number | null + genre: string | null + inList: boolean +} + +export interface ReadingListSummary { + id: number + name: string + description: string | null + createdDate: string | null + totalBooks: number + booksRead: number + booksUnread: number + completionPercentage: number +} + +export interface ReadingListBookRow { + bookId: number + title: string + author: string + genre: string | null + pubYear: number | null + pages: number | null + addedDate: string | null + priority: number + isRead: boolean + dateRead: string | null + rating: number | null +} + +export interface ReadingListDetail { + id: number + name: string + description: string | null + createdDate: string | null + books: ReadingListBookRow[] + stats: { + totalBooks: number + booksRead: number + booksUnread: number + completionPercentage: number + } +} + +export interface CreateListInput { + name: string + description: string | null +} + +export interface AddBooksToListInput { + listId: number + bookIds: number[] +} + +export interface AddBooksToListResult { + addedCount: number + skippedBookIds: number[] +} + +export interface LibroApi { + app: { + getDbInfo: () => Promise + } + books: { + addBookReview: (input: AddBookReviewInput) => Promise + searchBooks: (term: string, listId?: number) => Promise + } + reports: { + getYearCounts: () => Promise + getAuthorCounts: (minimumBooks?: number) => Promise + getReviews: (filters?: ReviewFilters) => Promise + } + lists: { + getAll: () => Promise + getById: (listId: number) => Promise + create: (input: CreateListInput) => Promise + addBooks: (input: AddBooksToListInput) => Promise + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..6a63bc5 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react' + +import type { DbInfo } from '../shared/types' +import { AddBookReviewForm } from './features/add/AddBookReviewForm' +import { BooksByYearView } from './features/books/BooksByYearView' +import { ListsView } from './features/lists/ListsView' +import { ReportsView } from './features/reports/ReportsView' +import { api } from './lib/api' + +type View = 'books' | 'reports' | 'lists' | 'add' + +const navItems: Array<{ id: View; label: string }> = [ + { id: 'books', label: 'Books' }, + { id: 'reports', label: 'Reports' }, + { id: 'lists', label: 'Lists' }, + { id: 'add', label: 'Add Book' }, +] + +export function App() { + const [activeView, setActiveView] = useState('books') + const [dbInfo, setDbInfo] = useState(null) + const [dbError, setDbError] = useState(null) + + useEffect(() => { + api.app + .getDbInfo() + .then(setDbInfo) + .catch((error: unknown) => { + setDbError(error instanceof Error ? error.message : 'Failed to load database info.') + }) + }, []) + + return ( +
+
+

Libro

+ + + +
+ {dbError ? ( +
{dbError}
+ ) : ( + <> + + {dbInfo?.exists ? 'Existing file' : 'Will be created on first write'} + + {dbInfo?.path ?? 'Resolving database path...'} + + )} +
+
+ +
+ {activeView === 'books' ? : null} + {activeView === 'reports' ? : null} + {activeView === 'lists' ? : null} + {activeView === 'add' ? : null} +
+
+ ) +} diff --git a/src/features/add/AddBookReviewForm.tsx b/src/features/add/AddBookReviewForm.tsx new file mode 100644 index 0000000..ccda2cb --- /dev/null +++ b/src/features/add/AddBookReviewForm.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react' + +import type { AddBookReviewInput, AddBookReviewResult } from '../../../shared/types' +import { api } from '../../lib/api' + +const initialForm: AddBookReviewInput = { + title: '', + author: '', + pubYear: null, + pages: null, + genre: null, + dateRead: null, + rating: null, + review: null, +} + +function toOptionalNumber(value: string): number | null { + if (!value.trim()) { + return null + } + + return Number(value) +} + +export function AddBookReviewForm() { + const [form, setForm] = useState(initialForm) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + setSubmitting(true) + setError(null) + setResult(null) + + try { + const payload = { + ...form, + title: form.title.trim(), + author: form.author.trim(), + genre: form.genre?.trim() || null, + review: form.review?.trim() || null, + } + const response = await api.books.addBookReview(payload) + setResult(response) + setForm(initialForm) + } catch (submitError: unknown) { + setError(submitError instanceof Error ? submitError.message : 'Failed to save book.') + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+
+

Add Book and Review

+

+ This matches the CLI's main add command. If the book already exists by title and + author, the app attaches a new review instead of creating a duplicate book. +

+
+
+ + {result ? ( +
+ Saved review #{result.reviewId} for book #{result.bookId} + . {result.usedExistingBook ? 'Existing book reused.' : 'New book created.'} +
+ ) : null} + + {error ?
{error}
: null} + +
+
+ + setForm((current) => ({ ...current, title: event.target.value }))} + required + /> +
+ +
+ + setForm((current) => ({ ...current, author: event.target.value }))} + required + /> +
+ +
+ + + setForm((current) => ({ ...current, pubYear: toOptionalNumber(event.target.value) })) + } + /> +
+ +
+ + + setForm((current) => ({ ...current, pages: toOptionalNumber(event.target.value) })) + } + /> +
+ +
+ + setForm((current) => ({ ...current, genre: event.target.value }))} + /> +
+ +
+ + + setForm((current) => ({ ...current, dateRead: event.target.value || null })) + } + /> +
+ +
+ + +
+ +
+ +