diff --git a/.gitignore b/.gitignore index 492790f..0b01571 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,15 @@ +covers/ data/ libro-test.db libro.db libro.bak +node_modules/ +dist/ +dist-electron/ +package-lock.json -.ruff_cache -uv.lock -.venv/ +*.log +*.local +.DS_Store +.vscode/* +.vite diff --git a/AGENTS.md b/AGENTS.md index de09b93..a3af516 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,43 +1,19 @@ -# AGENTS.md +# CLAUDE.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. +Libro is a desktop reading tracker built with Electron + React + Vite. It uses a local SQLite database. ## 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 +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 @@ -46,27 +22,41 @@ uv run python -m mypy --package libro ``` 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/ +├── 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, Search, Lists, Add Book) + ├── lib/ + │ ├── api.ts # Typed wrapper around window.libro (the context bridge) + │ ├── ratings.ts # Star rating helpers + │ ├── ReviewTable.tsx # Shared review table component (rows open BookDetailModal) + │ └── BookDetailModal.tsx # Modal showing full book details + all reviews + └── features/ + ├── books/BooksByYearView.tsx + ├── reports/ReportsView.tsx + ├── search/SearchView.tsx + ├── lists/ListsView.tsx + └── add/AddBookReviewForm.tsx ``` ### 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 +- **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 @@ -82,14 +72,7 @@ The application separates **books** from **reviews** to support: 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 +**Database Schema:** ```sql CREATE TABLE books ( @@ -129,128 +112,35 @@ CREATE TABLE reading_list_books ( ); ``` -## 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 +## IPC Architecture -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`. +All database access happens in the main process. The renderer never touches the database directly. -## Troubleshooting +- `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 -### Duplicate Book on Import +IPC channel naming: `namespace:verb-noun` (e.g. `books:add-book-review`, `lists:get-by-id`). -`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. +## Database Location -### Date Shows Wrong Value +Priority order: -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. +1. `libro.db` in cwd (if it exists) +2. `LIBRO_DB` environment variable +3. `{appData}/Libro/mkaz/libro.db` -### Reading List Migration +## Adding a New Feature -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. +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 -- 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 +- 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/CONTRIBUTING.md b/CONTRIBUTING.md index b2b8900..012e4a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,62 +1,23 @@ # Contributing to Libro -Hey there! Thanks for your interest in contributing to Libro. This is just a hobby project I work on in my spare time, but I'm totally open to help from others who find it useful or interesting. +Libro is a hobby project, but contributions are welcome. ## Getting Started -The easiest way to get up and running: +1. Fork and clone the repo +2. Install dependencies: `npm install` +3. Run the dev build: `just dev` -1. Fork the repo -2. Clone your fork locally -3. Make sure you have Python 3.10+ and install `uv` if you don't have it -4. Run `just install` to set up dependencies -5. Run `uv run libro --help` to make sure everything works +## Development -## Making Changes - -### Development Workflow - -- `just lint` - Check your code style (uses ruff) -- `just run ` - Test your changes with the CLI -- `just build` - Full build process before submitting - -### What I'm Looking For +`just dev` builds the Electron main process, starts the Vite dev server, then launches the app. Vite handles hot-reload for renderer changes. If you change anything in `electron/`, restart `just dev`. -I'm pretty relaxed about contributions, but here are some things that would be especially welcome: - -- **Bug fixes** - If something's broken, I'd love help fixing it -- **Small features** - New commands, import formats, display options, etc. -- **Documentation improvements** - Better help text, examples, etc. -- **Testing** - The project could use more automated tests -- **Performance improvements** - Making things faster is always good - -### What Probably Won't Get Merged - -- Major architectural changes without discussion first -- Features that significantly complicate the codebase -- Anything that breaks backward compatibility without a really good reason +## Making Changes -## Code Style +Follow the pattern in CLAUDE.md for adding new features — types in `shared/types.ts`, queries in `electron/db/`, IPC handler in `electron/ipc.ts`, wired up in `preload.ts` and `src/lib/api.ts`, then the React component in `src/features/`. -I use ruff for formatting and linting, so just run `just lint` and fix any issues it finds. The existing code style is pretty straightforward - try to match what's already there. +Run `npm run typecheck` and `npm run lint` before submitting. ## Submitting Changes -1. Create a branch for your changes -2. Make your changes and test them -3. Run `just lint` to make sure everything looks good -4. Open a pull request with a clear description of what you changed and why - -No need for fancy commit message formats or elaborate PR templates. Just explain what you did in plain English. - -## Questions or Ideas? - -Feel free to open an issue if you want to discuss something before diving in. I'm usually pretty responsive, though sometimes it might take a few days if I'm busy with other stuff. - -## License - -By contributing, you agree that your contributions will be licensed under the same license as the project (check the LICENSE file). - ---- - -Thanks for considering contributing! Even small improvements are appreciated. 🙂 \ No newline at end of file +Open a pull request with a plain description of what you changed and why. No fancy templates needed. diff --git a/electron/covers.ts b/electron/covers.ts new file mode 100644 index 0000000..0507cb2 --- /dev/null +++ b/electron/covers.ts @@ -0,0 +1,283 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import type { + BookCoverCandidate, + BookCoverSelection, + BookCoverSource, +} from '../shared/types' +import { getCoversDirectory, getDataDirectory } from './db/client' +import type { SavedBookCover } from './db/books' + +const OPEN_LIBRARY_BASE_URL = 'https://openlibrary.org' +const OPEN_LIBRARY_COVERS_URL = 'https://covers.openlibrary.org' +const GOOGLE_BOOKS_API_URL = 'https://www.googleapis.com/books/v1/volumes' +const MAX_CANDIDATES = 12 +const MAX_COVER_BYTES = 10 * 1024 * 1024 +const REQUEST_TIMEOUT_MS = 15_000 + +interface OpenLibrarySearchDocument { + key?: string + title?: string + author_name?: string[] + first_publish_year?: number + cover_i?: number +} + +interface OpenLibraryEdition { + key?: string + title?: string + publish_date?: string + covers?: number[] +} + +interface GoogleVolume { + id?: string + volumeInfo?: { + title?: string + authors?: string[] + publishedDate?: string + infoLink?: string + imageLinks?: Record + } +} + +function requestHeaders(): HeadersInit { + return { + Accept: 'application/json', + 'User-Agent': 'Libro Desktop/0.2.2 (personal desktop reading tracker)', + } +} + +async function fetchJson(url: URL): Promise { + const response = await fetch(url, { + headers: requestHeaders(), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + + if (!response.ok) { + throw new Error(`Cover service returned ${response.status}.`) + } + + return response.json() as Promise +} + +function coverUrl(coverId: number, size: 'M' | 'L'): string { + return `${OPEN_LIBRARY_COVERS_URL}/b/id/${coverId}-${size}.jpg?default=false` +} + +function publicationYear(value: string | number | undefined): string | null { + if (value === undefined) return null + const match = String(value).match(/\b\d{4}\b/) + return match?.[0] ?? null +} + +async function findOpenLibraryCovers( + title: string, + author: string, +): Promise { + const searchUrl = new URL('/search.json', OPEN_LIBRARY_BASE_URL) + searchUrl.searchParams.set('title', title) + searchUrl.searchParams.set('author', author) + searchUrl.searchParams.set('fields', 'key,title,author_name,first_publish_year,cover_i') + searchUrl.searchParams.set('limit', '5') + + const search = await fetchJson<{ docs?: OpenLibrarySearchDocument[] }>(searchUrl) + const work = search.docs?.find((document) => document.key?.startsWith('/works/')) + if (!work?.key) return [] + const workKey = work.key + + // Open Library's default unidentified-client limit is one request per second. + await new Promise((resolve) => setTimeout(resolve, 1_050)) + + const editionsUrl = new URL(`${workKey}/editions.json`, OPEN_LIBRARY_BASE_URL) + editionsUrl.searchParams.set('limit', '100') + const editions = await fetchJson<{ entries?: OpenLibraryEdition[] }>(editionsUrl) + const candidates: BookCoverCandidate[] = [] + const seenCoverIds = new Set() + + function addCandidate( + coverId: number, + candidateTitle: string, + year: string | null, + sourceUrl: string, + ): void { + if (!Number.isInteger(coverId) || coverId <= 0 || seenCoverIds.has(coverId)) return + seenCoverIds.add(coverId) + candidates.push({ + source: 'openlibrary', + sourceId: String(coverId), + thumbnailUrl: coverUrl(coverId, 'M'), + imageUrl: coverUrl(coverId, 'L'), + title: candidateTitle, + author: work?.author_name?.join(', ') || author, + publicationYear: year, + sourceUrl: new URL(sourceUrl, OPEN_LIBRARY_BASE_URL).toString(), + }) + } + + for (const edition of editions.entries ?? []) { + for (const coverId of edition.covers ?? []) { + addCandidate( + coverId, + edition.title || work.title || title, + publicationYear(edition.publish_date), + edition.key || workKey, + ) + if (candidates.length >= MAX_CANDIDATES) return candidates + } + } + + if (work.cover_i) { + addCandidate( + work.cover_i, + work.title || title, + publicationYear(work.first_publish_year), + workKey, + ) + } + + return candidates +} + +function httpsUrl(value: string): string { + return value.replace(/^http:\/\//, 'https://') +} + +async function findGoogleBooksCovers( + title: string, + author: string, +): Promise { + const url = new URL(GOOGLE_BOOKS_API_URL) + url.searchParams.set('q', `intitle:"${title}" inauthor:"${author}"`) + url.searchParams.set('printType', 'books') + url.searchParams.set('maxResults', String(MAX_CANDIDATES)) + const apiKey = process.env.GOOGLE_BOOKS_API_KEY?.trim() + if (apiKey) url.searchParams.set('key', apiKey) + + const result = await fetchJson<{ items?: GoogleVolume[] }>(url) + const candidates: BookCoverCandidate[] = [] + + for (const item of result.items ?? []) { + const info = item.volumeInfo + const images = info?.imageLinks + const thumbnail = images?.thumbnail || images?.smallThumbnail + const image = images?.large || images?.medium || images?.small || thumbnail + if (!item.id || !info || !thumbnail || !image) continue + + candidates.push({ + source: 'googlebooks', + sourceId: item.id, + thumbnailUrl: httpsUrl(thumbnail), + imageUrl: httpsUrl(image), + title: info.title || title, + author: info.authors?.join(', ') || author, + publicationYear: publicationYear(info.publishedDate), + sourceUrl: info.infoLink ? httpsUrl(info.infoLink) : `https://books.google.com/books?id=${item.id}`, + }) + } + + return candidates +} + +export async function findCoverCandidates( + titleValue: string, + authorValue: string, +): Promise { + const title = titleValue.trim() + const author = authorValue.trim() + if (!title || !author) { + throw new Error('Enter a title and author before looking for covers.') + } + + try { + const openLibraryCandidates = await findOpenLibraryCovers(title, author) + if (openLibraryCandidates.length > 0) return openLibraryCandidates + } catch (error) { + console.warn('Open Library cover lookup failed; trying Google Books.', error) + } + + try { + return await findGoogleBooksCovers(title, author) + } catch (error) { + console.warn('Google Books cover lookup failed.', error) + throw new Error('Cover lookup failed. Check your connection and try again.') + } +} + +function isAllowedImageUrl(url: URL, source: BookCoverSource): boolean { + if (url.protocol !== 'https:') return false + if (source === 'openlibrary') return url.hostname === 'covers.openlibrary.org' + return url.hostname === 'books.google.com' +} + +function isAllowedImageRedirect(url: URL, source: BookCoverSource): boolean { + if (url.protocol !== 'https:') return false + if (source === 'openlibrary') { + return url.hostname === 'covers.openlibrary.org' || url.hostname.endsWith('.archive.org') + } + return ( + url.hostname === 'books.google.com' || + url.hostname === 'books.googleusercontent.com' || + url.hostname.endsWith('.googleusercontent.com') + ) +} + +function extensionFor(contentType: string): string | null { + if (contentType.includes('image/jpeg')) return '.jpg' + if (contentType.includes('image/png')) return '.png' + if (contentType.includes('image/webp')) return '.webp' + return null +} + +export async function saveCoverLocally( + selection: BookCoverSelection, +): Promise { + const requestedUrl = new URL(selection.imageUrl) + if (!isAllowedImageUrl(requestedUrl, selection.source)) { + throw new Error('The selected cover URL is not allowed.') + } + + const response = await fetch(requestedUrl, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + if (!response.ok) { + throw new Error(`Could not download the selected cover (${response.status}).`) + } + + const finalUrl = new URL(response.url) + if (!isAllowedImageRedirect(finalUrl, selection.source)) { + throw new Error('The cover download redirected to an unexpected host.') + } + + const extension = extensionFor(response.headers.get('content-type') ?? '') + if (!extension) { + throw new Error('The cover service did not return a supported image.') + } + + const declaredSize = Number(response.headers.get('content-length') ?? 0) + if (declaredSize > MAX_COVER_BYTES) { + throw new Error('The selected cover is too large.') + } + + const image = Buffer.from(await response.arrayBuffer()) + if (image.byteLength > MAX_COVER_BYTES) { + throw new Error('The selected cover is too large.') + } + + const coversDirectory = getCoversDirectory() + await mkdir(coversDirectory, { recursive: true }) + const filename = `${randomUUID()}${extension}` + await writeFile(path.join(coversDirectory, filename), image, { flag: 'wx' }) + + return { + source: selection.source, + sourceId: selection.sourceId, + path: path.join('covers', filename), + } +} + +export async function deleteSavedCover(cover: SavedBookCover): Promise { + await rm(path.join(getDataDirectory(), cover.path), { force: true }) +} diff --git a/electron/db/books.ts b/electron/db/books.ts new file mode 100644 index 0000000..c42e56d --- /dev/null +++ b/electron/db/books.ts @@ -0,0 +1,241 @@ +import type Database from 'better-sqlite3' + +import type { + AddBookReviewInput, + AddBookReviewResult, + BookCoverSource, + BookDetail, + BookReviewDetail, + SearchBookResult, + UpdateReviewInput, +} 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 interface SavedBookCover { + source: BookCoverSource + sourceId: string + path: string +} + +export function addBookReview( + db: Database.Database, + input: AddBookReviewInput, + cover: SavedBookCover | null = null, +): 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, cover_path as coverPath + FROM books + WHERE LOWER(title) = LOWER(?) AND LOWER(author) = LOWER(?)`, + ) + .get(title, author) as { id: number; coverPath: string | null } | undefined + + const insertBook = db.prepare( + `INSERT INTO books ( + title, author, pub_year, pages, genre, + cover_source, cover_source_id, cover_path + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + const insertReview = db.prepare( + `INSERT INTO reviews (book_id, date_read, rating, review) + VALUES (?, ?, ?, ?)`, + ) + const updateMissingCover = db.prepare( + `UPDATE books + SET cover_source = ?, cover_source_id = ?, cover_path = ? + WHERE id = ? AND cover_path IS NULL`, + ) + + const transaction = db.transaction(() => { + let bookId = existingBook?.id + + if (bookId === undefined) { + const bookResult = insertBook.run( + title, + author, + input.pubYear, + input.pages, + genre, + cover?.source ?? null, + cover?.sourceId ?? null, + cover?.path ?? null, + ) + bookId = Number(bookResult.lastInsertRowid) + } else if (cover && existingBook && existingBook.coverPath === null) { + updateMissingCover.run(cover.source, cover.sourceId, cover.path, bookId) + } + + const reviewResult = insertReview.run( + bookId, + input.dateRead, + input.rating, + reviewText, + ) + + return { + bookId, + reviewId: Number(reviewResult.lastInsertRowid), + usedExistingBook: existingBook !== undefined, + coverSaved: cover !== null && (existingBook === undefined || existingBook.coverPath === null), + } + }) + + return transaction() +} + +export function updateReview( + db: Database.Database, + input: UpdateReviewInput, +): void { + const reviewText = normalizeOptionalText(input.review) + + const result = db + .prepare( + `UPDATE reviews + SET date_read = ?, rating = ?, review = ? + WHERE id = ?`, + ) + .run(input.dateRead, input.rating, reviewText, input.reviewId) + + if (result.changes === 0) { + throw new Error(`Review ${input.reviewId} not found.`) + } +} + +export function addBookCover( + db: Database.Database, + bookId: number, + cover: SavedBookCover, +): boolean { + const result = db + .prepare( + `UPDATE books + SET cover_source = ?, cover_source_id = ?, cover_path = ? + WHERE id = ? AND cover_path IS NULL`, + ) + .run(cover.source, cover.sourceId, cover.path, bookId) + + if (result.changes === 1) return true + + const bookExists = db.prepare('SELECT 1 FROM books WHERE id = ?').get(bookId) + if (!bookExists) throw new Error(`Book ${bookId} not found.`) + return false +} + +export function getBookCoverPath( + db: Database.Database, + bookId: number, +): string | null { + const row = db + .prepare('SELECT cover_path as coverPath FROM books WHERE id = ?') + .get(bookId) as { coverPath: string | null } | undefined + return row?.coverPath ?? null +} + +export function getBookDetail( + db: Database.Database, + bookId: number, +): BookDetail { + const book = db + .prepare( + `SELECT + id, + title, + author, + pub_year as pubYear, + pages, + genre, + cover_path IS NOT NULL as hasCover + FROM books + WHERE id = ?`, + ) + .get(bookId) as (Omit & { hasCover: number }) | undefined + + if (!book) { + throw new Error(`Book ${bookId} not found.`) + } + + const reviews = db + .prepare( + `SELECT + id as reviewId, + date_read as dateRead, + rating, + review + FROM reviews + WHERE book_id = ? + ORDER BY date_read DESC, id DESC`, + ) + .all(bookId) as BookReviewDetail[] + + return { ...book, hasCover: Boolean(book.hasCover), reviews } +} + +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..092183b --- /dev/null +++ b/electron/db/client.ts @@ -0,0 +1,113 @@ +import Database from 'better-sqlite3' +import { app } from 'electron' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +import { migrateDatabase } from './migrate' + +let dbInstance: Database.Database | null = null +let dbPathCache: string | undefined + +function getConfigPath(): string { + return path.join(app.getPath('userData'), 'libro-config.json') +} + +interface StorageConfig { + dataDirectory?: string + databaseFilename?: string + dbPath?: string +} + +function readConfigDbPath(): string | null { + const configPath = getConfigPath() + if (!existsSync(configPath)) return null + try { + const config = JSON.parse(readFileSync(configPath, 'utf8')) as StorageConfig + if (config.dataDirectory) { + return path.join( + path.resolve(config.dataDirectory), + path.basename(config.databaseFilename ?? 'libro.db'), + ) + } + return config.dbPath ? path.resolve(config.dbPath) : null + } catch { + return null + } +} + +export function writeConfigDbPath(dbPath: string): void { + const absoluteDbPath = path.resolve(dbPath) + const config: StorageConfig = { + dataDirectory: path.dirname(absoluteDbPath), + databaseFilename: path.basename(absoluteDbPath), + } + mkdirSync(path.dirname(getConfigPath()), { recursive: true }) + writeFileSync(getConfigPath(), JSON.stringify(config, null, 2)) +} + +function dbPathIn(directory: string): string { + return path.join(path.resolve(directory), 'libro.db') +} + +function resolveDbPath(): string { + const overrideDirectory = process.env.LIBRO_DATA_DIR_OVERRIDE + if (overrideDirectory) return dbPathIn(overrideDirectory) + + // Keep the database-path overrides for compatibility with existing workflows. + const overrideDb = process.env.LIBRO_DB_OVERRIDE + if (overrideDb) return path.resolve(overrideDb) + + const currentDirDb = path.resolve(process.cwd(), 'libro.db') + if (existsSync(currentDirDb)) return currentDirDb + + const envDirectory = process.env.LIBRO_DATA_DIR + if (envDirectory) return dbPathIn(envDirectory) + + const envDb = process.env.LIBRO_DB + if (envDb) return path.resolve(envDb) + + return readConfigDbPath() ?? dbPathIn(app.getPath('userData')) +} + +export function getDbPath(): string { + dbPathCache ??= resolveDbPath() + return dbPathCache +} + +export function getDataDirectory(): string { + return path.dirname(getDbPath()) +} + +export function getCoversDirectory(): string { + return path.join(getDataDirectory(), 'covers') +} + +export function getDatabase(): Database.Database { + if (dbInstance !== null) { + return dbInstance + } + + const dbPath = getDbPath() + mkdirSync(getCoversDirectory(), { 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 + dbPathCache = undefined +} diff --git a/electron/db/lists.ts b/electron/db/lists.ts new file mode 100644 index 0000000..5a0f672 --- /dev/null +++ b/electron/db/lists.ts @@ -0,0 +1,281 @@ +import type Database from 'better-sqlite3' + +import type { + AddBooksToListInput, + AddBooksToListResult, + AddNewBookToListInput, + AddNewBookToListResult, + 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 addNewBookToList( + db: Database.Database, + input: AddNewBookToListInput, +): AddNewBookToListResult { + const title = input.title.trim() + const author = input.author.trim() + const genre = input.genre?.trim().toLowerCase() || null + + if (!title || !author) { + throw new Error('Title and author are required.') + } + + const transaction = db.transaction(() => { + 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 existingBook = db + .prepare( + `SELECT id + FROM books + WHERE LOWER(title) = LOWER(?) AND LOWER(author) = LOWER(?)`, + ) + .get(title, author) as { id: number } | undefined + + const bookId = + existingBook?.id ?? + Number( + db + .prepare( + `INSERT INTO books (title, author, pub_year, pages, genre) + VALUES (?, ?, ?, ?, ?)`, + ) + .run(title, author, input.pubYear, input.pages, genre).lastInsertRowid, + ) + + db.prepare( + `INSERT OR IGNORE INTO reading_list_books (list_id, book_id, added_date, priority) + VALUES (?, ?, CURRENT_DATE, 0)`, + ).run(input.listId, bookId) + + return { bookId, usedExistingBook: existingBook !== undefined } + }) + + return transaction() +} + +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..f0e3313 --- /dev/null +++ b/electron/db/migrate.ts @@ -0,0 +1,60 @@ +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, + cover_source TEXT, + cover_source_id TEXT, + cover_path 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) + ); + `) + + const bookColumns = new Set( + (db.prepare('PRAGMA table_info(books)').all() as Array<{ name: string }>).map( + (column) => column.name, + ), + ) + + if (!bookColumns.has('cover_source')) { + db.exec('ALTER TABLE books ADD COLUMN cover_source TEXT') + } + if (!bookColumns.has('cover_source_id')) { + db.exec('ALTER TABLE books ADD COLUMN cover_source_id TEXT') + } + if (!bookColumns.has('cover_path')) { + db.exec('ALTER TABLE books ADD COLUMN cover_path TEXT') + } +} diff --git a/electron/db/reports.ts b/electron/db/reports.ts new file mode 100644 index 0000000..9269832 --- /dev/null +++ b/electron/db/reports.ts @@ -0,0 +1,80 @@ +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, + includeUndated = false, +): 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 (? = 1 OR r.date_read IS NOT NULL) + GROUP BY b.author + HAVING count >= ? + ORDER BY count DESC, LOWER(b.author) ASC`, + ) + .all(includeUndated ? 1 : 0, 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, + b.cover_path IS NOT NULL as hasCover, + 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 Array & { hasCover: number }>).map( + (row) => ({ ...row, hasCover: Boolean(row.hasCover) }), + ) +} diff --git a/electron/ipc.ts b/electron/ipc.ts new file mode 100644 index 0000000..cb9378a --- /dev/null +++ b/electron/ipc.ts @@ -0,0 +1,93 @@ +import { ipcMain } from 'electron' + +import type { AddBookCoverInput, AddBookReviewInput } from '../shared/types' +import { deleteSavedCover, findCoverCandidates, saveCoverLocally } from './covers' +import { getDatabase, getDbInfo } from './db/client' +import { addBookCover, addBookReview, getBookDetail, searchBooks, updateReview } from './db/books' +import { getAuthorCounts, getReviews, getYearCounts } from './db/reports' +import { addBooksToList, addNewBookToList, createList, getAllLists, getListById } from './db/lists' + +async function addBookReviewWithCover(input: AddBookReviewInput) { + const savedCover = input.cover ? await saveCoverLocally(input.cover) : null + let result + + try { + result = addBookReview(getDatabase(), input, savedCover) + } catch (error) { + if (savedCover) { + await deleteSavedCover(savedCover).catch((cleanupError: unknown) => { + console.warn('Could not remove unused cover after save failure.', cleanupError) + }) + } + throw error + } + + if (!result.coverSaved && savedCover) { + await deleteSavedCover(savedCover).catch((cleanupError: unknown) => { + console.warn('Could not remove a cover the book did not use.', cleanupError) + }) + } + return result +} + +async function addCoverToBook(input: AddBookCoverInput) { + const savedCover = await saveCoverLocally(input.cover) + + try { + const coverSaved = addBookCover(getDatabase(), input.bookId, savedCover) + if (!coverSaved) { + await deleteSavedCover(savedCover).catch((cleanupError: unknown) => { + console.warn('Could not remove a cover the book did not use.', cleanupError) + }) + } + return { coverSaved } + } catch (error) { + await deleteSavedCover(savedCover).catch((cleanupError: unknown) => { + console.warn('Could not remove unused cover after save failure.', cleanupError) + }) + throw error + } +} + +export function registerIpcHandlers(): void { + ipcMain.handle('app:get-db-info', () => getDbInfo()) + + ipcMain.handle('books:find-cover-candidates', (_, title: string, author: string) => + findCoverCandidates(title, author), + ) + ipcMain.handle('books:add-book-review', (_, input: AddBookReviewInput) => + addBookReviewWithCover(input), + ) + ipcMain.handle('books:add-cover', (_, input: AddBookCoverInput) => + addCoverToBook(input), + ) + ipcMain.handle('books:search', (_, term: string, listId?: number) => + searchBooks(getDatabase(), term, listId), + ) + ipcMain.handle('books:get-detail', (_, bookId: number) => + getBookDetail(getDatabase(), bookId), + ) + ipcMain.handle('books:update-review', (_, input) => + updateReview(getDatabase(), input), + ) + + ipcMain.handle('reports:get-year-counts', () => getYearCounts(getDatabase())) + ipcMain.handle('reports:get-author-counts', (_, minimumBooks?: number, includeUndated?: boolean) => + getAuthorCounts(getDatabase(), minimumBooks, includeUndated), + ) + 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), + ) + ipcMain.handle('lists:add-new-book', (_, input) => + addNewBookToList(getDatabase(), input), + ) +} diff --git a/electron/main.ts b/electron/main.ts new file mode 100644 index 0000000..c33b755 --- /dev/null +++ b/electron/main.ts @@ -0,0 +1,211 @@ +import { app, BrowserWindow, dialog, Menu, protocol, shell } from 'electron' +import type { MenuItemConstructorOptions } from 'electron' +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import { getBookCoverPath } from './db/books' +import { + closeDatabase, + getCoversDirectory, + getDatabase, + getDataDirectory, + writeConfigDbPath, +} from './db/client' +import { registerIpcHandlers } from './ipc' + +const rendererDevUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://127.0.0.1:5173' + +protocol.registerSchemesAsPrivileged([ + { + scheme: 'libro-cover', + privileges: { standard: true, secure: true, supportFetchAPI: true }, + }, +]) + +function notFoundResponse(): Response { + return new Response('Cover not found.', { status: 404 }) +} + +function coverContentType(filePath: string): string { + switch (path.extname(filePath).toLowerCase()) { + case '.jpg': + case '.jpeg': + return 'image/jpeg' + case '.png': + return 'image/png' + case '.webp': + return 'image/webp' + default: + return 'application/octet-stream' + } +} + +function registerCoverProtocol(): void { + protocol.handle('libro-cover', async (request) => { + const url = new URL(request.url) + const bookId = Number(url.pathname.slice(1)) + if (url.hostname !== 'book' || !Number.isInteger(bookId) || bookId <= 0) { + return notFoundResponse() + } + + const coverPath = getBookCoverPath(getDatabase(), bookId) + if (!coverPath) return notFoundResponse() + + const coversDirectory = path.resolve(getCoversDirectory()) + const absoluteCoverPath = path.resolve(getDataDirectory(), coverPath) + if (!absoluteCoverPath.startsWith(`${coversDirectory}${path.sep}`)) { + return notFoundResponse() + } + + try { + const image = await readFile(absoluteCoverPath) + return new Response(new Uint8Array(image), { + headers: { + 'Content-Type': coverContentType(absoluteCoverPath), + 'Cache-Control': 'private, max-age=3600', + }, + }) + } catch { + return notFoundResponse() + } + }) +} + +async function chooseDatabase(): Promise { + const { response } = await dialog.showMessageBox({ + type: 'question', + title: 'Select Database', + message: 'Choose a Libro database', + detail: 'Open an existing database file or create a new one.', + buttons: ['Open Existing', 'Create New', 'Cancel'], + defaultId: 0, + cancelId: 2, + }) + + if (response === 2) return null + + if (response === 0) { + const result = await dialog.showOpenDialog({ + title: 'Open Libro Database', + filters: [{ name: 'SQLite Database', extensions: ['db', 'sqlite', 'sqlite3'] }], + properties: ['openFile'], + }) + return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0] + } + + // Create new — pick a directory, libro.db will be created inside it + const result = await dialog.showOpenDialog({ + title: 'Choose Location for New Database', + properties: ['openDirectory', 'createDirectory'], + }) + return result.canceled || result.filePaths.length === 0 + ? null + : path.join(result.filePaths[0], 'libro.db') +} + +async function switchDatabase(mainWindow: BrowserWindow): Promise { + const dbPath = await chooseDatabase() + if (!dbPath) return + + writeConfigDbPath(dbPath) + closeDatabase() + getDatabase() + mainWindow.reload() +} + +function buildMenu(mainWindow: BrowserWindow): void { + const template: MenuItemConstructorOptions[] = [ + { + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + { + label: 'File', + submenu: [ + { + label: 'Open Database...', + accelerator: 'CmdOrCtrl+O', + click: () => switchDatabase(mainWindow), + }, + ], + }, + { role: 'editMenu' }, + { role: 'viewMenu' }, + { role: 'windowMenu' }, + ] + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)) +} + +function createWindow(): BrowserWindow { + 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, + }, + }) + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('https://')) void shell.openExternal(url) + return { action: 'deny' } + }) + + if (app.isPackaged) { + mainWindow.loadFile(path.resolve(__dirname, '../dist/index.html')) + } else { + const loadDevUrl = () => { + mainWindow.loadURL(rendererDevUrl).catch(() => { + // Dev server may not be ready yet; retry shortly. + setTimeout(loadDevUrl, 300) + }) + } + mainWindow.webContents.on('did-fail-load', () => { + setTimeout(loadDevUrl, 300) + }) + loadDevUrl() + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } + + return mainWindow +} + +app.whenReady().then(() => { + getDatabase() + registerCoverProtocol() + registerIpcHandlers() + const mainWindow = createWindow() + buildMenu(mainWindow) + + 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..0a5ab67 --- /dev/null +++ b/electron/preload.ts @@ -0,0 +1,33 @@ +import { contextBridge, ipcRenderer } from 'electron' + +import type { LibroApi } from '../shared/types' + +const api: LibroApi = { + app: { + getDbInfo: () => ipcRenderer.invoke('app:get-db-info'), + }, + books: { + findCoverCandidates: (title, author) => + ipcRenderer.invoke('books:find-cover-candidates', title, author), + addBookReview: (input) => ipcRenderer.invoke('books:add-book-review', input), + addBookCover: (input) => ipcRenderer.invoke('books:add-cover', input), + updateReview: (input) => ipcRenderer.invoke('books:update-review', input), + searchBooks: (term, listId) => ipcRenderer.invoke('books:search', term, listId), + getBookDetail: (bookId) => ipcRenderer.invoke('books:get-detail', bookId), + }, + reports: { + getYearCounts: () => ipcRenderer.invoke('reports:get-year-counts'), + getAuthorCounts: (minimumBooks, includeUndated) => + ipcRenderer.invoke('reports:get-author-counts', minimumBooks, includeUndated), + 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), + addNewBook: (input) => ipcRenderer.invoke('lists:add-new-book', 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/justfile b/justfile index faa771b..eff1023 100644 --- a/justfile +++ b/justfile @@ -5,68 +5,10 @@ set quiet default: @just --list -# Run lint and format checks -lint: - echo "Running ruff to check..." - uv run python -m ruff check src/libro/ - uv run python -m ruff format --check src/libro/ - echo "." +# Start dev server with hot-reload +dev: + npm run dev -# Fix lint and format checks -lint-fix: - echo "Fixing lint issues..." - uv run python -m ruff check --fix src/libro/ - uv run python -m ruff format src/libro/ - echo "." - -# Run mypy typecheck -type-check: - echo "Running mypy to type check..." - uv run python -m mypy --package libro - -# Run tests -test: - echo "Running tests..." - uv run python -m pytest tests/ -v - -# Run all CI checks locally -ci: lint type-check test - echo "All CI checks passed!" - -# Clean Python artifacts -clean: - echo "Cleaning..." - rm -rf build/ - rm -rf dist/ - rm -rf *.egg-info - find . -type d -name __pycache__ -exec rm -rf {} + - find . -type f -name "*.pyc" -delete - echo "." - -# Install dependencies -install: - echo "Installing dependencies" - uv sync - echo "." - -# Install developer dependencies -dev-install: - echo "Installing developer dependencies" - uv sync --dev - echo "." - -# Build the project -build: clean lint dev-install - echo "Building" - uv run -m build - echo "." - -# Publish the project to PyPI -publish: build - echo "Publishing to PyPI" - uv run -m twine upload dist/* - echo "." - -# Run the CLI application -run *args: - uv run libro {{args}} +# Build and package production DMG +package: + npm run package diff --git a/package.json b/package.json new file mode 100644 index 0000000..a8499f6 --- /dev/null +++ b/package.json @@ -0,0 +1,64 @@ +{ + "name": "libro-desktop", + "private": true, + "version": "0.3.0", + "description": "Desktop app for the Libro reading tracker", + "author": "mkaz", + "type": "module", + "main": "dist-electron/main.cjs", + "scripts": { + "dev": "node scripts/dev.mjs", + "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 .", +"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", + "icon": "public/libro-icon.png", + "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/public/libro-icon.png b/public/libro-icon.png new file mode 100644 index 0000000..2cedef4 Binary files /dev/null and b/public/libro-icon.png differ 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..750cdb5 100644 --- a/readme.md +++ b/readme.md @@ -1,392 +1,113 @@ -# Libro +# Libro Desktop -📚 Libro: A terminal-based tool to track your reading history, with your data stored locally in a SQLite database. +## Development -## Core Concepts +Start Libro with its configured data directory: -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): -``` -❯ 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" +npm run dev ``` -Add a book to a list: -```bash -libro list add 1 42 -``` +To use an isolated data directory for development, pass it with `--data-dir`: -Remove a book from a list: ```bash -libro list remove 1 42 +npm run dev -- --data-dir ./data/cover-test ``` -Delete an entire list: -```bash -libro list delete 1 -``` +Libro creates this layout as files are needed: -View statistics for all lists: -```bash -libro list stats +```text +cover-test/ +├── libro.db +└── covers/ ``` -View statistics for a specific list: -```bash -libro list stats 1 -``` +The older `--db ` option remains available for testing a specific database file. Covers are stored in a `covers` directory beside that file. -## Install +## Data directory -Libro is packaged as `libro-book` on PyPI. +A packaged app stores data in Electron's per-user application directory by default. On macOS this is normally `~/Library/Application Support/Libro Desktop`. Use **File > Open Database…** to select another database; Libro remembers its containing directory in `libro-config.json` under the per-user application directory. -``` -pip install libro-book -``` +The database path is resolved in this order: -You can also clone this repository and install it locally: +1. `LIBRO_DATA_DIR_OVERRIDE` or the legacy `LIBRO_DB_OVERRIDE` +2. An existing `libro.db` in the current working directory +3. `LIBRO_DATA_DIR` or the legacy `LIBRO_DB` +4. The directory saved through **File > Open Database…** +5. Electron's per-user application directory -``` -git clone https://github.com/mkaz/libro.git -cd libro -pip install -e . -``` +`LIBRO_DATA_DIR` and `LIBRO_DATA_DIR_OVERRIDE` name directories. Libro uses `libro.db` and `covers/` inside them. The `LIBRO_DB` variables name a database file directly and are retained for compatibility. -## Setup +## Vite template notes -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. +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. -**Database locations:** +Currently, two official plugins are available: -The following order is used to determine the database location: +- [@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/) -1. Using the `--db` flag on command-line. +## React Compiler -2. `libro.db` in current directory +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). -3. Environment variable `LIBRO_DB` to specify custom file/location +## Expanding the ESLint configuration -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` +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... -For example, if you want to create a new database file in the current directory, you can use the following command: + // 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, -``` -libro --db ./libro.db + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) ``` -### Import from Goodreads +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: -Libro can import your reading history from a Goodreads export CSV file. +```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 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/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 0000000..d1dcd3f --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,103 @@ +import { spawn } from 'node:child_process' +import path from 'node:path' +import process from 'node:process' + +function usage() { + console.log('Usage: npm run dev [-- --data-dir | --db ]') +} + +function parseStoragePath(args) { + let storagePath = null + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index] + + if (argument === '--help' || argument === '-h') { + usage() + process.exit(0) + } + + if (argument === '--data-dir') { + const value = args[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('--data-dir requires a directory.') + } + storagePath = { type: 'directory', path: value } + index += 1 + continue + } + + if (argument.startsWith('--data-dir=')) { + const value = argument.slice('--data-dir='.length) + if (!value) throw new Error('--data-dir requires a directory.') + storagePath = { type: 'directory', path: value } + continue + } + + if (argument === '--db') { + const value = args[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('--db requires a database path.') + } + storagePath = { type: 'database', path: value } + index += 1 + continue + } + + if (argument.startsWith('--db=')) { + const value = argument.slice('--db='.length) + if (!value) throw new Error('--db requires a database path.') + storagePath = { type: 'database', path: value } + continue + } + + throw new Error(`Unknown option: ${argument}`) + } + + return storagePath ? { ...storagePath, path: path.resolve(storagePath.path) } : null +} + +function run(command, args, env = process.env) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { env, stdio: 'inherit' }) + child.once('error', reject) + child.once('exit', (code, signal) => { + if (signal) { + reject(new Error(`${command} stopped with ${signal}.`)) + } else if (code !== 0) { + reject(new Error(`${command} exited with code ${code}.`)) + } else { + resolve() + } + }) + }) +} + +async function main() { + const storagePath = parseStoragePath(process.argv.slice(2)) + const env = { ...process.env } + + if (storagePath?.type === 'directory') { + env.LIBRO_DATA_DIR_OVERRIDE = storagePath.path + console.log(`Using development data directory: ${storagePath.path}`) + } else if (storagePath) { + env.LIBRO_DB_OVERRIDE = storagePath.path + console.log(`Using development database: ${storagePath.path}`) + } + + await run('npm', ['run', 'build:electron'], env) + await run( + 'concurrently', + [ + '-k', + 'vite', + 'wait-on http://127.0.0.1:5173 && electron dist-electron/main.cjs', + ], + env, + ) +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..cbf066d --- /dev/null +++ b/shared/types.ts @@ -0,0 +1,204 @@ +export interface DbInfo { + path: string + exists: boolean +} + +export type BookCoverSource = 'openlibrary' | 'googlebooks' + +export interface BookCoverSelection { + source: BookCoverSource + sourceId: string + imageUrl: string +} + +export interface BookCoverCandidate extends BookCoverSelection { + thumbnailUrl: string + title: string + author: string + publicationYear: string | null + sourceUrl: string +} + +export interface AddBookCoverInput { + bookId: number + cover: BookCoverSelection +} + +export interface AddBookCoverResult { + coverSaved: 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 + cover: BookCoverSelection | null +} + +export interface AddBookReviewResult { + bookId: number + reviewId: number + usedExistingBook: boolean + coverSaved: boolean +} + +export interface UpdateReviewInput { + reviewId: number + dateRead: string | null + rating: number | null + review: string | null +} + +export interface ReviewRow { + reviewId: number + bookId: number + title: string + author: string + genre: string | null + hasCover: boolean + rating: number | null + dateRead: string | null +} + +export interface BookReviewDetail { + reviewId: number + dateRead: string | null + rating: number | null + review: string | null +} + +export interface BookDetail { + id: number + title: string + author: string + pubYear: number | null + pages: number | null + genre: string | null + hasCover: boolean + reviews: BookReviewDetail[] +} + +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 AddNewBookToListInput { + listId: number + title: string + author: string + pubYear: number | null + pages: number | null + genre: string | null +} + +export interface AddNewBookToListResult { + bookId: number + usedExistingBook: boolean +} + +export interface LibroApi { + app: { + getDbInfo: () => Promise + } + books: { + findCoverCandidates: (title: string, author: string) => Promise + addBookReview: (input: AddBookReviewInput) => Promise + addBookCover: (input: AddBookCoverInput) => Promise + updateReview: (input: UpdateReviewInput) => Promise + searchBooks: (term: string, listId?: number) => Promise + getBookDetail: (bookId: number) => Promise + } + reports: { + getYearCounts: () => Promise + getAuthorCounts: (minimumBooks?: number, includeUndated?: boolean) => Promise + getReviews: (filters?: ReviewFilters) => Promise + } + lists: { + getAll: () => Promise + getById: (listId: number) => Promise + create: (input: CreateListInput) => Promise + addBooks: (input: AddBooksToListInput) => Promise + addNewBook: (input: AddNewBookToListInput) => Promise + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..be537d0 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,76 @@ +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 { SearchView } from './features/search/SearchView' +import { api } from './lib/api' + +type View = 'books' | 'reports' | 'search' | 'lists' | 'add' + +const navItems: Array<{ id: View; label: string }> = [ + { id: 'books', label: 'Books' }, + { id: 'reports', label: 'Reports' }, + { id: 'search', label: 'Search' }, + { id: 'lists', label: 'Lists' }, + { id: 'add', label: 'Add Review' }, +] + +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 === 'search' ? : 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..cb2a0b8 --- /dev/null +++ b/src/features/add/AddBookReviewForm.tsx @@ -0,0 +1,320 @@ +import { useState } from 'react' + +import type { + AddBookReviewInput, + AddBookReviewResult, + BookCoverCandidate, +} from '../../../shared/types' +import { api } from '../../lib/api' +import { StarRating } from '../../lib/StarRating' + +const initialForm: AddBookReviewInput = { + title: '', + author: '', + pubYear: null, + pages: null, + genre: null, + dateRead: null, + rating: null, + review: null, + cover: 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 [coverCandidates, setCoverCandidates] = useState(null) + const [selectedCover, setSelectedCover] = useState(null) + const [lookingUpCovers, setLookingUpCovers] = useState(false) + const [submitting, setSubmitting] = useState(false) + + function resetCoverSelection() { + setCoverCandidates(null) + setSelectedCover(null) + } + + async function findCovers() { + setLookingUpCovers(true) + setError(null) + setResult(null) + + try { + const candidates = await api.books.findCoverCandidates(form.title, form.author) + setCoverCandidates(candidates) + } catch (lookupError: unknown) { + setCoverCandidates([]) + setError(lookupError instanceof Error ? lookupError.message : 'Failed to find covers.') + } finally { + setLookingUpCovers(false) + } + } + + async function saveBook(cover: BookCoverCandidate | null) { + 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, + cover: cover + ? { source: cover.source, sourceId: cover.sourceId, imageUrl: cover.imageUrl } + : null, + } + const response = await api.books.addBookReview(payload) + setResult(response) + setForm(initialForm) + resetCoverSelection() + } catch (submitError: unknown) { + setError(submitError instanceof Error ? submitError.message : 'Failed to save book.') + } finally { + setSubmitting(false) + } + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + if (coverCandidates === null) { + await findCovers() + return + } + if (selectedCover) await saveBook(selectedCover) + } + + 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.'} + {result.coverSaved ? ' Cover saved locally.' : ''} +
+ ) : null} + + {error ?
{error}
: null} + +
+
+ + { + setForm((current) => ({ ...current, title: event.target.value })) + resetCoverSelection() + }} + required + /> +
+ +
+ + { + setForm((current) => ({ ...current, author: event.target.value })) + resetCoverSelection() + }} + 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 })) + } + /> +
+ +
+ + setForm((current) => ({ ...current, rating }))} + /> +
+ +
+ +