mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-09-24 23:12:10 +08:00
Merge pull request #223 from OGRLEAF/feat/add-calibre-cli
feat: add Calibre CLI harness for e-book library management
This commit is contained in:
@@ -93,6 +93,7 @@
|
||||
!/quietshrink/
|
||||
!/mailchimp/
|
||||
!/3MF/
|
||||
!/calibre/
|
||||
# Step 5: Inside each software dir, ignore everything (including dotfiles)
|
||||
/gimp/*
|
||||
/gimp/.*
|
||||
@@ -192,6 +193,8 @@
|
||||
/quietshrink/.*
|
||||
/mailchimp/*
|
||||
/mailchimp/.*
|
||||
/calibre/*
|
||||
/calibre/.*
|
||||
|
||||
# Step 6: ...except agent-harness/
|
||||
!/gimp/agent-harness/
|
||||
@@ -241,6 +244,7 @@
|
||||
!/QGIS/agent-harness/
|
||||
!/n8n/agent-harness/
|
||||
!/obsidian/agent-harness/
|
||||
!/calibre/agent-harness/
|
||||
!/safari/
|
||||
!/safari/agent-harness/
|
||||
!/unrealinsights/agent-harness/
|
||||
|
||||
@@ -880,6 +880,13 @@ Each application received complete, production-ready CLI interfaces — not demo
|
||||
<td align="center">✅ <a href="zotero/agent-harness/">New</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>📖 <a href="calibre/agent-harness/">Calibre</a></strong></td>
|
||||
<td>E-book Library Management</td>
|
||||
<td><code>cli-anything-calibre</code></td>
|
||||
<td>calibredb + ebook-convert + ebook-meta</td>
|
||||
<td align="center">✅ <a href="calibre/agent-harness/">58</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>📝 <a href="mubu/agent-harness/">Mubu</a></strong></td>
|
||||
<td>Knowledge Management & Outlining</td>
|
||||
<td><code>cli-anything-mubu</code></td>
|
||||
@@ -1191,6 +1198,7 @@ cli-anything/
|
||||
├── 📄 libreoffice/agent-harness/ # LibreOffice CLI (158 tests)
|
||||
├── 📧 mailchimp/agent-harness/ # Mailchimp Marketing API CLI (303 commands, 36 unit tests)
|
||||
├── 📚 zotero/agent-harness/ # Zotero CLI (new, write import support)
|
||||
├── 📖 calibre/agent-harness/ # Calibre CLI (58 tests: 38 unit + 20 E2E)
|
||||
├── 📝 mubu/agent-harness/ # Mubu CLI (96 tests)
|
||||
├── 📹 obs-studio/agent-harness/ # OBS Studio CLI (153 tests)
|
||||
├── 📱 nslogger/agent-harness/ # NSLogger CLI (139 tests)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# Package build
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.egg
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Session files
|
||||
.cli-anything-calibre/
|
||||
@@ -0,0 +1,160 @@
|
||||
# Calibre CLI Harness — SOP
|
||||
|
||||
## Overview
|
||||
|
||||
Calibre is the world's most popular e-book management application. This CLI harness wraps the real Calibre tools (`calibredb`, `ebook-convert`, `ebook-meta`) to give AI agents and scripts a clean, structured interface for library management, metadata editing, and format conversion.
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### Real Software Used
|
||||
|
||||
All heavy lifting is done by the **actual Calibre binaries**:
|
||||
|
||||
| Task | Tool | Command Pattern |
|
||||
|------|------|----------------|
|
||||
| Library operations | `calibredb` | `calibredb --with-library <lib> <cmd>` |
|
||||
| Format conversion | `ebook-convert` | `ebook-convert input.epub output.mobi` |
|
||||
| File metadata | `ebook-meta` | `ebook-meta book.epub --field value` |
|
||||
|
||||
The CLI harness is a **stateful wrapper** — it tracks the library path in a session file and passes it to every `calibredb` invocation. It never reimplements library logic.
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
~/Calibre Library/
|
||||
├── metadata.db # SQLite database (all metadata)
|
||||
├── Author Name (ID)/
|
||||
│ ├── Book Title (ID)/
|
||||
│ │ ├── cover.jpg # Cover image
|
||||
│ │ ├── metadata.opf # OPF metadata file
|
||||
│ │ ├── Title - Author.epub
|
||||
│ │ └── Title - Author.mobi
|
||||
```
|
||||
|
||||
### Session State
|
||||
|
||||
The CLI maintains a JSON session file at `~/.cli-anything-calibre/session.json`:
|
||||
```json
|
||||
{
|
||||
"library_path": "/path/to/Calibre Library",
|
||||
"last_command": "list",
|
||||
"filters": {}
|
||||
}
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
### `library` - Library management
|
||||
- `connect <path>` — Set active library path
|
||||
- `info` — Show library stats (book count, formats, size)
|
||||
- `check` — Verify library integrity
|
||||
|
||||
### `books` — Book operations (wrap `calibredb`)
|
||||
- `list` — List books with filtering and sorting
|
||||
- `search <query>` — Search using Calibre query language
|
||||
- `add <files>` — Add book files to library
|
||||
- `remove <ids>` — Remove books (move to trash)
|
||||
- `show <id>` — Show full metadata for a book
|
||||
- `export <ids>` — Export books to directory
|
||||
|
||||
### `meta` — Metadata editing (wrap `calibredb set_metadata`)
|
||||
- `set <id> <field> <value>` — Set a metadata field
|
||||
- `get <id> [field]` — Get metadata (all or specific field)
|
||||
- `embed <ids>` — Embed metadata into book files
|
||||
|
||||
### `formats` — Format management (wrap `calibredb` + `ebook-convert`)
|
||||
- `list <id>` — List available formats for a book
|
||||
- `add <id> <file>` — Add a format to a book
|
||||
- `remove <id> <fmt>` — Remove a format from a book
|
||||
- `convert <id> <input_fmt> <output_fmt>` — Convert book format
|
||||
|
||||
### `custom` — Custom columns (wrap `calibredb`)
|
||||
- `list` — List all custom columns
|
||||
- `add <label> <name> <type>` — Create custom column
|
||||
- `remove <label>` — Delete custom column
|
||||
- `set <id> <label> <value>` — Set custom field value
|
||||
|
||||
### `catalog` — Catalog generation
|
||||
- `generate <output>` — Generate a catalog of the library
|
||||
|
||||
## Calibre Query Language
|
||||
|
||||
Used with `books search` and `books list --search`:
|
||||
|
||||
```
|
||||
author:asimov # Author contains "asimov"
|
||||
title:"Foundation" # Title phrase
|
||||
tags:fiction # Tag match
|
||||
rating:>3 # Rating greater than 3
|
||||
series:"Foundation" # Series match
|
||||
pubdate:[2020-01-01,2021-12-31] # Date range
|
||||
identifiers:isbn:1234567890 # Specific identifier
|
||||
has:cover # Has cover image
|
||||
not:tags:fiction # Negation
|
||||
author:asimov and tags:scifi # Boolean AND
|
||||
```
|
||||
|
||||
## Key Metadata Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `title` | text | Book title |
|
||||
| `authors` | text | Author names (&-separated) |
|
||||
| `tags` | text | Comma-separated tags |
|
||||
| `series` | text | Series name |
|
||||
| `series_index` | float | Position in series |
|
||||
| `rating` | float | Rating 1-5 |
|
||||
| `publisher` | text | Publisher name |
|
||||
| `pubdate` | datetime | Publication date |
|
||||
| `comments` | text | Description/comments |
|
||||
| `cover` | path | Path to cover image |
|
||||
| `languages` | text | Language codes |
|
||||
| `identifiers` | text | ISBN, ASIN, etc. (`type:value`) |
|
||||
|
||||
## Supported Formats
|
||||
|
||||
**Input:** EPUB, MOBI, AZW, AZW3, PDF, HTML, DOCX, ODT, FB2, TXT, RTF, LIT, and more
|
||||
|
||||
**Output (conversion):** EPUB, MOBI, AZW3, PDF, HTML, DOCX, TXT, and more
|
||||
|
||||
## Installation Requirements
|
||||
|
||||
```bash
|
||||
# Calibre must be installed (hard dependency)
|
||||
sudo apt-get install calibre # Debian/Ubuntu
|
||||
brew install --cask calibre # macOS
|
||||
# Or download from: https://calibre-ebook.com/download
|
||||
|
||||
# Verify tools are in PATH:
|
||||
which calibredb
|
||||
which ebook-convert
|
||||
which ebook-meta
|
||||
```
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
- Unit tests: synthetic CLI argument parsing, query building, JSON output
|
||||
- E2E tests with `calibredb`: real library operations, real output verification
|
||||
- E2E tests with `ebook-convert`: real format conversion, output file validation
|
||||
- Subprocess tests via `_resolve_cli("cli-anything-calibre")`
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Import a library of epubs and tag them:
|
||||
```bash
|
||||
cli-anything-calibre library connect ~/my-books
|
||||
cli-anything-calibre books add *.epub
|
||||
cli-anything-calibre books search "not:tags:read" --json
|
||||
```
|
||||
|
||||
### Convert EPUB to MOBI for Kindle:
|
||||
```bash
|
||||
cli-anything-calibre formats convert 42 EPUB MOBI
|
||||
```
|
||||
|
||||
### Batch metadata update:
|
||||
```bash
|
||||
cli-anything-calibre meta set 42 series "Foundation"
|
||||
cli-anything-calibre meta set 42 series_index 1
|
||||
cli-anything-calibre meta set 42 tags "scifi,classic"
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
# FIX_NOTES.md - PR #223 Calibre Harness Validation
|
||||
|
||||
## Blocker Status
|
||||
|
||||
- No code blocker was identified in the Calibre harness.
|
||||
- Remaining validation/documentation blocker addressed by adding no-Calibre subprocess smoke coverage and explicit real-backend validation steps.
|
||||
|
||||
## No-Calibre Smoke Validation
|
||||
|
||||
These commands validate importability, Click entrypoint behavior, and missing-library
|
||||
error handling without requiring `calibredb`, `ebook-convert`, or `ebook-meta`.
|
||||
|
||||
```bash
|
||||
cd calibre/agent-harness
|
||||
python -m py_compile \
|
||||
cli_anything/calibre/calibre_cli.py \
|
||||
cli_anything/calibre/core/*.py \
|
||||
cli_anything/calibre/utils/*.py
|
||||
python -m pytest cli_anything/calibre/tests/test_core.py -v
|
||||
```
|
||||
|
||||
To require the installed console script for smoke validation:
|
||||
|
||||
```bash
|
||||
cd calibre/agent-harness
|
||||
pip install -e .
|
||||
CLI_ANYTHING_FORCE_INSTALLED=1 python -m pytest \
|
||||
cli_anything/calibre/tests/test_core.py::TestCLISubprocessSmoke -v
|
||||
```
|
||||
|
||||
## Real Calibre Backend Validation
|
||||
|
||||
Install Calibre first and confirm all wrapped commands resolve:
|
||||
|
||||
```bash
|
||||
which calibredb
|
||||
which ebook-convert
|
||||
which ebook-meta
|
||||
```
|
||||
|
||||
Then run the E2E suite:
|
||||
|
||||
```bash
|
||||
cd calibre/agent-harness
|
||||
pip install -e .
|
||||
CLI_ANYTHING_FORCE_INSTALLED=1 python -m pytest \
|
||||
cli_anything/calibre/tests/test_full_e2e.py -v -s
|
||||
```
|
||||
|
||||
Expected E2E coverage:
|
||||
|
||||
- Temporary Calibre libraries are created for test isolation.
|
||||
- Generated EPUB fixtures are imported through `calibredb`.
|
||||
- Metadata changes are round-tripped through the real backend.
|
||||
- EPUB to TXT/MOBI conversion runs through `ebook-convert`.
|
||||
- Exported and converted artifacts are checked for existence and nonzero size.
|
||||
|
||||
## Remaining Gaps
|
||||
|
||||
- Catalog generation, file-level metadata embedding, and real custom-column workflows are documented as E2E gaps in `cli_anything/calibre/tests/TEST.md`.
|
||||
@@ -0,0 +1,214 @@
|
||||
# cli-anything-calibre
|
||||
|
||||
A CLI harness for [Calibre](https://calibre-ebook.com/), the powerful e-book management application. Part of the [cli-anything](https://github.com/HKUDS/CLI-Anything) toolkit.
|
||||
|
||||
## What It Does
|
||||
|
||||
`cli-anything-calibre` gives AI agents and scripts a clean, structured command-line interface to Calibre's library operations, metadata editing, and format conversion — without reimplementing any of Calibre's logic. It wraps the real tools:
|
||||
|
||||
- **`calibredb`** — library management, metadata, import/export
|
||||
- **`ebook-convert`** — format conversion (EPUB → MOBI, PDF, TXT, etc.)
|
||||
- **`ebook-meta`** — standalone file metadata editing
|
||||
|
||||
## Requirements
|
||||
|
||||
### 1. Install Calibre (hard dependency)
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt-get install calibre
|
||||
|
||||
# Fedora/RHEL
|
||||
sudo dnf install calibre
|
||||
|
||||
# macOS
|
||||
brew install --cask calibre
|
||||
|
||||
# Windows / other
|
||||
# Download from: https://calibre-ebook.com/download
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
which calibredb # must be in PATH
|
||||
which ebook-convert # must be in PATH
|
||||
```
|
||||
|
||||
### 2. Install cli-anything-calibre
|
||||
|
||||
```bash
|
||||
cd agent-harness
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
which cli-anything-calibre
|
||||
cli-anything-calibre --version
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Connect to your Calibre library
|
||||
cli-anything-calibre library connect ~/Calibre\ Library
|
||||
|
||||
# List all books
|
||||
cli-anything-calibre books list
|
||||
|
||||
# List as JSON (for agent consumption)
|
||||
cli-anything-calibre --json books list
|
||||
|
||||
# Search books
|
||||
cli-anything-calibre books search "author:asimov"
|
||||
|
||||
# Show metadata for book ID 42
|
||||
cli-anything-calibre books show 42
|
||||
|
||||
# Set metadata
|
||||
cli-anything-calibre meta set 42 series "Foundation"
|
||||
cli-anything-calibre meta set 42 series_index 1
|
||||
|
||||
# Convert EPUB to MOBI
|
||||
cli-anything-calibre formats convert 42 EPUB MOBI
|
||||
|
||||
# Export books to directory
|
||||
cli-anything-calibre books export 42 --to-dir ~/kindle-transfer
|
||||
|
||||
# Enter interactive REPL (default when no subcommand given)
|
||||
cli-anything-calibre
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Library Management
|
||||
|
||||
```bash
|
||||
cli-anything-calibre library connect <path> # Set active library
|
||||
cli-anything-calibre library info # Library statistics
|
||||
cli-anything-calibre library check # Verify integrity
|
||||
```
|
||||
|
||||
### Book Operations
|
||||
|
||||
```bash
|
||||
cli-anything-calibre books list [--search QUERY] [--sort FIELD] [--limit N]
|
||||
cli-anything-calibre books search "title:Foundation"
|
||||
cli-anything-calibre books add book.epub [book2.epub ...]
|
||||
cli-anything-calibre books remove 42,43,44
|
||||
cli-anything-calibre books show 42
|
||||
cli-anything-calibre books export 42 --to-dir /path/to/dir
|
||||
```
|
||||
|
||||
### Metadata Editing
|
||||
|
||||
```bash
|
||||
cli-anything-calibre meta get 42 # All metadata
|
||||
cli-anything-calibre meta get 42 title # Single field
|
||||
cli-anything-calibre meta set 42 title "New Title"
|
||||
cli-anything-calibre meta set 42 authors "Author One & Author Two"
|
||||
cli-anything-calibre meta set 42 tags "scifi,classic,favorite"
|
||||
cli-anything-calibre meta set 42 rating 5
|
||||
cli-anything-calibre meta embed 42 # Embed into book file
|
||||
```
|
||||
|
||||
### Format Management
|
||||
|
||||
```bash
|
||||
cli-anything-calibre formats list 42
|
||||
cli-anything-calibre formats add 42 /path/to/book.mobi
|
||||
cli-anything-calibre formats remove 42 MOBI
|
||||
cli-anything-calibre formats convert 42 EPUB MOBI
|
||||
cli-anything-calibre formats convert 42 EPUB PDF --output /tmp/book.pdf
|
||||
```
|
||||
|
||||
### Custom Columns
|
||||
|
||||
```bash
|
||||
cli-anything-calibre custom list
|
||||
cli-anything-calibre custom add "#genre" "Genre" text
|
||||
cli-anything-calibre custom add "#read_date" "Date Read" datetime
|
||||
cli-anything-calibre custom set 42 "#genre" "Science Fiction"
|
||||
cli-anything-calibre custom remove "#genre"
|
||||
```
|
||||
|
||||
### Catalog Generation
|
||||
|
||||
```bash
|
||||
cli-anything-calibre catalog /output/catalog.epub --title "My Library"
|
||||
cli-anything-calibre catalog /output/catalog.csv --format csv
|
||||
```
|
||||
|
||||
## JSON Output Mode
|
||||
|
||||
All commands support `--json` for machine-readable output:
|
||||
|
||||
```bash
|
||||
cli-anything-calibre --json books list
|
||||
cli-anything-calibre --json books search "author:asimov"
|
||||
cli-anything-calibre --json meta get 42
|
||||
cli-anything-calibre --json library info
|
||||
```
|
||||
|
||||
## Calibre Query Language
|
||||
|
||||
Used with `books list --search` and `books search`:
|
||||
|
||||
```
|
||||
author:asimov # Author contains "asimov"
|
||||
title:"Foundation" # Title phrase
|
||||
tags:fiction # Tag match
|
||||
rating:>3 # Rating greater than 3
|
||||
series:"Foundation" # Series exact match
|
||||
pubdate:[2020-01-01,2021-12-31] # Date range
|
||||
identifiers:isbn:9780553293357 # ISBN lookup
|
||||
has:cover # Books with covers
|
||||
not:tags:read # Unread books
|
||||
author:asimov and tags:scifi # Boolean AND
|
||||
```
|
||||
|
||||
## Session State
|
||||
|
||||
The active library path is persisted in `~/.cli-anything-calibre/session.json`.
|
||||
Override with the `CALIBRE_LIBRARY` environment variable.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd agent-harness
|
||||
|
||||
# Syntax check
|
||||
python -m py_compile \
|
||||
cli_anything/calibre/calibre_cli.py \
|
||||
cli_anything/calibre/core/*.py \
|
||||
cli_anything/calibre/utils/*.py
|
||||
|
||||
# Unit and CLI smoke tests (no Calibre required)
|
||||
python -m pytest cli_anything/calibre/tests/test_core.py -v
|
||||
|
||||
# Installed-command smoke tests (no Calibre required)
|
||||
pip install -e .
|
||||
CLI_ANYTHING_FORCE_INSTALLED=1 python -m pytest \
|
||||
cli_anything/calibre/tests/test_core.py::TestCLISubprocessSmoke -v
|
||||
|
||||
# Full E2E tests (Calibre required)
|
||||
python -m pytest cli_anything/calibre/tests/test_full_e2e.py -v -s
|
||||
|
||||
# All tests with installed binary and real Calibre backend
|
||||
CLI_ANYTHING_FORCE_INSTALLED=1 python -m pytest cli_anything/calibre/tests/ -v -s
|
||||
```
|
||||
|
||||
### Real Backend Validation
|
||||
|
||||
Before running E2E validation, verify Calibre's commands are on PATH:
|
||||
|
||||
```bash
|
||||
which calibredb
|
||||
which ebook-convert
|
||||
which ebook-meta
|
||||
```
|
||||
|
||||
The E2E suite creates temporary Calibre libraries, imports generated EPUB files,
|
||||
updates metadata with `calibredb`, converts EPUB files with `ebook-convert`, and
|
||||
checks exported/converted artifacts for real output files. These tests require a
|
||||
real Calibre installation; `test_core.py` remains the no-Calibre validation path.
|
||||
@@ -0,0 +1,3 @@
|
||||
"""cli-anything-calibre — CLI harness for the Calibre e-book manager."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Allow running as: python3 -m cli_anything.calibre"""
|
||||
from cli_anything.calibre.calibre_cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,818 @@
|
||||
"""cli-anything-calibre — Main CLI entry point.
|
||||
|
||||
Provides a Click-based command-line interface for the Calibre e-book manager.
|
||||
Wraps calibredb, ebook-convert, and ebook-meta as the real backend.
|
||||
|
||||
Usage:
|
||||
cli-anything-calibre # Enter interactive REPL
|
||||
cli-anything-calibre library connect <path> # Set active library
|
||||
cli-anything-calibre books list # List books
|
||||
cli-anything-calibre books search "author:asimov"
|
||||
cli-anything-calibre meta set 42 title "New Title"
|
||||
cli-anything-calibre formats convert 42 EPUB MOBI
|
||||
"""
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from cli_anything.calibre import __version__
|
||||
from cli_anything.calibre.core import session as _session
|
||||
from cli_anything.calibre.core import library as _library
|
||||
from cli_anything.calibre.core import metadata as _metadata
|
||||
from cli_anything.calibre.core import formats as _formats
|
||||
from cli_anything.calibre.core import custom as _custom
|
||||
from cli_anything.calibre.core import export as _export
|
||||
|
||||
|
||||
# ── Output helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _out(data, as_json: bool):
|
||||
"""Print data as JSON or human-readable depending on --json flag."""
|
||||
if as_json:
|
||||
click.echo(json.dumps(data, indent=2, default=str))
|
||||
else:
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
click.echo(item)
|
||||
elif isinstance(data, dict):
|
||||
for k, v in data.items():
|
||||
if k not in ("stdout", "stderr", "raw_opf"):
|
||||
click.echo(f" {k}: {v}")
|
||||
else:
|
||||
click.echo(str(data))
|
||||
|
||||
|
||||
def _err(msg: str):
|
||||
click.echo(f" ✗ {msg}", err=True)
|
||||
|
||||
|
||||
def _ok(msg: str):
|
||||
click.echo(f" ✓ {msg}")
|
||||
|
||||
|
||||
# ── Root group ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||||
@click.option("--library", "library_path", default=None,
|
||||
help="Override active library path for this command")
|
||||
@click.version_option(__version__, prog_name="cli-anything-calibre")
|
||||
@click.pass_context
|
||||
def main(ctx, as_json, library_path):
|
||||
"""cli-anything-calibre — Calibre e-book manager CLI harness.
|
||||
|
||||
If no subcommand is given, enters the interactive REPL.
|
||||
"""
|
||||
ctx.ensure_object(dict)
|
||||
sess = _session.load_session()
|
||||
if library_path:
|
||||
sess["library_path"] = library_path
|
||||
ctx.obj["session"] = sess
|
||||
ctx.obj["as_json"] = as_json
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
ctx.invoke(repl)
|
||||
|
||||
|
||||
# ── REPL ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.pass_context
|
||||
def repl(ctx):
|
||||
"""Enter the interactive REPL session."""
|
||||
from cli_anything.calibre.utils.repl_skin import ReplSkin
|
||||
|
||||
sess = ctx.obj["session"] if ctx.obj else _session.load_session()
|
||||
skin = ReplSkin("calibre", version=__version__)
|
||||
skin.print_banner()
|
||||
|
||||
pt_session = skin.create_prompt_session()
|
||||
|
||||
repl_commands = {
|
||||
"library connect <path>": "Set active library",
|
||||
"library info": "Show library statistics",
|
||||
"books list": "List books",
|
||||
"books search <query>": "Search books",
|
||||
"books add <files>": "Add books to library",
|
||||
"books remove <ids>": "Remove books",
|
||||
"books show <id>": "Show book metadata",
|
||||
"books export <ids>": "Export books to directory",
|
||||
"books export-chapters <id>": "Export chapters as PDFs",
|
||||
"meta get <id>": "Get book metadata",
|
||||
"meta set <id> <f> <v>": "Set metadata field",
|
||||
"formats list <id>": "List book formats",
|
||||
"formats convert <id>": "Convert book format",
|
||||
"custom list": "List custom columns",
|
||||
"help": "Show this help",
|
||||
"quit": "Exit REPL",
|
||||
}
|
||||
|
||||
while True:
|
||||
lib_path = _session.get_library_path(sess)
|
||||
project_name = Path(lib_path).name if lib_path else ""
|
||||
|
||||
try:
|
||||
raw = skin.get_input(pt_session, project_name=project_name)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
if raw in ("quit", "exit", "q"):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
if raw in ("help", "?"):
|
||||
skin.help(repl_commands)
|
||||
continue
|
||||
|
||||
# Parse and dispatch via Click
|
||||
args = shlex.split(raw)
|
||||
try:
|
||||
main.main(args=args, obj={"session": sess, "as_json": False},
|
||||
standalone_mode=False)
|
||||
except SystemExit:
|
||||
pass
|
||||
except Exception as exc:
|
||||
skin.error(str(exc))
|
||||
|
||||
|
||||
# ── library group ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@main.group()
|
||||
def library():
|
||||
"""Library management commands."""
|
||||
|
||||
|
||||
@library.command("connect")
|
||||
@click.argument("path")
|
||||
@click.pass_context
|
||||
def library_connect(ctx, path):
|
||||
"""Set the active Calibre library path."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
resolved = str(Path(path).expanduser().resolve())
|
||||
if not Path(resolved).exists():
|
||||
_err(f"Path does not exist: {resolved}")
|
||||
sys.exit(1)
|
||||
|
||||
sess = _session.set_library_path(resolved, sess)
|
||||
if ctx.obj:
|
||||
ctx.obj["session"] = sess
|
||||
|
||||
result = {"library_path": resolved, "status": "connected"}
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
_ok(f"Library connected: {resolved}")
|
||||
|
||||
|
||||
@library.command("info")
|
||||
@click.pass_context
|
||||
def library_info(ctx):
|
||||
"""Show library statistics."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
info = _library.library_info(lib_path)
|
||||
except (RuntimeError, FileNotFoundError) as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(info, True)
|
||||
else:
|
||||
_ok(f"Library: {info['library_path']}")
|
||||
click.echo(f" books: {info['book_count']}")
|
||||
click.echo(f" db size: {info['db_size_bytes']:,} bytes")
|
||||
if info["format_counts"]:
|
||||
click.echo(" formats:")
|
||||
for fmt, cnt in sorted(info["format_counts"].items()):
|
||||
click.echo(f" {fmt}: {cnt}")
|
||||
|
||||
|
||||
@library.command("check")
|
||||
@click.pass_context
|
||||
def library_check(ctx):
|
||||
"""Verify library integrity using calibredb check_library."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _library.check_library(lib_path)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
_out(result, as_json)
|
||||
if not as_json:
|
||||
if result["ok"]:
|
||||
_ok("Library is healthy")
|
||||
else:
|
||||
_err(f"{len(result['issues'])} issues found")
|
||||
|
||||
|
||||
# ── books group ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@main.group()
|
||||
def books():
|
||||
"""Book management commands."""
|
||||
|
||||
|
||||
@books.command("list")
|
||||
@click.option("--search", "-s", default="", help="Calibre search query")
|
||||
@click.option("--sort", default="title", help="Sort field (title, authors, date, etc.)")
|
||||
@click.option("--limit", "-n", default=50, help="Maximum number of results")
|
||||
@click.option("--fields", "-f", default=None,
|
||||
help="Comma-separated fields to display")
|
||||
@click.pass_context
|
||||
def books_list(ctx, search, sort, limit, fields):
|
||||
"""List books in the library."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
field_list = fields.split(",") if fields else None
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
book_list = _library.list_books(lib_path, search=search, sort_by=sort,
|
||||
limit=limit, fields=field_list)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(book_list, True)
|
||||
else:
|
||||
if not book_list:
|
||||
click.echo(" No books found.")
|
||||
else:
|
||||
# Determine columns from actual returned fields (id is always first)
|
||||
actual_fields = list(book_list[0].keys())
|
||||
non_id_fields = [f for f in actual_fields if f != "id"]
|
||||
|
||||
# Build header dynamically
|
||||
header = f" {'ID':<5} " + " ".join(f"{f.capitalize():<30}" for f in non_id_fields)
|
||||
sep = f" {'─'*5} " + " ".join(f"{'─'*30}" for _ in non_id_fields)
|
||||
click.echo(header)
|
||||
click.echo(sep)
|
||||
for b in book_list:
|
||||
row = f" {b.get('id', ''):<5} " + " ".join(
|
||||
f"{str(b.get(f, ''))[:30]:<30}" for f in non_id_fields
|
||||
)
|
||||
click.echo(row)
|
||||
|
||||
|
||||
@books.command("search")
|
||||
@click.argument("query")
|
||||
@click.option("--limit", "-n", default=100, help="Max results")
|
||||
@click.pass_context
|
||||
def books_search(ctx, query, limit):
|
||||
"""Search books using Calibre query language. Returns book IDs."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
ids = _library.search_books(lib_path, query, limit=limit)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out({"query": query, "ids": ids, "count": len(ids)}, True)
|
||||
else:
|
||||
if ids:
|
||||
_ok(f"{len(ids)} books found: {', '.join(str(i) for i in ids)}")
|
||||
else:
|
||||
click.echo(" No books match the query.")
|
||||
|
||||
|
||||
@books.command("show")
|
||||
@click.argument("book_id", type=int)
|
||||
@click.pass_context
|
||||
def books_show(ctx, book_id):
|
||||
"""Show full metadata for a book."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
meta = _metadata.get_metadata(lib_path, book_id)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(meta, True)
|
||||
else:
|
||||
for k, v in meta.items():
|
||||
if k not in ("raw_opf",):
|
||||
click.echo(f" {k:<20}: {v}")
|
||||
|
||||
|
||||
@books.command("add")
|
||||
@click.argument("files", nargs=-1, required=True)
|
||||
@click.option("--automerge", default="ignore",
|
||||
type=click.Choice(["ignore", "overwrite", "new_record"]),
|
||||
help="How to handle duplicates")
|
||||
@click.pass_context
|
||||
def books_add(ctx, files, automerge):
|
||||
"""Add book files to the library."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _library.add_books(lib_path, list(files), automerge=automerge)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
_ok(f"Added {result['count']} book(s): IDs {result['added_ids']}")
|
||||
if result["duplicates"]:
|
||||
for d in result["duplicates"]:
|
||||
click.echo(f" ⚠ {d}")
|
||||
|
||||
|
||||
@books.command("remove")
|
||||
@click.argument("book_ids")
|
||||
@click.option("--permanent", is_flag=True, help="Delete permanently (bypass trash)")
|
||||
@click.pass_context
|
||||
def books_remove(ctx, book_ids, permanent):
|
||||
"""Remove books from the library. BOOK_IDS is a comma-separated list."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
ids = [int(i.strip()) for i in book_ids.split(",") if i.strip().isdigit()]
|
||||
if not ids:
|
||||
_err("No valid book IDs provided")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _library.remove_books(lib_path, ids, permanent=permanent)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
action = "permanently deleted" if permanent else "moved to trash"
|
||||
_ok(f"{result['count']} book(s) {action}")
|
||||
|
||||
|
||||
@books.command("export-chapters")
|
||||
@click.argument("book_id", type=int)
|
||||
@click.option("--to-dir", "-d", required=True, help="Output directory for chapter PDFs")
|
||||
@click.option(
|
||||
"--chapters", "-c", default=None,
|
||||
help="Chapter range to export, e.g. '3-7' or '5' (default: all chapters)",
|
||||
)
|
||||
@click.pass_context
|
||||
def books_export_chapters(ctx, book_id, to_dir, chapters):
|
||||
"""Export each chapter of a book as a separate PDF file.
|
||||
|
||||
Requires the book to have an EPUB format in the library.
|
||||
If it doesn't, convert it first with: formats convert BOOK_ID <FMT> EPUB
|
||||
|
||||
\b
|
||||
Examples:
|
||||
books export-chapters 42 --to-dir ./pdfs
|
||||
books export-chapters 42 --to-dir ./pdfs --chapters 1-5
|
||||
books export-chapters 42 --to-dir ./pdfs --chapters 3
|
||||
"""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
chapter_range = None
|
||||
if chapters:
|
||||
if "-" in chapters:
|
||||
parts = chapters.split("-", 1)
|
||||
try:
|
||||
chapter_range = (int(parts[0]), int(parts[1]))
|
||||
except ValueError:
|
||||
_err("Invalid --chapters range. Use '1-5' or a single number like '3'.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
try:
|
||||
n = int(chapters)
|
||||
chapter_range = (n, n)
|
||||
except ValueError:
|
||||
_err("Invalid --chapters value. Use '1-5' or a single number like '3'.")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _export.export_chapters_pdf(lib_path, book_id, to_dir, chapter_range)
|
||||
except (RuntimeError, FileNotFoundError) as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
_ok(
|
||||
f"Exported {result['exported_chapters']}/{result['total_chapters']} "
|
||||
f"chapters to {to_dir}"
|
||||
)
|
||||
for pdf in result["exported_pdfs"]:
|
||||
click.echo(f" [{pdf['index']:03d}] {pdf['title']} ({pdf['size']:,} bytes)")
|
||||
|
||||
|
||||
@books.command("export")
|
||||
@click.argument("book_ids", default="")
|
||||
@click.option("--to-dir", "-d", required=True, help="Output directory")
|
||||
@click.option("--formats", "-f", default=None,
|
||||
help="Comma-separated formats to export (e.g. 'EPUB,MOBI')")
|
||||
@click.option("--all", "export_all", is_flag=True,
|
||||
help="Export all books in library (ignores book_ids)")
|
||||
@click.pass_context
|
||||
def books_export(ctx, book_ids, to_dir, formats, export_all):
|
||||
"""Export books to a directory.
|
||||
|
||||
BOOK_IDS is comma-separated (e.g. '42,43,44').
|
||||
Use --all to export entire library instead.
|
||||
"""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
if export_all:
|
||||
ids = []
|
||||
else:
|
||||
ids = [int(i.strip()) for i in book_ids.split(",") if i.strip().isdigit()]
|
||||
if not ids:
|
||||
_err("No valid book IDs (or use --all to export entire library)")
|
||||
sys.exit(1)
|
||||
|
||||
format_list = formats.split(",") if formats else None
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _export.export_books(
|
||||
lib_path, ids, to_dir,
|
||||
formats=format_list,
|
||||
export_all=export_all,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
action = "all books" if export_all else f"book IDs {ids}"
|
||||
_ok(f"Exported {result['count']} file(s) ({action}) to {to_dir}")
|
||||
|
||||
|
||||
# ── meta group ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@main.group()
|
||||
def meta():
|
||||
"""Metadata editing commands."""
|
||||
|
||||
|
||||
@meta.command("get")
|
||||
@click.argument("book_id", type=int)
|
||||
@click.argument("field", default=None, required=False)
|
||||
@click.pass_context
|
||||
def meta_get(ctx, book_id, field):
|
||||
"""Get metadata for a book. Optionally specify a single FIELD."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
meta_dict = _metadata.get_metadata(lib_path, book_id)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if field:
|
||||
value = meta_dict.get(field)
|
||||
if as_json:
|
||||
_out({"book_id": book_id, "field": field, "value": value}, True)
|
||||
else:
|
||||
click.echo(f" {field}: {value}")
|
||||
else:
|
||||
if as_json:
|
||||
_out(meta_dict, True)
|
||||
else:
|
||||
for k, v in meta_dict.items():
|
||||
if k != "raw_opf":
|
||||
click.echo(f" {k:<20}: {v}")
|
||||
|
||||
|
||||
@meta.command("set")
|
||||
@click.argument("book_id", type=int)
|
||||
@click.argument("field")
|
||||
@click.argument("value")
|
||||
@click.pass_context
|
||||
def meta_set(ctx, book_id, field, value):
|
||||
"""Set a metadata FIELD on a book to VALUE."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _metadata.set_metadata(lib_path, book_id, field, value)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
_ok(f"Set {field} = {value!r} on book {book_id}")
|
||||
|
||||
|
||||
@meta.command("embed")
|
||||
@click.argument("book_ids")
|
||||
@click.pass_context
|
||||
def meta_embed(ctx, book_ids):
|
||||
"""Embed library metadata into the actual book files. BOOK_IDS is comma-separated."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
ids = [int(i.strip()) for i in book_ids.split(",") if i.strip().isdigit()]
|
||||
if not ids:
|
||||
_err("No valid book IDs")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _metadata.embed_metadata(lib_path, ids)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
_ok(f"Embedded metadata into {result['count']} book(s)")
|
||||
|
||||
|
||||
# ── formats group ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@main.group()
|
||||
def formats():
|
||||
"""Format management and conversion commands."""
|
||||
|
||||
|
||||
@formats.command("list")
|
||||
@click.argument("book_id", type=int)
|
||||
@click.pass_context
|
||||
def formats_list(ctx, book_id):
|
||||
"""List available formats for a book."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _formats.list_formats(lib_path, book_id)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
if result["formats"]:
|
||||
_ok(f"Book {book_id} formats: {', '.join(result['formats'])}")
|
||||
else:
|
||||
click.echo(f" Book {book_id} has no formats in the library.")
|
||||
|
||||
|
||||
@formats.command("add")
|
||||
@click.argument("book_id", type=int)
|
||||
@click.argument("file_path")
|
||||
@click.pass_context
|
||||
def formats_add(ctx, book_id, file_path):
|
||||
"""Add a format file to a book in the library."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _formats.add_format(lib_path, book_id, file_path)
|
||||
except (RuntimeError, FileNotFoundError) as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
_ok(f"Added {result['format']} to book {book_id}")
|
||||
|
||||
|
||||
@formats.command("remove")
|
||||
@click.argument("book_id", type=int)
|
||||
@click.argument("fmt")
|
||||
@click.pass_context
|
||||
def formats_remove(ctx, book_id, fmt):
|
||||
"""Remove a format from a book."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _formats.remove_format(lib_path, book_id, fmt)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
_ok(f"Removed {fmt.upper()} from book {book_id}")
|
||||
|
||||
|
||||
@formats.command("convert")
|
||||
@click.argument("book_id", type=int)
|
||||
@click.argument("input_fmt")
|
||||
@click.argument("output_fmt")
|
||||
@click.option("--output", "-o", default=None,
|
||||
help="Output file path (defaults to temp dir, auto-added to library)")
|
||||
@click.option("--no-add", is_flag=True,
|
||||
help="Do not add converted file back to library")
|
||||
@click.option("--option", "-x", multiple=True,
|
||||
help="Extra ebook-convert options (can repeat)")
|
||||
@click.pass_context
|
||||
def formats_convert(ctx, book_id, input_fmt, output_fmt, output, no_add, option):
|
||||
"""Convert a book from INPUT_FMT to OUTPUT_FMT using ebook-convert."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _formats.convert_format(
|
||||
lib_path, book_id, input_fmt, output_fmt,
|
||||
output_path=output,
|
||||
extra_options=list(option),
|
||||
add_to_library=not no_add,
|
||||
)
|
||||
except (RuntimeError, FileNotFoundError) as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
_ok(f"Converted book {book_id}: {input_fmt} → {output_fmt}")
|
||||
click.echo(f" Output: {result['output_path']} ({result['output_size']:,} bytes)")
|
||||
|
||||
|
||||
# ── custom group ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@main.group()
|
||||
def custom():
|
||||
"""Custom column management commands."""
|
||||
|
||||
|
||||
@custom.command("list")
|
||||
@click.pass_context
|
||||
def custom_list(ctx):
|
||||
"""List all custom columns in the library."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
columns = _custom.list_custom_columns(lib_path)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(columns, True)
|
||||
else:
|
||||
if not columns:
|
||||
click.echo(" No custom columns defined.")
|
||||
else:
|
||||
for col in columns:
|
||||
click.echo(f" {col.get('raw', col)}")
|
||||
|
||||
|
||||
@custom.command("add")
|
||||
@click.argument("label")
|
||||
@click.argument("name")
|
||||
@click.argument("datatype")
|
||||
@click.option("--multiple", is_flag=True, help="Allow multiple values")
|
||||
@click.pass_context
|
||||
def custom_add(ctx, label, name, datatype, multiple):
|
||||
"""Add a custom LABEL column with NAME and DATATYPE.
|
||||
|
||||
Valid datatypes: rating, text, comments, datetime, int, float,
|
||||
bool, series, enumeration, composite
|
||||
"""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _custom.add_custom_column(lib_path, label, name, datatype, multiple)
|
||||
except (RuntimeError, ValueError) as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
_ok(f"Created custom column {result['label']} ({datatype})")
|
||||
|
||||
|
||||
@custom.command("remove")
|
||||
@click.argument("label")
|
||||
@click.pass_context
|
||||
def custom_remove(ctx, label):
|
||||
"""Remove a custom column by LABEL."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _custom.remove_custom_column(lib_path, label)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
_ok(f"Removed custom column {result['label']}")
|
||||
|
||||
|
||||
@custom.command("set")
|
||||
@click.argument("book_id", type=int)
|
||||
@click.argument("label")
|
||||
@click.argument("value")
|
||||
@click.pass_context
|
||||
def custom_set(ctx, book_id, label, value):
|
||||
"""Set a custom field LABEL to VALUE for a book."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _custom.set_custom_field(lib_path, book_id, label, value)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
_ok(f"Set {result['label']} = {value!r} on book {book_id}")
|
||||
|
||||
|
||||
# ── catalog command ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("output_path")
|
||||
@click.option("--format", "catalog_format", default="epub",
|
||||
type=click.Choice(["epub", "csv", "opds"]),
|
||||
help="Catalog format")
|
||||
@click.option("--title", default="My Calibre Library", help="Catalog title")
|
||||
@click.pass_context
|
||||
def catalog(ctx, output_path, catalog_format, title):
|
||||
"""Generate a catalog of the library at OUTPUT_PATH."""
|
||||
as_json = ctx.obj.get("as_json", False) if ctx.obj else False
|
||||
sess = ctx.obj.get("session", {}) if ctx.obj else {}
|
||||
|
||||
try:
|
||||
lib_path = _session.require_library(sess)
|
||||
result = _export.generate_catalog(lib_path, output_path, catalog_format, title)
|
||||
except RuntimeError as e:
|
||||
_err(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
if as_json:
|
||||
_out(result, True)
|
||||
else:
|
||||
_ok(f"Catalog generated: {result['output_path']} ({result['file_size']:,} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Core modules for cli-anything-calibre."""
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Custom column management — wraps calibredb custom_columns, add/remove/set_custom."""
|
||||
|
||||
from cli_anything.calibre.utils.calibre_backend import (
|
||||
run_calibredb,
|
||||
find_calibredb,
|
||||
)
|
||||
|
||||
# Valid data types for custom columns
|
||||
VALID_DATATYPES = {
|
||||
"rating", "text", "comments", "datetime", "int",
|
||||
"float", "bool", "series", "enumeration", "composite",
|
||||
}
|
||||
|
||||
|
||||
def list_custom_columns(library_path: str) -> list[dict]:
|
||||
"""List all custom columns in the library."""
|
||||
find_calibredb()
|
||||
|
||||
result = run_calibredb(["custom_columns"], library_path=library_path)
|
||||
|
||||
columns = []
|
||||
for line in result["stdout"].strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Format: #label (Name) [datatype]
|
||||
columns.append({"raw": line})
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def add_custom_column(
|
||||
library_path: str,
|
||||
label: str,
|
||||
name: str,
|
||||
datatype: str,
|
||||
is_multiple: bool = False,
|
||||
) -> dict:
|
||||
"""Add a new custom column to the library."""
|
||||
find_calibredb()
|
||||
|
||||
if datatype not in VALID_DATATYPES:
|
||||
raise ValueError(
|
||||
f"Invalid datatype '{datatype}'. "
|
||||
f"Valid types: {', '.join(sorted(VALID_DATATYPES))}"
|
||||
)
|
||||
|
||||
# Ensure label starts with #
|
||||
if not label.startswith("#"):
|
||||
label = f"#{label}"
|
||||
|
||||
cmd = ["add_custom_column", label, name, datatype]
|
||||
if is_multiple:
|
||||
cmd.append("--is-multiple")
|
||||
|
||||
result = run_calibredb(cmd, library_path=library_path)
|
||||
return {
|
||||
"label": label,
|
||||
"name": name,
|
||||
"datatype": datatype,
|
||||
"is_multiple": is_multiple,
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
|
||||
|
||||
def remove_custom_column(
|
||||
library_path: str,
|
||||
label: str,
|
||||
force: bool = True,
|
||||
confirm: bool | None = None,
|
||||
) -> dict:
|
||||
"""Remove a custom column from the library."""
|
||||
find_calibredb()
|
||||
|
||||
if confirm is not None:
|
||||
force = confirm
|
||||
|
||||
if not label.startswith("#"):
|
||||
label = f"#{label}"
|
||||
|
||||
cmd = ["remove_custom_column", label]
|
||||
if force:
|
||||
cmd.append("--force")
|
||||
|
||||
result = run_calibredb(cmd, library_path=library_path)
|
||||
return {
|
||||
"label": label,
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
|
||||
|
||||
def set_custom_field(
|
||||
library_path: str,
|
||||
book_id: int,
|
||||
label: str,
|
||||
value: str,
|
||||
) -> dict:
|
||||
"""Set a custom field value on a book."""
|
||||
find_calibredb()
|
||||
|
||||
if not label.startswith("#"):
|
||||
label = f"#{label}"
|
||||
|
||||
result = run_calibredb(
|
||||
["set_custom", label, str(book_id), value],
|
||||
library_path=library_path,
|
||||
)
|
||||
return {
|
||||
"book_id": book_id,
|
||||
"label": label,
|
||||
"value": value,
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
"""Export pipeline — wraps calibredb export and supports catalog generation."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from cli_anything.calibre.utils.calibre_backend import (
|
||||
run_calibredb,
|
||||
run_ebook_convert,
|
||||
find_calibredb,
|
||||
find_ebook_convert,
|
||||
)
|
||||
|
||||
_CONTAINER_NS = "urn:oasis:names:tc:opendocument:xmlns:container"
|
||||
_OPF_NS = "http://www.idpf.org/2007/opf"
|
||||
_XHTML_NS = "http://www.w3.org/1999/xhtml"
|
||||
_NCX_NS = "http://www.daisy.org/z3986/2005/ncx/"
|
||||
|
||||
|
||||
def export_books(
|
||||
library_path: str,
|
||||
book_ids: list[int],
|
||||
output_dir: str,
|
||||
formats: list[str] | None = None,
|
||||
template: str | None = None,
|
||||
progress: bool = False,
|
||||
export_all: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
Export books from the library to a directory.
|
||||
|
||||
Invokes the real calibredb export — preserves Calibre's original
|
||||
folder structure, cover.jpg, and metadata.opf per book.
|
||||
|
||||
Args:
|
||||
library_path: Path to the Calibre library.
|
||||
book_ids: List of book IDs to export (ignored if export_all=True).
|
||||
output_dir: Directory to export books to.
|
||||
formats: Optional list of formats to export (e.g. ["EPUB", "MOBI"]).
|
||||
template: Optional filename template.
|
||||
progress: Whether to show progress output.
|
||||
export_all: If True, export all books in library (ignores book_ids).
|
||||
"""
|
||||
find_calibredb()
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
files_before = {str(p) for p in Path(output_dir).rglob("*") if p.is_file()}
|
||||
|
||||
cmd = ["export", "--to-dir", output_dir]
|
||||
if export_all:
|
||||
cmd.append("--all")
|
||||
if formats:
|
||||
cmd.extend(["--formats", ",".join(formats)])
|
||||
if template:
|
||||
cmd.extend(["--template", template])
|
||||
if progress:
|
||||
cmd.append("--progress")
|
||||
if not export_all:
|
||||
ids_str = ",".join(str(i) for i in book_ids)
|
||||
cmd.append(ids_str)
|
||||
|
||||
result = run_calibredb(cmd, library_path=library_path)
|
||||
|
||||
files_after = {str(p) for p in Path(output_dir).rglob("*") if p.is_file()}
|
||||
new_files = sorted(files_after - files_before)
|
||||
|
||||
return {
|
||||
"output_dir": output_dir,
|
||||
"book_ids": book_ids if not export_all else "all",
|
||||
"exported_files": new_files,
|
||||
"count": len(new_files),
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
|
||||
|
||||
def generate_catalog(
|
||||
library_path: str,
|
||||
output_path: str,
|
||||
catalog_format: str = "epub",
|
||||
title: str = "My Calibre Library",
|
||||
extra_options: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Generate a catalog of the library using calibredb catalog.
|
||||
|
||||
The catalog format can be epub, csv, or opds.
|
||||
"""
|
||||
find_calibredb()
|
||||
|
||||
output_path = str(Path(output_path).with_suffix(f".{catalog_format.lower()}"))
|
||||
os.makedirs(Path(output_path).parent, exist_ok=True)
|
||||
|
||||
cmd = ["catalog", output_path, f"--catalog-title={title}"]
|
||||
if extra_options:
|
||||
cmd.extend(extra_options)
|
||||
|
||||
result = run_calibredb(cmd, library_path=library_path)
|
||||
|
||||
if not Path(output_path).exists():
|
||||
raise RuntimeError(
|
||||
f"calibredb catalog did not produce output at {output_path}\n"
|
||||
f"stdout: {result['stdout']}\nstderr: {result['stderr']}"
|
||||
)
|
||||
|
||||
size = Path(output_path).stat().st_size
|
||||
|
||||
return {
|
||||
"output_path": output_path,
|
||||
"format": catalog_format,
|
||||
"title": title,
|
||||
"file_size": size,
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
|
||||
|
||||
def _safe_chapter_filename(title: str, index: int) -> str:
|
||||
"""Convert a chapter title to a safe, zero-padded filename (no extension)."""
|
||||
safe = re.sub(r"[^\w\s-]", "", title)[:50].strip()
|
||||
safe = re.sub(r"\s+", "_", safe)
|
||||
return f"{index:03d}_{safe}" if safe else f"{index:03d}_chapter"
|
||||
|
||||
|
||||
def _parse_nav_titles(nav_path: Path, epub_dir: Path) -> dict:
|
||||
"""Parse EPUB 3 nav.xhtml. Returns {path-relative-to-epub-dir: title}."""
|
||||
titles: dict[str, str] = {}
|
||||
nav_dir = nav_path.parent
|
||||
try:
|
||||
tree = ET.parse(nav_path)
|
||||
for a in tree.findall(f".//{{{_XHTML_NS}}}a"):
|
||||
href = a.get("href", "").split("#")[0]
|
||||
if not href:
|
||||
continue
|
||||
text = "".join(a.itertext()).strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
rel = str((nav_dir / href).resolve().relative_to(epub_dir.resolve()))
|
||||
titles[rel] = text
|
||||
except ValueError:
|
||||
titles[href] = text
|
||||
except ET.ParseError:
|
||||
pass
|
||||
return titles
|
||||
|
||||
|
||||
def _parse_ncx_titles(ncx_path: Path, epub_dir: Path) -> dict:
|
||||
"""Parse EPUB 2 NCX. Returns {path-relative-to-epub-dir: title}."""
|
||||
titles: dict[str, str] = {}
|
||||
ncx_dir = ncx_path.parent
|
||||
try:
|
||||
tree = ET.parse(ncx_path)
|
||||
for nav_point in tree.findall(f".//{{{_NCX_NS}}}navPoint"):
|
||||
content = nav_point.find(f"{{{_NCX_NS}}}content")
|
||||
label = nav_point.find(f".//{{{_NCX_NS}}}text")
|
||||
if content is None or label is None:
|
||||
continue
|
||||
src = content.get("src", "").split("#")[0]
|
||||
text = (label.text or "").strip()
|
||||
if not src or not text:
|
||||
continue
|
||||
try:
|
||||
rel = str((ncx_dir / src).resolve().relative_to(epub_dir.resolve()))
|
||||
titles[rel] = text
|
||||
except ValueError:
|
||||
titles[src] = text
|
||||
except ET.ParseError:
|
||||
pass
|
||||
return titles
|
||||
|
||||
|
||||
def _parse_epub_chapters(epub_dir: Path) -> list[dict]:
|
||||
"""Parse an extracted EPUB directory and return an ordered chapter list.
|
||||
|
||||
Each entry: {order (int), title (str), src (str, relative to epub_dir)}.
|
||||
"""
|
||||
container_path = epub_dir / "META-INF" / "container.xml"
|
||||
if not container_path.exists():
|
||||
raise RuntimeError("Not a valid EPUB: META-INF/container.xml not found")
|
||||
|
||||
container_tree = ET.parse(container_path)
|
||||
rootfile = container_tree.find(f".//{{{_CONTAINER_NS}}}rootfile")
|
||||
if rootfile is None:
|
||||
raise RuntimeError("Could not find rootfile in container.xml")
|
||||
|
||||
opf_path = epub_dir / rootfile.get("full-path", "")
|
||||
opf_dir = opf_path.parent
|
||||
opf_tree = ET.parse(opf_path)
|
||||
|
||||
# Build manifest id → {href, media_type, properties}
|
||||
manifest: dict[str, dict] = {}
|
||||
for item in opf_tree.findall(f".//{{{_OPF_NS}}}item"):
|
||||
manifest[item.get("id", "")] = {
|
||||
"href": item.get("href", ""),
|
||||
"media_type": item.get("media-type", ""),
|
||||
"properties": item.get("properties", ""),
|
||||
}
|
||||
|
||||
# Locate nav (EPUB 3) or NCX (EPUB 2) for chapter titles
|
||||
chapter_titles: dict[str, str] = {}
|
||||
nav_item = next(
|
||||
(v for v in manifest.values() if "nav" in v.get("properties", "")), None
|
||||
)
|
||||
ncx_item = next(
|
||||
(v for v in manifest.values()
|
||||
if v.get("media_type") == "application/x-dtbncx+xml"),
|
||||
None,
|
||||
)
|
||||
if nav_item:
|
||||
nav_path = opf_dir / nav_item["href"]
|
||||
if nav_path.exists():
|
||||
chapter_titles = _parse_nav_titles(nav_path, epub_dir)
|
||||
elif ncx_item:
|
||||
ncx_path = opf_dir / ncx_item["href"]
|
||||
if ncx_path.exists():
|
||||
chapter_titles = _parse_ncx_titles(ncx_path, epub_dir)
|
||||
|
||||
# Walk the spine to build the ordered chapter list
|
||||
chapters: list[dict] = []
|
||||
for itemref in opf_tree.findall(f".//{{{_OPF_NS}}}itemref"):
|
||||
item = manifest.get(itemref.get("idref", ""), {})
|
||||
href = item.get("href", "")
|
||||
media_type = item.get("media_type", "")
|
||||
is_html = (
|
||||
href.endswith((".html", ".xhtml", ".htm")) or "html" in media_type
|
||||
)
|
||||
if not is_html:
|
||||
continue
|
||||
|
||||
abs_path = opf_dir / href
|
||||
if not abs_path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
rel = str(abs_path.resolve().relative_to(epub_dir.resolve()))
|
||||
except ValueError:
|
||||
rel = str((opf_dir.relative_to(epub_dir)) / href)
|
||||
|
||||
order = len(chapters) + 1
|
||||
title = chapter_titles.get(rel) or f"Chapter {order}"
|
||||
chapters.append({"order": order, "title": title, "src": rel})
|
||||
|
||||
return chapters
|
||||
|
||||
|
||||
def export_chapters_pdf(
|
||||
library_path: str,
|
||||
book_id: int,
|
||||
output_dir: str,
|
||||
chapter_range: tuple[int, int] | None = None,
|
||||
) -> dict:
|
||||
"""Export each chapter of a book as a separate PDF file.
|
||||
|
||||
Workflow:
|
||||
1. Export the book from the Calibre library to obtain the EPUB file.
|
||||
2. Unpack the EPUB and parse the TOC (nav.xhtml for EPUB 3, NCX for
|
||||
EPUB 2) to retrieve chapter titles and file order.
|
||||
3. Convert each chapter HTML file to PDF with ``ebook-convert``.
|
||||
4. Write zero-padded, title-slugified PDFs to *output_dir*.
|
||||
|
||||
Args:
|
||||
library_path: Path to the Calibre library.
|
||||
book_id: ID of the book to export.
|
||||
output_dir: Directory that will receive the chapter PDFs.
|
||||
chapter_range: Optional ``(start, end)`` tuple (1-indexed, inclusive)
|
||||
to export only a slice of chapters.
|
||||
|
||||
Returns:
|
||||
dict with keys ``book_id``, ``output_dir``, ``total_chapters``,
|
||||
``exported_chapters``, and ``exported_pdfs`` (list of per-chapter
|
||||
dicts with ``index``, ``title``, ``file``, ``size``).
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Book has no EPUB format in the library.
|
||||
RuntimeError: EPUB structure is invalid or has no chapters.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
find_calibredb()
|
||||
find_ebook_convert()
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Step 1: export book from calibre to get the EPUB
|
||||
# Note: DO NOT use --all, it would export all books and ignore book_id
|
||||
run_calibredb(
|
||||
["export", "--to-dir", tmpdir, str(book_id)],
|
||||
library_path=library_path,
|
||||
)
|
||||
|
||||
epub_files = list(Path(tmpdir).rglob("*.epub"))
|
||||
if not epub_files:
|
||||
raise FileNotFoundError(
|
||||
f"Book {book_id} has no EPUB format in the library. "
|
||||
f"Convert it first: formats convert {book_id} <FMT> EPUB"
|
||||
)
|
||||
|
||||
# Step 2: extract EPUB and parse chapter list
|
||||
epub_dir = Path(tmpdir) / "epub_content"
|
||||
epub_dir.mkdir()
|
||||
resolved_epub_dir = epub_dir.resolve()
|
||||
with zipfile.ZipFile(epub_files[0]) as zf:
|
||||
# Validate each member path to prevent Zip Slip directory traversal attacks.
|
||||
# Use Path.is_relative_to (Py3.10+) instead of str.startswith to avoid
|
||||
# sibling-prefix bypasses (e.g. /tmp/epub vs /tmp/epub_evil).
|
||||
for member in zf.infolist():
|
||||
member_path = (resolved_epub_dir / member.filename).resolve()
|
||||
if not member_path.is_relative_to(resolved_epub_dir):
|
||||
raise ValueError(f"Unsafe EPUB entry rejected: {member.filename}")
|
||||
zf.extract(member, epub_dir)
|
||||
|
||||
chapters = _parse_epub_chapters(epub_dir)
|
||||
if not chapters:
|
||||
raise RuntimeError(
|
||||
f"No chapters found in book {book_id}. "
|
||||
"The EPUB may not have a valid TOC or spine."
|
||||
)
|
||||
|
||||
# Step 3: apply optional range filter
|
||||
if chapter_range:
|
||||
start, end = chapter_range
|
||||
chapters = [c for c in chapters if start <= c["order"] <= end]
|
||||
|
||||
# Step 4: convert each chapter to PDF
|
||||
exported_pdfs: list[dict] = []
|
||||
skipped_chapters: list[dict] = []
|
||||
total = len(chapters)
|
||||
for i, chapter in enumerate(chapters, 1):
|
||||
src_path = epub_dir / chapter["src"]
|
||||
if not src_path.exists():
|
||||
skipped_chapters.append({"order": chapter["order"], "title": chapter["title"], "reason": "missing source file"})
|
||||
continue
|
||||
|
||||
out_name = _safe_chapter_filename(chapter["title"], chapter["order"]) + ".pdf"
|
||||
out_path = Path(output_dir) / out_name
|
||||
|
||||
try:
|
||||
run_ebook_convert([str(src_path), str(out_path)])
|
||||
except RuntimeError as exc:
|
||||
skipped_chapters.append({"order": chapter["order"], "title": chapter["title"], "reason": str(exc)})
|
||||
continue
|
||||
|
||||
if out_path.exists():
|
||||
exported_pdfs.append({
|
||||
"index": chapter["order"],
|
||||
"title": chapter["title"],
|
||||
"file": str(out_path),
|
||||
"size": out_path.stat().st_size,
|
||||
})
|
||||
|
||||
return {
|
||||
"book_id": book_id,
|
||||
"output_dir": output_dir,
|
||||
"total_chapters": total,
|
||||
"exported_chapters": len(exported_pdfs),
|
||||
"exported_pdfs": exported_pdfs,
|
||||
"skipped_chapters": skipped_chapters,
|
||||
}
|
||||
|
||||
|
||||
def backup_metadata(library_path: str, output_dir: str | None = None) -> dict:
|
||||
"""Backup all book metadata.opf files to a directory."""
|
||||
find_calibredb()
|
||||
|
||||
cmd = ["backup_metadata"]
|
||||
if output_dir:
|
||||
cmd.extend(["--to-dir", output_dir])
|
||||
|
||||
result = run_calibredb(cmd, library_path=library_path)
|
||||
return {
|
||||
"library_path": library_path,
|
||||
"output_dir": output_dir,
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Format management — wraps calibredb add_format, remove_format + ebook-convert."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.calibre.utils.calibre_backend import (
|
||||
run_calibredb,
|
||||
run_ebook_convert,
|
||||
find_calibredb,
|
||||
find_ebook_convert,
|
||||
)
|
||||
|
||||
|
||||
def list_formats(library_path: str, book_id: int) -> dict:
|
||||
"""List available formats for a book.
|
||||
|
||||
Uses the OPF metadata to find the book's folder, then scans for format files.
|
||||
"""
|
||||
import re
|
||||
find_calibredb()
|
||||
|
||||
# Get the raw calibredb list output for this book (formats field = full paths)
|
||||
result = run_calibredb(
|
||||
["list", "--fields=id,formats", f"--search=id:{book_id}", "--limit=1"],
|
||||
library_path=library_path,
|
||||
)
|
||||
|
||||
# Join all lines to handle line-wrapped paths
|
||||
raw = " ".join(result["stdout"].splitlines())
|
||||
|
||||
# Extract file extensions from paths embedded in the output
|
||||
# calibredb shows: [/path/to/book.epub, /path/to/book.mobi]
|
||||
extensions = re.findall(r"\.([A-Za-z0-9]{2,6})\b", raw)
|
||||
skip = {"db", "opf", "jpg", "png", "gif", "webp"}
|
||||
formats = sorted(set(ext.upper() for ext in extensions if ext.lower() not in skip))
|
||||
|
||||
return {
|
||||
"book_id": book_id,
|
||||
"formats": formats,
|
||||
}
|
||||
|
||||
|
||||
def add_format(
|
||||
library_path: str,
|
||||
book_id: int,
|
||||
file_path: str,
|
||||
) -> dict:
|
||||
"""Add a new format file to an existing book in the library."""
|
||||
find_calibredb()
|
||||
|
||||
if not Path(file_path).exists():
|
||||
raise FileNotFoundError(f"Format file not found: {file_path}")
|
||||
|
||||
fmt = Path(file_path).suffix.lstrip(".").upper()
|
||||
|
||||
result = run_calibredb(
|
||||
["add_format", str(book_id), file_path],
|
||||
library_path=library_path,
|
||||
)
|
||||
return {
|
||||
"book_id": book_id,
|
||||
"format": fmt,
|
||||
"file_path": file_path,
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
|
||||
|
||||
def remove_format(
|
||||
library_path: str,
|
||||
book_id: int,
|
||||
fmt: str,
|
||||
) -> dict:
|
||||
"""Remove a format from a book."""
|
||||
find_calibredb()
|
||||
|
||||
result = run_calibredb(
|
||||
["remove_format", str(book_id), fmt.upper()],
|
||||
library_path=library_path,
|
||||
)
|
||||
return {
|
||||
"book_id": book_id,
|
||||
"format": fmt.upper(),
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
|
||||
|
||||
def convert_format(
|
||||
library_path: str,
|
||||
book_id: int,
|
||||
input_fmt: str,
|
||||
output_fmt: str,
|
||||
output_path: str | None = None,
|
||||
extra_options: list[str] | None = None,
|
||||
add_to_library: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Convert a book format using ebook-convert.
|
||||
|
||||
1. Exports the input format file from the library
|
||||
2. Runs ebook-convert to produce the output format
|
||||
3. Optionally adds the converted file back to the library
|
||||
"""
|
||||
find_calibredb()
|
||||
find_ebook_convert()
|
||||
|
||||
import tempfile
|
||||
|
||||
input_fmt = input_fmt.upper()
|
||||
output_fmt = output_fmt.upper()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Export only the target book and requested input format to get the source file
|
||||
export_result = run_calibredb(
|
||||
["export", "--to-dir", tmpdir, "--formats", input_fmt, str(book_id)],
|
||||
library_path=library_path,
|
||||
)
|
||||
|
||||
# Find the input format file
|
||||
input_file = None
|
||||
for f in Path(tmpdir).rglob(f"*.{input_fmt.lower()}"):
|
||||
input_file = str(f)
|
||||
break
|
||||
|
||||
if not input_file:
|
||||
raise FileNotFoundError(
|
||||
f"Book {book_id} does not have format {input_fmt} in the library."
|
||||
)
|
||||
|
||||
# Determine output path; default to cwd (mirrors calibredb export default)
|
||||
if output_path is None:
|
||||
input_stem = Path(input_file).stem
|
||||
output_path = str(Path.cwd() / f"{input_stem}.{output_fmt.lower()}")
|
||||
else:
|
||||
os.makedirs(Path(output_path).parent, exist_ok=True)
|
||||
|
||||
# Run ebook-convert
|
||||
cmd = [input_file, output_path]
|
||||
if extra_options:
|
||||
cmd.extend(extra_options)
|
||||
|
||||
convert_result = run_ebook_convert(cmd)
|
||||
|
||||
if not Path(output_path).exists():
|
||||
raise RuntimeError(
|
||||
f"ebook-convert did not produce output at {output_path}\n"
|
||||
f"stderr: {convert_result['stderr']}"
|
||||
)
|
||||
|
||||
output_size = Path(output_path).stat().st_size
|
||||
|
||||
# Add back to library if requested
|
||||
added_id = None
|
||||
if add_to_library:
|
||||
add_result = run_calibredb(
|
||||
["add_format", str(book_id), output_path],
|
||||
library_path=library_path,
|
||||
)
|
||||
|
||||
return {
|
||||
"book_id": book_id,
|
||||
"input_format": input_fmt,
|
||||
"output_format": output_fmt,
|
||||
"output_path": output_path,
|
||||
"output_size": output_size,
|
||||
"added_to_library": add_to_library,
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Library operations — wraps calibredb list, search, add, remove, export."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.calibre.utils.calibre_backend import (
|
||||
run_calibredb,
|
||||
find_calibredb,
|
||||
)
|
||||
|
||||
|
||||
def library_info(library_path: str) -> dict:
|
||||
"""Return library statistics: book count, format counts, total size."""
|
||||
find_calibredb() # Ensure installed
|
||||
|
||||
# Count books: search with empty string returns all book IDs
|
||||
result = run_calibredb(
|
||||
["search", ""],
|
||||
library_path=library_path,
|
||||
)
|
||||
id_output = result["stdout"].strip()
|
||||
if id_output:
|
||||
book_ids = [p.strip() for p in id_output.split(",") if p.strip().isdigit()]
|
||||
book_count = len(book_ids)
|
||||
else:
|
||||
book_count = 0
|
||||
|
||||
# Derive format counts from calibredb metadata instead of recursively scanning
|
||||
# the library directory, which is slow and I/O-heavy on large libraries.
|
||||
format_counts: dict[str, int] = {}
|
||||
formats_result = run_calibredb(
|
||||
["list", "--fields=formats", "--for-machine"],
|
||||
library_path=library_path,
|
||||
)
|
||||
formats_output = formats_result["stdout"].strip()
|
||||
if formats_output:
|
||||
try:
|
||||
rows = json.loads(formats_output)
|
||||
except json.JSONDecodeError:
|
||||
rows = []
|
||||
|
||||
if isinstance(rows, dict):
|
||||
rows = list(rows.values())
|
||||
elif not isinstance(rows, list):
|
||||
rows = []
|
||||
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
value = row.get("formats")
|
||||
if not value:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
fmts = [f.strip() for f in value.split(",") if f.strip()]
|
||||
elif isinstance(value, list):
|
||||
fmts = [str(f).strip() for f in value if str(f).strip()]
|
||||
else:
|
||||
continue
|
||||
for fmt in fmts:
|
||||
# calibredb returns paths like "/.../book.epub" or bare "EPUB"
|
||||
ext = Path(fmt).suffix.lstrip(".").upper() or fmt.upper()
|
||||
if ext and ext not in {"JPG", "PNG", "OPF", "SQLITE", "WEBP", "GIF", "DB"}:
|
||||
format_counts[ext] = format_counts.get(ext, 0) + 1
|
||||
|
||||
lib = Path(library_path)
|
||||
db_path = lib / "metadata.db"
|
||||
db_size = db_path.stat().st_size if db_path.exists() else 0
|
||||
|
||||
return {
|
||||
"library_path": library_path,
|
||||
"book_count": book_count,
|
||||
"format_counts": format_counts,
|
||||
"db_size_bytes": db_size,
|
||||
}
|
||||
|
||||
|
||||
def list_books(
|
||||
library_path: str,
|
||||
search: str = "",
|
||||
sort_by: str = "title",
|
||||
ascending: bool = True,
|
||||
limit: int = 100,
|
||||
fields: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""List books in the library, optionally filtered and sorted."""
|
||||
find_calibredb()
|
||||
|
||||
# Avoid including "formats" in default fields — calibredb outputs full paths
|
||||
# that can wrap across lines and break simple separator-based parsing.
|
||||
if fields is None:
|
||||
fields = ["id", "title", "authors", "tags", "series", "rating"]
|
||||
|
||||
# Use a tab separator (least likely to appear in metadata fields)
|
||||
cmd = [
|
||||
"list",
|
||||
f"--fields={','.join(fields)}",
|
||||
"--separator=\t",
|
||||
f"--sort-by={sort_by}",
|
||||
f"--limit={limit}",
|
||||
]
|
||||
if not ascending:
|
||||
cmd.append("--ascending=False")
|
||||
if search:
|
||||
cmd.extend(["--search", search])
|
||||
|
||||
result = run_calibredb(cmd, library_path=library_path)
|
||||
|
||||
books = []
|
||||
lines = result["stdout"].strip().splitlines()
|
||||
for line in lines:
|
||||
if "\t" not in line:
|
||||
continue
|
||||
parts = [p.strip() for p in line.split("\t")]
|
||||
try:
|
||||
book_id = int(parts[0])
|
||||
except ValueError:
|
||||
continue
|
||||
book = {"id": book_id}
|
||||
for i, field in enumerate(fields[1:], 1):
|
||||
book[field] = parts[i] if i < len(parts) else ""
|
||||
books.append(book)
|
||||
|
||||
return books
|
||||
|
||||
|
||||
def search_books(library_path: str, query: str, limit: int = 100) -> list[int]:
|
||||
"""Search the library using Calibre query language, return book IDs."""
|
||||
find_calibredb()
|
||||
|
||||
result = run_calibredb(
|
||||
["search", "--", query],
|
||||
library_path=library_path,
|
||||
)
|
||||
|
||||
output = result["stdout"].strip()
|
||||
if not output:
|
||||
return []
|
||||
|
||||
ids = []
|
||||
for part in output.split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
ids.append(int(part))
|
||||
return ids[:limit]
|
||||
|
||||
|
||||
def add_books(
|
||||
library_path: str,
|
||||
file_paths: list[str],
|
||||
automerge: str = "ignore",
|
||||
) -> dict:
|
||||
"""Add book files to the library. Returns added IDs and duplicates."""
|
||||
find_calibredb()
|
||||
|
||||
cmd = ["add", f"--automerge={automerge}"] + file_paths
|
||||
result = run_calibredb(cmd, library_path=library_path)
|
||||
|
||||
added_ids = []
|
||||
duplicates = []
|
||||
for line in result["stdout"].splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("Added book ids:"):
|
||||
raw = line.split(":", 1)[1].strip()
|
||||
for part in raw.split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
added_ids.append(int(part))
|
||||
elif "duplicate" in line.lower():
|
||||
duplicates.append(line)
|
||||
|
||||
return {
|
||||
"added_ids": added_ids,
|
||||
"duplicates": duplicates,
|
||||
"count": len(added_ids),
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
|
||||
|
||||
def remove_books(
|
||||
library_path: str,
|
||||
book_ids: list[int],
|
||||
permanent: bool = False,
|
||||
) -> dict:
|
||||
"""Remove books from the library (to trash unless permanent=True)."""
|
||||
find_calibredb()
|
||||
|
||||
ids_str = ",".join(str(i) for i in book_ids)
|
||||
cmd = ["remove"]
|
||||
if permanent:
|
||||
cmd.append("--permanent")
|
||||
cmd.append(ids_str)
|
||||
|
||||
result = run_calibredb(cmd, library_path=library_path)
|
||||
return {
|
||||
"removed_ids": book_ids,
|
||||
"count": len(book_ids),
|
||||
"permanent": permanent,
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
|
||||
|
||||
def check_library(library_path: str) -> dict:
|
||||
"""Check library integrity, return issues found."""
|
||||
find_calibredb()
|
||||
|
||||
result = run_calibredb(["check_library"], library_path=library_path)
|
||||
|
||||
issues = []
|
||||
for line in result["stdout"].splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
issues.append(line)
|
||||
|
||||
return {
|
||||
"library_path": library_path,
|
||||
"issues": issues,
|
||||
"ok": len(issues) == 0,
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Metadata operations — wraps calibredb set_metadata, show_metadata, embed_metadata."""
|
||||
|
||||
from cli_anything.calibre.utils.calibre_backend import (
|
||||
run_calibredb,
|
||||
find_calibredb,
|
||||
)
|
||||
|
||||
# Fields that calibredb set_metadata --field supports
|
||||
SETTABLE_FIELDS = {
|
||||
"title", "authors", "tags", "series", "series_index",
|
||||
"rating", "publisher", "pubdate", "comments", "languages",
|
||||
"cover", "identifiers",
|
||||
}
|
||||
|
||||
|
||||
def get_metadata(library_path: str, book_id: int) -> dict:
|
||||
"""Retrieve all metadata for a book as a dict."""
|
||||
find_calibredb()
|
||||
|
||||
result = run_calibredb(
|
||||
["show_metadata", "--as-opf", str(book_id)],
|
||||
library_path=library_path,
|
||||
)
|
||||
|
||||
# Parse the OPF XML output into a simple dict
|
||||
return _parse_opf_to_dict(result["stdout"], book_id)
|
||||
|
||||
|
||||
def set_metadata(
|
||||
library_path: str,
|
||||
book_id: int,
|
||||
field: str,
|
||||
value: str,
|
||||
) -> dict:
|
||||
"""Set a single metadata field on a book."""
|
||||
find_calibredb()
|
||||
|
||||
result = run_calibredb(
|
||||
["set_metadata", str(book_id), f"--field={field}:{value}"],
|
||||
library_path=library_path,
|
||||
)
|
||||
return {
|
||||
"book_id": book_id,
|
||||
"field": field,
|
||||
"value": value,
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
|
||||
|
||||
def set_metadata_batch(
|
||||
library_path: str,
|
||||
book_id: int,
|
||||
fields: dict[str, str],
|
||||
) -> dict:
|
||||
"""Set multiple metadata fields on a book in a single calibredb call."""
|
||||
find_calibredb()
|
||||
|
||||
field_args = []
|
||||
for field, value in fields.items():
|
||||
field_args.extend([f"--field={field}:{value}"])
|
||||
|
||||
result = run_calibredb(
|
||||
["set_metadata", str(book_id)] + field_args,
|
||||
library_path=library_path,
|
||||
)
|
||||
return {
|
||||
"book_id": book_id,
|
||||
"fields": fields,
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
|
||||
|
||||
def embed_metadata(library_path: str, book_ids: list[int]) -> dict:
|
||||
"""Embed library metadata into the actual book files (EPUB, etc.)."""
|
||||
find_calibredb()
|
||||
|
||||
ids_str = ",".join(str(i) for i in book_ids)
|
||||
result = run_calibredb(
|
||||
["embed_metadata", ids_str],
|
||||
library_path=library_path,
|
||||
)
|
||||
return {
|
||||
"book_ids": book_ids,
|
||||
"count": len(book_ids),
|
||||
"stdout": result["stdout"],
|
||||
}
|
||||
|
||||
|
||||
def _parse_opf_to_dict(opf_xml: str, book_id: int) -> dict:
|
||||
"""Parse OPF XML into a flat metadata dict."""
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
meta: dict = {"id": book_id, "raw_opf": opf_xml}
|
||||
|
||||
try:
|
||||
# OPF uses namespaces
|
||||
ns = {
|
||||
"opf": "http://www.idpf.org/2007/opf",
|
||||
"dc": "http://purl.org/dc/elements/1.1/",
|
||||
}
|
||||
root = ET.fromstring(opf_xml)
|
||||
metadata_el = root.find("opf:metadata", ns)
|
||||
if metadata_el is None:
|
||||
metadata_el = root.find("metadata")
|
||||
if metadata_el is None:
|
||||
return meta
|
||||
|
||||
def _text(tag: str, namespace: str = "dc") -> str:
|
||||
el = metadata_el.find(f"{namespace}:{tag}", ns)
|
||||
return el.text.strip() if el is not None and el.text else ""
|
||||
|
||||
meta["title"] = _text("title")
|
||||
meta["publisher"] = _text("publisher")
|
||||
meta["language"] = _text("language")
|
||||
meta["description"] = _text("description")
|
||||
|
||||
authors = [
|
||||
el.text.strip()
|
||||
for el in metadata_el.findall("dc:creator", ns)
|
||||
if el.text
|
||||
]
|
||||
meta["authors"] = authors
|
||||
|
||||
tags = [
|
||||
el.text.strip()
|
||||
for el in metadata_el.findall("dc:subject", ns)
|
||||
if el.text
|
||||
]
|
||||
meta["tags"] = tags
|
||||
|
||||
identifiers = {}
|
||||
for el in metadata_el.findall("dc:identifier", ns):
|
||||
scheme = el.get("{http://www.idpf.org/2007/opf}scheme", "")
|
||||
if scheme and el.text:
|
||||
identifiers[scheme.lower()] = el.text.strip()
|
||||
meta["identifiers"] = identifiers
|
||||
|
||||
# Calibre-specific meta tags
|
||||
for el in metadata_el.findall("opf:meta", ns):
|
||||
name = el.get("name", "")
|
||||
content = el.get("content", "")
|
||||
if name == "calibre:series":
|
||||
meta["series"] = content
|
||||
elif name == "calibre:series_index":
|
||||
try:
|
||||
meta["series_index"] = float(content)
|
||||
except ValueError:
|
||||
pass
|
||||
elif name == "calibre:rating":
|
||||
try:
|
||||
meta["rating"] = float(content)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
except ET.ParseError:
|
||||
pass
|
||||
|
||||
return meta
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Session management — persists library path and state between CLI invocations."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SESSION_DIR = Path.home() / ".cli-anything-calibre"
|
||||
_SESSION_FILE = _SESSION_DIR / "session.json"
|
||||
|
||||
|
||||
def _default_session() -> dict:
|
||||
return {
|
||||
"library_path": None,
|
||||
"last_command": None,
|
||||
}
|
||||
|
||||
|
||||
def load_session() -> dict:
|
||||
"""Load session from disk, creating defaults if missing."""
|
||||
_SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not _SESSION_FILE.exists():
|
||||
return _default_session()
|
||||
try:
|
||||
with open(_SESSION_FILE) as f:
|
||||
data = json.load(f)
|
||||
# Merge with defaults for forward-compatibility
|
||||
session = _default_session()
|
||||
session.update(data)
|
||||
return session
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return _default_session()
|
||||
|
||||
|
||||
def save_session(session: dict) -> None:
|
||||
"""Persist session to disk."""
|
||||
_SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(_SESSION_FILE, "w") as f:
|
||||
json.dump(session, f, indent=2)
|
||||
|
||||
|
||||
def get_library_path(session: dict | None = None) -> str | None:
|
||||
"""Return the active library path from session or CALIBRE_LIBRARY env var."""
|
||||
env_path = os.environ.get("CALIBRE_LIBRARY")
|
||||
if env_path:
|
||||
return env_path
|
||||
if session is None:
|
||||
session = load_session()
|
||||
return session.get("library_path")
|
||||
|
||||
|
||||
def set_library_path(path: str, session: dict | None = None) -> dict:
|
||||
"""Set the active library path and save session."""
|
||||
if session is None:
|
||||
session = load_session()
|
||||
resolved = str(Path(path).expanduser().resolve())
|
||||
session["library_path"] = resolved
|
||||
save_session(session)
|
||||
return session
|
||||
|
||||
|
||||
def require_library(session: dict | None = None) -> str:
|
||||
"""Return library path or raise RuntimeError with helpful message."""
|
||||
path = get_library_path(session)
|
||||
if not path:
|
||||
raise RuntimeError(
|
||||
"No Calibre library connected.\n"
|
||||
"Set one with: cli-anything-calibre library connect <path>\n"
|
||||
"Or set CALIBRE_LIBRARY environment variable."
|
||||
)
|
||||
if not Path(path).exists():
|
||||
raise RuntimeError(
|
||||
f"Library path does not exist: {path}\n"
|
||||
"Set a valid path with: cli-anything-calibre library connect <path>"
|
||||
)
|
||||
return path
|
||||
@@ -0,0 +1,282 @@
|
||||
---
|
||||
name: >-
|
||||
cli-anything-calibre
|
||||
description: >-
|
||||
Command-line interface for Calibre - A stateful CLI harness for e-book library management, metadata editing, and format conversion wrapping the real Calibre tools (calibredb, ebook-convert, ebook-meta)...
|
||||
---
|
||||
|
||||
# cli-anything-calibre
|
||||
|
||||
A stateful CLI harness for Calibre e-book management. Wraps the real Calibre tools (`calibredb`, `ebook-convert`, `ebook-meta`) to give AI agents and scripts a clean, structured interface for library operations, metadata editing, and format conversion.
|
||||
|
||||
## Installation
|
||||
|
||||
This CLI is installed as part of the cli-anything-calibre package:
|
||||
|
||||
```bash
|
||||
pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=calibre/agent-harness
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- Calibre must be installed on your system (hard dependency)
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt-get install calibre
|
||||
|
||||
# macOS
|
||||
brew install --cask calibre
|
||||
|
||||
# Verify tools are in PATH
|
||||
which calibredb
|
||||
which ebook-convert
|
||||
which ebook-meta
|
||||
```
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-calibre --help
|
||||
|
||||
# Start interactive REPL mode
|
||||
cli-anything-calibre
|
||||
|
||||
# Connect to a Calibre library
|
||||
cli-anything-calibre library connect ~/Calibre\ Library
|
||||
|
||||
# Run with JSON output (for agent consumption)
|
||||
cli-anything-calibre --json books list
|
||||
```
|
||||
|
||||
### REPL Mode
|
||||
|
||||
When invoked without a subcommand, the CLI enters an interactive REPL session:
|
||||
|
||||
```bash
|
||||
cli-anything-calibre
|
||||
# Enter commands interactively with tab-completion and history
|
||||
# Use 'help' to see available commands
|
||||
# Use 'quit' or 'exit' to leave
|
||||
```
|
||||
|
||||
|
||||
## Command Groups
|
||||
|
||||
|
||||
### Library
|
||||
|
||||
Library management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `connect <path>` | Set active library path |
|
||||
| `info` | Show library statistics (book count, formats, db size) |
|
||||
| `check` | Verify library integrity using calibredb check_library |
|
||||
|
||||
|
||||
### Books
|
||||
|
||||
Book operations (wrap `calibredb`).
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List books with filtering and sorting |
|
||||
| `search <query>` | Search using Calibre query language |
|
||||
| `add <files>` | Add book files to library |
|
||||
| `remove <ids>` | Remove books (move to trash or permanent delete) |
|
||||
| `show <id>` | Show full metadata for a book |
|
||||
| `export <ids>` | Export books to directory |
|
||||
| `export-chapters <id>` | Export each chapter as separate PDF (requires EPUB format) |
|
||||
|
||||
|
||||
### Meta
|
||||
|
||||
Metadata editing (wrap `calibredb set_metadata`).
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `get <id> [field]` | Get metadata (all or specific field) |
|
||||
| `set <id> <field> <value>` | Set a metadata field |
|
||||
| `embed <ids>` | Embed metadata into book files |
|
||||
|
||||
|
||||
### Formats
|
||||
|
||||
Format management (wrap `calibredb` + `ebook-convert`).
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list <id>` | List available formats for a book |
|
||||
| `add <id> <file>` | Add a format to a book |
|
||||
| `remove <id> <fmt>` | Remove a format from a book |
|
||||
| `convert <id> <input_fmt> <output_fmt>` | Convert book format |
|
||||
|
||||
|
||||
### Custom
|
||||
|
||||
Custom columns (wrap `calibredb`).
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List all custom columns |
|
||||
| `add <label> <name> <type>` | Create custom column |
|
||||
| `remove <label>` | Delete custom column |
|
||||
| `set <id> <label> <value>` | Set custom field value |
|
||||
|
||||
|
||||
### Catalog
|
||||
|
||||
Catalog generation.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `catalog <output>` | Generate a catalog of the library (EPUB, CSV, or OPDS) |
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
### Connect and List Books
|
||||
|
||||
Connect to your Calibre library and list books.
|
||||
|
||||
```bash
|
||||
cli-anything-calibre library connect ~/Calibre\ Library
|
||||
cli-anything-calibre books list
|
||||
# Or with JSON output
|
||||
cli-anything-calibre --json books list --search "author:asimov"
|
||||
```
|
||||
|
||||
|
||||
### Search and Filter
|
||||
|
||||
Search books using Calibre query language.
|
||||
|
||||
```bash
|
||||
cli-anything-calibre books search "title:Foundation"
|
||||
cli-anything-calibre books search "author:asimov and tags:scifi"
|
||||
cli-anything-calibre books search "rating:>3"
|
||||
```
|
||||
|
||||
|
||||
### Metadata Editing
|
||||
|
||||
Set metadata fields on books.
|
||||
|
||||
```bash
|
||||
cli-anything-calibre meta set 42 title "New Title"
|
||||
cli-anything-calibre meta set 42 series "Foundation"
|
||||
cli-anything-calibre meta set 42 series_index 1
|
||||
cli-anything-calibre meta set 42 tags "scifi,classic"
|
||||
cli-anything-calibre meta set 42 rating 5
|
||||
```
|
||||
|
||||
|
||||
### Format Conversion
|
||||
|
||||
Convert between e-book formats.
|
||||
|
||||
```bash
|
||||
cli-anything-calibre formats convert 42 EPUB MOBI
|
||||
cli-anything-calibre formats convert 42 EPUB PDF --output /tmp/book.pdf
|
||||
```
|
||||
|
||||
|
||||
### Export Chapters as PDFs
|
||||
|
||||
Export each chapter of an EPUB as a separate PDF file.
|
||||
|
||||
```bash
|
||||
cli-anything-calibre books export-chapters 42 --to-dir ./pdfs
|
||||
cli-anything-calibre books export-chapters 42 --to-dir ./pdfs --chapters 1-5
|
||||
```
|
||||
|
||||
|
||||
## Calibre Query Language
|
||||
|
||||
Used with `books search` and `books list --search`:
|
||||
|
||||
```
|
||||
author:asimov # Author contains "asimov"
|
||||
title:"Foundation" # Title phrase
|
||||
tags:fiction # Tag match
|
||||
rating:>3 # Rating greater than 3
|
||||
series:"Foundation" # Series match
|
||||
pubdate:[2020-01-01,2021-12-31] # Date range
|
||||
identifiers:isbn:1234567890 # Specific identifier
|
||||
has:cover # Has cover image
|
||||
not:tags:fiction # Negation
|
||||
author:asimov and tags:scifi # Boolean AND
|
||||
```
|
||||
|
||||
## Key Metadata Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `title` | text | Book title |
|
||||
| `authors` | text | Author names (&-separated) |
|
||||
| `tags` | text | Comma-separated tags |
|
||||
| `series` | text | Series name |
|
||||
| `series_index` | float | Position in series |
|
||||
| `rating` | float | Rating 1-5 |
|
||||
| `publisher` | text | Publisher name |
|
||||
| `pubdate` | datetime | Publication date |
|
||||
| `comments` | text | Description/comments |
|
||||
| `languages` | text | Language codes |
|
||||
| `identifiers` | text | ISBN, ASIN, etc. (`type:value`) |
|
||||
|
||||
## Supported Formats
|
||||
|
||||
**Input:** EPUB, MOBI, AZW, AZW3, PDF, HTML, DOCX, ODT, FB2, TXT, RTF, LIT, and more
|
||||
|
||||
**Output (conversion):** EPUB, MOBI, AZW3, PDF, HTML, DOCX, TXT, and more
|
||||
|
||||
## State Management
|
||||
|
||||
The CLI maintains session state with:
|
||||
|
||||
- **Session file**: `~/.cli-anything-calibre/session.json`
|
||||
- **Library path persistence**: Active library is saved across sessions
|
||||
- **Environment override**: `CALIBRE_LIBRARY` environment variable
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support dual output modes:
|
||||
|
||||
- **Human-readable** (default): Tables, colors, formatted text
|
||||
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
|
||||
|
||||
```bash
|
||||
# Human output
|
||||
cli-anything-calibre books list
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-calibre --json books list
|
||||
```
|
||||
|
||||
## For AI Agents
|
||||
|
||||
When using this CLI programmatically:
|
||||
|
||||
1. **Always use `--json` flag** for parseable output
|
||||
2. **Check return codes** - 0 for success, non-zero for errors
|
||||
3. **Parse stderr** for error messages on failure
|
||||
4. **Verify outputs exist** after export/conversion operations
|
||||
5. **Use `--library` flag** or `CALIBRE_LIBRARY` env to specify library path
|
||||
6. **Chapter export requires EPUB format** - convert first if needed
|
||||
|
||||
## More Information
|
||||
|
||||
- Full documentation: See README.md in the package
|
||||
- Architecture SOP: See CALIBRE.md in the agent-harness directory
|
||||
- Test coverage: See test_core.py and test_full_e2e.py in the tests directory
|
||||
- Methodology: See HARNESS.md in the cli-anything-plugin
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
@@ -0,0 +1,359 @@
|
||||
# TEST.md — cli-anything-calibre Test Plan & Results
|
||||
|
||||
## Overview
|
||||
|
||||
This document covers the test plan and results for `cli-anything-calibre`, a CLI harness
|
||||
wrapping the real Calibre tools (`calibredb`, `ebook-convert`, `ebook-meta`).
|
||||
|
||||
**Unit/smoke dependency:** `test_core.py` does not require Calibre. It includes
|
||||
subprocess smoke checks for help, version, and missing-library behavior using the
|
||||
installed `cli-anything-calibre` command when available, or `python -m
|
||||
cli_anything.calibre` as a development fallback.
|
||||
|
||||
**E2E hard dependency:** `test_full_e2e.py` requires Calibre (`calibredb`,
|
||||
`ebook-convert`, and `ebook-meta` in PATH) for real backend validation.
|
||||
|
||||
---
|
||||
|
||||
## Test Inventory Plan
|
||||
|
||||
| File | Tests Planned | Description |
|
||||
|------|--------------|-------------|
|
||||
| `test_core.py` | 41 | Unit tests: synthetic data, no external deps, no Calibre needed, plus subprocess smoke |
|
||||
| `test_full_e2e.py` | 21 | E2E tests: real Calibre library operations + subprocess CLI tests |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Unit Tests (`test_core.py`)
|
||||
|
||||
### `core/session.py` — 6 tests
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `test_load_session_defaults` | load_session() returns valid defaults when no file exists |
|
||||
| `test_save_and_load_session` | save/load roundtrip preserves data |
|
||||
| `test_set_library_path` | set_library_path() updates session dict and saves |
|
||||
| `test_get_library_path_from_session` | get_library_path() reads from session |
|
||||
| `test_get_library_path_from_env` | CALIBRE_LIBRARY env var overrides session |
|
||||
| `test_require_library_no_path` | require_library() raises RuntimeError if no path set |
|
||||
|
||||
### `core/metadata.py` — 8 tests
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `test_parse_opf_basic` | _parse_opf_to_dict parses title, authors, tags |
|
||||
| `test_parse_opf_calibre_meta` | Parses calibre:series, calibre:rating, series_index |
|
||||
| `test_parse_opf_identifiers` | Parses dc:identifier elements (isbn, asin) |
|
||||
| `test_parse_opf_empty` | Handles empty/minimal OPF gracefully |
|
||||
| `test_parse_opf_bad_xml` | Handles malformed XML without crashing |
|
||||
| `test_settable_fields_set` | SETTABLE_FIELDS contains all expected fields |
|
||||
| `test_parse_opf_multiple_authors` | Multiple dc:creator elements parsed as list |
|
||||
| `test_parse_opf_no_tags` | Handles OPF without dc:subject/tag elements |
|
||||
|
||||
### `core/custom.py` — 5 tests
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `test_valid_datatypes_set` | VALID_DATATYPES contains all 10 types |
|
||||
| `test_label_normalization_add_hash` | Label without # gets # prepended |
|
||||
| `test_label_already_has_hash` | Label with # not double-prefixed |
|
||||
| `test_invalid_datatype_raises` | add_custom_column raises ValueError for bad type |
|
||||
| `test_custom_label_normalization_remove` | remove_custom_column normalizes label |
|
||||
|
||||
### `core/library.py` query parsing — 4 tests
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `test_search_empty_result` | Returns [] for empty calibredb search output |
|
||||
| `test_search_parses_ids` | Correctly splits "1,2,3" from calibredb output |
|
||||
| `test_list_fields_default` | Default field list includes id, title, authors, formats |
|
||||
| `test_export_creates_output_dir` | export_books creates output dir if not present |
|
||||
|
||||
### `utils/calibre_backend.py` — 4 tests
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `test_find_calibredb_missing` | find_calibredb() raises RuntimeError if not in PATH |
|
||||
| `test_find_ebook_convert_missing` | find_ebook_convert() raises RuntimeError if not in PATH |
|
||||
| `test_find_ebook_meta_missing` | find_ebook_meta() raises RuntimeError if not in PATH |
|
||||
| `test_error_message_contains_install_hint` | Error messages include install instructions |
|
||||
|
||||
### `calibre_cli.py` — 6 tests
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `test_cli_help_exits_zero` | `--help` prints usage and returns 0 |
|
||||
| `test_cli_version` | `--version` returns version string |
|
||||
| `test_cli_missing_library_error` | Commands without library set print clear error |
|
||||
| `test_installed_or_module_help_smoke` | Subprocess `--help` smoke test; uses installed entry point if present |
|
||||
| `test_installed_or_module_version_smoke` | Subprocess `--version` smoke test; uses installed entry point if present |
|
||||
| `test_missing_library_error_without_calibre` | Subprocess missing-library error works without Calibre installed |
|
||||
|
||||
Run no-backend validation:
|
||||
|
||||
```bash
|
||||
cd calibre/agent-harness
|
||||
python -m py_compile \
|
||||
cli_anything/calibre/calibre_cli.py \
|
||||
cli_anything/calibre/core/*.py \
|
||||
cli_anything/calibre/utils/*.py
|
||||
python -m pytest cli_anything/calibre/tests/test_core.py -v
|
||||
```
|
||||
|
||||
Installed-command smoke mode:
|
||||
|
||||
```bash
|
||||
cd calibre/agent-harness
|
||||
pip install -e .
|
||||
CLI_ANYTHING_FORCE_INSTALLED=1 python -m pytest \
|
||||
cli_anything/calibre/tests/test_core.py::TestCLISubprocessSmoke -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: E2E Tests (`test_full_e2e.py`)
|
||||
|
||||
These tests invoke the **real Calibre** tools and verify the output.
|
||||
|
||||
### Setup
|
||||
|
||||
E2E tests use a temporary Calibre library created with `calibredb add` from a real EPUB.
|
||||
A minimal EPUB is generated programmatically (valid ZIP structure) for reproducibility.
|
||||
|
||||
### `TestLibraryOperations` — 6 tests
|
||||
|
||||
| Test | Description | Verified |
|
||||
|------|-------------|---------|
|
||||
| fixture setup | Add EPUB to new library | Book ID returned, book count > 0 |
|
||||
| `test_list_books` | list_books() returns book entries | ID, title, authors present |
|
||||
| `test_list_books_custom_fields` | list_books() honors explicit field list | Requested fields present, omitted fields absent |
|
||||
| `test_search_books` | search_books() with query | Returns matching IDs |
|
||||
| `test_get_metadata` | get_metadata() returns parsed OPF | title, authors fields present |
|
||||
| `test_export_books` | export_books() exports to directory | Files exported, cover.jpg present |
|
||||
|
||||
### `TestMetadataOperations` — 3 tests
|
||||
|
||||
| Test | Description | Verified |
|
||||
|------|-------------|---------|
|
||||
| `test_set_metadata_title` | set_metadata() changes title | Round-trip: get_metadata confirms new title |
|
||||
| `test_set_metadata_tags` | set_metadata() sets tags | Tags reflected in get_metadata |
|
||||
| `test_set_metadata_series` | Set series + series_index | Both values parsed from OPF |
|
||||
|
||||
### `TestFormatConversion` — 3 tests
|
||||
|
||||
| Test | Description | Verified |
|
||||
|------|-------------|---------|
|
||||
| `test_convert_epub_to_mobi` | Convert EPUB → MOBI via ebook-convert | MOBI file exists, size > 0, magic bytes |
|
||||
| `test_convert_epub_to_txt` | Convert EPUB → TXT | TXT file exists, plaintext content |
|
||||
| `test_convert_adds_to_library` | Convert with add_to_library=True | New format appears in library |
|
||||
|
||||
### `TestCLISubprocess` — 9 tests
|
||||
|
||||
Tests the installed `cli-anything-calibre` command directly via subprocess.
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `test_help` | `--help` exits 0 with usage text |
|
||||
| `test_version` | `--version` outputs version string |
|
||||
| `test_library_connect` | `library connect <path>` sets library |
|
||||
| `test_library_info_json` | `--json library info` returns JSON dict |
|
||||
| `test_books_list_json` | `--json books list` returns JSON array |
|
||||
| `test_books_search_json` | `--json books search "title:..."` returns IDs |
|
||||
| `test_books_add_and_show` | Add EPUB, then show metadata in JSON |
|
||||
| `test_meta_set_and_get` | Set title, get title, verify match |
|
||||
| `test_full_workflow` | Add → set metadata → search → export workflow |
|
||||
|
||||
---
|
||||
|
||||
## Realistic Workflow Scenarios
|
||||
|
||||
### Workflow 1: Import and Organize a Collection
|
||||
|
||||
**Simulates:** A user importing a folder of EPUBs, tagging them, and setting series info.
|
||||
|
||||
**Operations:**
|
||||
1. `library connect ~/my-library`
|
||||
2. `books add *.epub` — import all EPUBs
|
||||
3. `books search "not:tags:read"` — find unread books
|
||||
4. `meta set 1 tags "scifi,classic"` — tag book
|
||||
5. `meta set 1 series "Foundation"` — set series
|
||||
6. `meta set 1 series_index 1` — set position
|
||||
7. `books list --sort=series` — verify ordering
|
||||
|
||||
**Verified:** Series and tags appear in `books show` JSON output.
|
||||
|
||||
### Workflow 2: Format Conversion for Kindle
|
||||
|
||||
**Simulates:** Converting a library of EPUBs to MOBI for a Kindle device.
|
||||
|
||||
**Operations:**
|
||||
1. `books search "formats:EPUB"` — find all EPUBs
|
||||
2. `formats convert 1 EPUB MOBI` — convert each
|
||||
3. `formats list 1` — verify MOBI added to library
|
||||
4. `books export 1 --to-dir /kindle-transfer` — export for sideloading
|
||||
|
||||
**Verified:** MOBI file exists, > 0 bytes, added to library.
|
||||
|
||||
### Workflow 3: Metadata Enrichment Pipeline
|
||||
|
||||
**Simulates:** An AI agent enriching metadata for imported books.
|
||||
|
||||
**Operations:**
|
||||
1. `books list --fields=id,title,authors` — get book list in JSON
|
||||
2. `meta get 1` — get current metadata
|
||||
3. `meta set 1 publisher "Penguin"` — update fields
|
||||
4. `meta set 1 pubdate "1951-05-01"` — set date
|
||||
5. `meta embed 1` — embed into file
|
||||
6. `meta get 1 publisher` — verify
|
||||
|
||||
**Verified:** All set fields appear in get output; embedded EPUB contains updated metadata.
|
||||
|
||||
---
|
||||
|
||||
## Test Results
|
||||
|
||||
No-backend validation run:
|
||||
|
||||
```bash
|
||||
cd calibre/agent-harness
|
||||
python -m py_compile \
|
||||
cli_anything/calibre/calibre_cli.py \
|
||||
cli_anything/calibre/core/*.py \
|
||||
cli_anything/calibre/utils/*.py
|
||||
python -m pytest cli_anything/calibre/tests/test_core.py -v
|
||||
```
|
||||
|
||||
Current no-backend result:
|
||||
|
||||
```text
|
||||
41 passed in 0.74s
|
||||
```
|
||||
|
||||
Installed-command smoke run:
|
||||
|
||||
```bash
|
||||
cd calibre/agent-harness
|
||||
pip install -e .
|
||||
CLI_ANYTHING_FORCE_INSTALLED=1 python -m pytest \
|
||||
cli_anything/calibre/tests/test_core.py::TestCLISubprocessSmoke -v
|
||||
```
|
||||
|
||||
Current installed-command smoke result:
|
||||
|
||||
```text
|
||||
3 passed in 0.63s
|
||||
```
|
||||
|
||||
Historical real-backend run:
|
||||
```bash
|
||||
CLI_ANYTHING_FORCE_INSTALLED=1 python -m pytest cli_anything/calibre/tests/ -v --tb=no
|
||||
```
|
||||
|
||||
```
|
||||
============================= test session starts ==============================
|
||||
platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0
|
||||
[_resolve_cli] Using installed command: /home/orgleaf/py-base-venv/bin/cli-anything-calibre
|
||||
|
||||
cli_anything/calibre/tests/test_core.py::TestSession::test_get_library_path_from_env PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestSession::test_get_library_path_from_session PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestSession::test_load_session_defaults PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestSession::test_require_library_no_path PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestSession::test_save_and_load_session PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestSession::test_set_library_path PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestMetadataParsing::test_parse_opf_bad_xml PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestMetadataParsing::test_parse_opf_basic PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestMetadataParsing::test_parse_opf_calibre_meta PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestMetadataParsing::test_parse_opf_empty PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestMetadataParsing::test_parse_opf_identifiers PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestMetadataParsing::test_parse_opf_multiple_authors PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestMetadataParsing::test_parse_opf_no_tags PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestMetadataParsing::test_settable_fields_set PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestCustomColumns::test_custom_label_normalization_remove PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestCustomColumns::test_invalid_datatype_raises PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestCustomColumns::test_label_already_has_hash PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestCustomColumns::test_label_normalization_add_hash PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestCustomColumns::test_valid_datatypes_set PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestCalibreBackend::test_error_message_contains_install_hint PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestCalibreBackend::test_find_calibredb_missing PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestCalibreBackend::test_find_ebook_convert_missing PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestCalibreBackend::test_find_ebook_meta_missing PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestLibrarySearch::test_export_creates_output_dir PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestLibrarySearch::test_list_fields_default PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestLibrarySearch::test_search_empty_result PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestLibrarySearch::test_search_parses_ids PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestCLIHelp::test_cli_help_exits_zero PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestCLIHelp::test_cli_missing_library_error PASSED
|
||||
cli_anything/calibre/tests/test_core.py::TestCLIHelp::test_cli_version PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestLibraryOperations::test_export_books PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestLibraryOperations::test_get_metadata PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestLibraryOperations::test_library_info PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestLibraryOperations::test_list_books PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestLibraryOperations::test_search_books PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestMetadataOperations::test_set_metadata_tags PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestMetadataOperations::test_set_metadata_title PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestMetadataOperations::test_set_series PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestFormatConversion::test_convert_adds_to_library PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestFormatConversion::test_convert_epub_to_mobi PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestFormatConversion::test_convert_epub_to_txt PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestCLISubprocess::test_books_add_json PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestCLISubprocess::test_books_list_json PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestCLISubprocess::test_books_search_json PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestCLISubprocess::test_full_workflow PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestCLISubprocess::test_help PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestCLISubprocess::test_library_connect PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestCLISubprocess::test_library_info_json PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestCLISubprocess::test_meta_set_and_get PASSED
|
||||
cli_anything/calibre/tests/test_full_e2e.py::TestCLISubprocess::test_version PASSED
|
||||
|
||||
============================== 50 passed in 15.93s ==============================
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total tests expected now | 62 |
|
||||
| No-backend tests passed in current run | 41 |
|
||||
| Installed smoke tests passed in current run | 3 |
|
||||
| Historical full-suite tests passed | 50 |
|
||||
| Failed | 0 |
|
||||
| Current no-backend execution time | 0.74s |
|
||||
| Current installed-smoke execution time | 0.63s |
|
||||
| Historical full-suite execution time | 15.93s |
|
||||
| Calibre version | 7.6 |
|
||||
| Subprocess backend | `/home/orgleaf/py-base-venv/bin/cli-anything-calibre` (installed) |
|
||||
|
||||
## Real Backend Validation Steps
|
||||
|
||||
Use these steps to validate the harness against a real Calibre install:
|
||||
|
||||
```bash
|
||||
cd calibre/agent-harness
|
||||
pip install -e .
|
||||
which calibredb
|
||||
which ebook-convert
|
||||
which ebook-meta
|
||||
CLI_ANYTHING_FORCE_INSTALLED=1 python -m pytest \
|
||||
cli_anything/calibre/tests/test_full_e2e.py -v -s
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- Tests create temporary Calibre libraries and seed them with generated EPUB files.
|
||||
- `calibredb` is used for add/list/search/metadata/export operations.
|
||||
- `ebook-convert` is used for EPUB to TXT/MOBI conversion and output files are checked for existence and nonzero size.
|
||||
- Subprocess E2E tests require the installed `cli-anything-calibre` entry point when `CLI_ANYTHING_FORCE_INSTALLED=1` is set.
|
||||
|
||||
## Coverage Notes
|
||||
|
||||
- All session persistence operations are tested (save, load, env override, error paths)
|
||||
- OPF metadata parsing tested for basic fields, Calibre-specific fields (series, rating), multiple authors, identifiers, empty/malformed XML
|
||||
- All custom column operations tested (label normalization, invalid type rejection)
|
||||
- All backend tool discovery functions tested with missing-tool error paths
|
||||
- E2E: real Calibre library created and seeded with a valid EPUB for each test class
|
||||
- E2E: metadata set + get round-trips verified (title, tags, series, series_index)
|
||||
- E2E: format conversion EPUB→MOBI and EPUB→TXT verified with real ebook-convert
|
||||
- E2E: subprocess tests use installed `cli-anything-calibre` binary (not fallback)
|
||||
- Gap: catalog generation (calibredb catalog) not E2E tested (requires more complex deps)
|
||||
- Gap: embed_metadata not E2E tested (requires checking file-level metadata)
|
||||
- Gap: custom column operations not E2E tested (add/remove/set in real library)
|
||||
@@ -0,0 +1,674 @@
|
||||
"""Unit tests for cli-anything-calibre core modules.
|
||||
|
||||
All tests use synthetic data — no real Calibre installation required.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
# ── session.py tests ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSession(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
# Patch the session file location
|
||||
self.patcher = mock.patch(
|
||||
"cli_anything.calibre.core.session._SESSION_FILE",
|
||||
Path(self.tmp) / "session.json",
|
||||
)
|
||||
self.patcher2 = mock.patch(
|
||||
"cli_anything.calibre.core.session._SESSION_DIR",
|
||||
Path(self.tmp),
|
||||
)
|
||||
self.patcher.start()
|
||||
self.patcher2.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.patcher.stop()
|
||||
self.patcher2.stop()
|
||||
|
||||
def test_load_session_defaults(self):
|
||||
from cli_anything.calibre.core.session import load_session
|
||||
sess = load_session()
|
||||
self.assertIsNone(sess["library_path"])
|
||||
self.assertIsNone(sess["last_command"])
|
||||
|
||||
def test_save_and_load_session(self):
|
||||
from cli_anything.calibre.core.session import load_session, save_session
|
||||
sess = load_session()
|
||||
sess["library_path"] = "/tmp/calibre-lib"
|
||||
save_session(sess)
|
||||
sess2 = load_session()
|
||||
self.assertEqual(sess2["library_path"], "/tmp/calibre-lib")
|
||||
|
||||
def test_set_library_path(self):
|
||||
from cli_anything.calibre.core.session import set_library_path, load_session
|
||||
# Create a real directory to resolve
|
||||
target = Path(self.tmp) / "mylib"
|
||||
target.mkdir()
|
||||
sess = load_session()
|
||||
new_sess = set_library_path(str(target), sess)
|
||||
self.assertEqual(new_sess["library_path"], str(target))
|
||||
|
||||
def test_get_library_path_from_session(self):
|
||||
from cli_anything.calibre.core.session import get_library_path
|
||||
sess = {"library_path": "/test/library"}
|
||||
result = get_library_path(sess)
|
||||
self.assertEqual(result, "/test/library")
|
||||
|
||||
def test_get_library_path_from_env(self):
|
||||
from cli_anything.calibre.core.session import get_library_path
|
||||
with mock.patch.dict(os.environ, {"CALIBRE_LIBRARY": "/env/library"}):
|
||||
result = get_library_path({"library_path": "/session/library"})
|
||||
self.assertEqual(result, "/env/library")
|
||||
|
||||
def test_require_library_no_path(self):
|
||||
from cli_anything.calibre.core.session import require_library
|
||||
sess = {"library_path": None}
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
require_library(sess)
|
||||
self.assertIn("No Calibre library", str(ctx.exception))
|
||||
|
||||
|
||||
# ── metadata.py tests ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMetadataParsing(unittest.TestCase):
|
||||
BASIC_OPF = """<?xml version='1.0' encoding='utf-8'?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:title>Foundation</dc:title>
|
||||
<dc:creator>Isaac Asimov</dc:creator>
|
||||
<dc:publisher>Gnome Press</dc:publisher>
|
||||
<dc:language>en</dc:language>
|
||||
<dc:subject>science fiction</dc:subject>
|
||||
<dc:subject>classic</dc:subject>
|
||||
<dc:identifier opf:scheme="ISBN">978-0-553-29335-7</dc:identifier>
|
||||
</metadata>
|
||||
</package>"""
|
||||
|
||||
CALIBRE_OPF = """<?xml version='1.0' encoding='utf-8'?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:title>Foundation</dc:title>
|
||||
<dc:creator>Isaac Asimov</dc:creator>
|
||||
<opf:meta name="calibre:series" content="Foundation"/>
|
||||
<opf:meta name="calibre:series_index" content="1.0"/>
|
||||
<opf:meta name="calibre:rating" content="5"/>
|
||||
</metadata>
|
||||
</package>"""
|
||||
|
||||
MULTI_AUTHOR_OPF = """<?xml version='1.0' encoding='utf-8'?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:title>Good Omens</dc:title>
|
||||
<dc:creator>Terry Pratchett</dc:creator>
|
||||
<dc:creator>Neil Gaiman</dc:creator>
|
||||
</metadata>
|
||||
</package>"""
|
||||
|
||||
def test_parse_opf_basic(self):
|
||||
from cli_anything.calibre.core.metadata import _parse_opf_to_dict
|
||||
result = _parse_opf_to_dict(self.BASIC_OPF, book_id=42)
|
||||
self.assertEqual(result["id"], 42)
|
||||
self.assertEqual(result["title"], "Foundation")
|
||||
self.assertEqual(result["authors"], ["Isaac Asimov"])
|
||||
self.assertIn("science fiction", result["tags"])
|
||||
self.assertIn("classic", result["tags"])
|
||||
self.assertEqual(result["publisher"], "Gnome Press")
|
||||
|
||||
def test_parse_opf_calibre_meta(self):
|
||||
from cli_anything.calibre.core.metadata import _parse_opf_to_dict
|
||||
result = _parse_opf_to_dict(self.CALIBRE_OPF, book_id=1)
|
||||
self.assertEqual(result["series"], "Foundation")
|
||||
self.assertEqual(result["series_index"], 1.0)
|
||||
self.assertEqual(result["rating"], 5.0)
|
||||
|
||||
def test_parse_opf_identifiers(self):
|
||||
from cli_anything.calibre.core.metadata import _parse_opf_to_dict
|
||||
result = _parse_opf_to_dict(self.BASIC_OPF, book_id=1)
|
||||
self.assertIn("isbn", result["identifiers"])
|
||||
self.assertEqual(result["identifiers"]["isbn"], "978-0-553-29335-7")
|
||||
|
||||
def test_parse_opf_empty(self):
|
||||
from cli_anything.calibre.core.metadata import _parse_opf_to_dict
|
||||
minimal = """<?xml version='1.0'?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"/>
|
||||
</package>"""
|
||||
result = _parse_opf_to_dict(minimal, book_id=99)
|
||||
self.assertEqual(result["id"], 99)
|
||||
self.assertEqual(result["title"], "")
|
||||
self.assertEqual(result["authors"], [])
|
||||
self.assertEqual(result["tags"], [])
|
||||
|
||||
def test_parse_opf_bad_xml(self):
|
||||
from cli_anything.calibre.core.metadata import _parse_opf_to_dict
|
||||
result = _parse_opf_to_dict("NOT XML <<<<", book_id=1)
|
||||
self.assertEqual(result["id"], 1)
|
||||
self.assertIn("raw_opf", result)
|
||||
|
||||
def test_settable_fields_set(self):
|
||||
from cli_anything.calibre.core.metadata import SETTABLE_FIELDS
|
||||
for field in ("title", "authors", "tags", "series", "series_index",
|
||||
"rating", "publisher", "pubdate", "comments"):
|
||||
self.assertIn(field, SETTABLE_FIELDS)
|
||||
|
||||
def test_parse_opf_multiple_authors(self):
|
||||
from cli_anything.calibre.core.metadata import _parse_opf_to_dict
|
||||
result = _parse_opf_to_dict(self.MULTI_AUTHOR_OPF, book_id=5)
|
||||
self.assertEqual(len(result["authors"]), 2)
|
||||
self.assertIn("Terry Pratchett", result["authors"])
|
||||
self.assertIn("Neil Gaiman", result["authors"])
|
||||
|
||||
def test_parse_opf_no_tags(self):
|
||||
from cli_anything.calibre.core.metadata import _parse_opf_to_dict
|
||||
opf = """<?xml version='1.0'?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>No Tags Book</dc:title>
|
||||
<dc:creator>Author</dc:creator>
|
||||
</metadata>
|
||||
</package>"""
|
||||
result = _parse_opf_to_dict(opf, book_id=7)
|
||||
self.assertEqual(result["tags"], [])
|
||||
|
||||
|
||||
# ── custom.py tests ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCustomColumns(unittest.TestCase):
|
||||
def test_valid_datatypes_set(self):
|
||||
from cli_anything.calibre.core.custom import VALID_DATATYPES
|
||||
expected = {
|
||||
"rating", "text", "comments", "datetime", "int",
|
||||
"float", "bool", "series", "enumeration", "composite",
|
||||
}
|
||||
self.assertEqual(VALID_DATATYPES, expected)
|
||||
|
||||
def test_invalid_datatype_raises(self):
|
||||
from cli_anything.calibre.core.custom import add_custom_column
|
||||
with mock.patch("cli_anything.calibre.core.custom.find_calibredb",
|
||||
return_value="/usr/bin/calibredb"):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
add_custom_column("/lib", "#myfield", "My Field", "badtype")
|
||||
self.assertIn("Invalid datatype", str(ctx.exception))
|
||||
|
||||
def test_label_normalization_add_hash(self):
|
||||
"""add_custom_column adds # prefix if missing."""
|
||||
from cli_anything.calibre.utils.calibre_backend import find_calibredb
|
||||
called_with = []
|
||||
|
||||
def fake_run(cmd, library_path=None, timeout=120):
|
||||
called_with.append(cmd)
|
||||
return {"stdout": "", "stderr": "", "returncode": 0}
|
||||
|
||||
with mock.patch("cli_anything.calibre.core.custom.find_calibredb",
|
||||
return_value="/usr/bin/calibredb"), \
|
||||
mock.patch("cli_anything.calibre.core.custom.run_calibredb",
|
||||
side_effect=fake_run):
|
||||
from cli_anything.calibre.core.custom import add_custom_column
|
||||
add_custom_column("/lib", "myfield", "My Field", "text")
|
||||
|
||||
self.assertTrue(called_with[0][1].startswith("#"))
|
||||
|
||||
def test_label_already_has_hash(self):
|
||||
"""add_custom_column doesn't double-prefix # if already present."""
|
||||
called_with = []
|
||||
|
||||
def fake_run(cmd, library_path=None, timeout=120):
|
||||
called_with.append(cmd)
|
||||
return {"stdout": "", "stderr": "", "returncode": 0}
|
||||
|
||||
with mock.patch("cli_anything.calibre.core.custom.find_calibredb",
|
||||
return_value="/usr/bin/calibredb"), \
|
||||
mock.patch("cli_anything.calibre.core.custom.run_calibredb",
|
||||
side_effect=fake_run):
|
||||
from cli_anything.calibre.core.custom import add_custom_column
|
||||
add_custom_column("/lib", "#myfield", "My Field", "text")
|
||||
|
||||
label = called_with[0][1]
|
||||
self.assertFalse(label.startswith("##"), f"Label double-prefixed: {label}")
|
||||
|
||||
def test_custom_label_normalization_remove(self):
|
||||
"""remove_custom_column adds # prefix if missing."""
|
||||
called_with = []
|
||||
|
||||
def fake_run(cmd, library_path=None, timeout=120):
|
||||
called_with.append(cmd)
|
||||
return {"stdout": "", "stderr": "", "returncode": 0}
|
||||
|
||||
with mock.patch("cli_anything.calibre.core.custom.find_calibredb",
|
||||
return_value="/usr/bin/calibredb"), \
|
||||
mock.patch("cli_anything.calibre.core.custom.run_calibredb",
|
||||
side_effect=fake_run):
|
||||
from cli_anything.calibre.core.custom import remove_custom_column
|
||||
remove_custom_column("/lib", "genre")
|
||||
|
||||
label = called_with[0][1]
|
||||
self.assertTrue(label.startswith("#"))
|
||||
|
||||
|
||||
# ── calibre_backend.py tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalibreBackend(unittest.TestCase):
|
||||
def test_find_calibredb_missing(self):
|
||||
from cli_anything.calibre.utils.calibre_backend import find_calibredb
|
||||
with mock.patch("shutil.which", return_value=None):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
find_calibredb()
|
||||
self.assertIn("calibredb", str(ctx.exception))
|
||||
|
||||
def test_find_ebook_convert_missing(self):
|
||||
from cli_anything.calibre.utils.calibre_backend import find_ebook_convert
|
||||
with mock.patch("shutil.which", return_value=None):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
find_ebook_convert()
|
||||
self.assertIn("ebook-convert", str(ctx.exception))
|
||||
|
||||
def test_find_ebook_meta_missing(self):
|
||||
from cli_anything.calibre.utils.calibre_backend import find_ebook_meta
|
||||
with mock.patch("shutil.which", return_value=None):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
find_ebook_meta()
|
||||
self.assertIn("ebook-meta", str(ctx.exception))
|
||||
|
||||
def test_error_message_contains_install_hint(self):
|
||||
from cli_anything.calibre.utils.calibre_backend import find_calibredb
|
||||
with mock.patch("shutil.which", return_value=None):
|
||||
try:
|
||||
find_calibredb()
|
||||
except RuntimeError as e:
|
||||
self.assertIn("apt", str(e).lower())
|
||||
|
||||
|
||||
# ── library.py helper tests ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLibrarySearch(unittest.TestCase):
|
||||
def test_search_empty_result(self):
|
||||
"""Returns [] when calibredb search output is empty."""
|
||||
def fake_run(cmd, library_path=None, timeout=120):
|
||||
return {"stdout": "", "stderr": "", "returncode": 0}
|
||||
|
||||
with mock.patch("cli_anything.calibre.core.library.find_calibredb",
|
||||
return_value="/usr/bin/calibredb"), \
|
||||
mock.patch("cli_anything.calibre.core.library.run_calibredb",
|
||||
side_effect=fake_run):
|
||||
from cli_anything.calibre.core.library import search_books
|
||||
result = search_books("/lib", "author:nobody")
|
||||
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_search_parses_ids(self):
|
||||
"""Correctly parses comma-separated IDs from calibredb output."""
|
||||
def fake_run(cmd, library_path=None, timeout=120):
|
||||
return {"stdout": "1, 2, 3, 42", "stderr": "", "returncode": 0}
|
||||
|
||||
with mock.patch("cli_anything.calibre.core.library.find_calibredb",
|
||||
return_value="/usr/bin/calibredb"), \
|
||||
mock.patch("cli_anything.calibre.core.library.run_calibredb",
|
||||
side_effect=fake_run):
|
||||
from cli_anything.calibre.core.library import search_books
|
||||
result = search_books("/lib", "author:asimov")
|
||||
|
||||
self.assertEqual(result, [1, 2, 3, 42])
|
||||
|
||||
def test_list_fields_default(self):
|
||||
"""Default field list includes required fields."""
|
||||
from cli_anything.calibre.core.library import list_books
|
||||
captured = []
|
||||
|
||||
def fake_run(cmd, library_path=None, timeout=120):
|
||||
captured.append(cmd)
|
||||
return {"stdout": "", "stderr": "", "returncode": 0}
|
||||
|
||||
with mock.patch("cli_anything.calibre.core.library.find_calibredb",
|
||||
return_value="/usr/bin/calibredb"), \
|
||||
mock.patch("cli_anything.calibre.core.library.run_calibredb",
|
||||
side_effect=fake_run):
|
||||
list_books("/lib")
|
||||
|
||||
fields_arg = next((a for a in captured[0] if a.startswith("--fields=")), "")
|
||||
self.assertIn("id", fields_arg)
|
||||
self.assertIn("title", fields_arg)
|
||||
self.assertIn("authors", fields_arg)
|
||||
|
||||
def test_export_creates_output_dir(self):
|
||||
"""export_books creates the output directory if it doesn't exist."""
|
||||
import tempfile
|
||||
from cli_anything.calibre.core.export import export_books
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out_dir = os.path.join(tmp, "does-not-exist")
|
||||
# The directory should not exist yet
|
||||
self.assertFalse(os.path.exists(out_dir))
|
||||
|
||||
def fake_run(cmd, library_path=None, timeout=120):
|
||||
return {"stdout": "", "stderr": "", "returncode": 0}
|
||||
|
||||
with mock.patch("cli_anything.calibre.core.export.find_calibredb",
|
||||
return_value="/usr/bin/calibredb"), \
|
||||
mock.patch("cli_anything.calibre.core.export.run_calibredb",
|
||||
side_effect=fake_run):
|
||||
export_books("/lib", [1], out_dir)
|
||||
|
||||
self.assertTrue(os.path.isdir(out_dir))
|
||||
|
||||
|
||||
# ── EPUB chapter parsing tests ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEpubChapterParsing(unittest.TestCase):
|
||||
"""Tests for _parse_epub_chapters, _parse_nav_titles, _parse_ncx_titles."""
|
||||
|
||||
_NAV_XHTML = """\
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<body>
|
||||
<nav epub:type="toc">
|
||||
<ol>
|
||||
<li><a href="Text/chapter1.xhtml">Chapter One</a></li>
|
||||
<li><a href="Text/chapter2.xhtml">Chapter Two</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
_NCX = """\
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<navMap>
|
||||
<navPoint id="n1" playOrder="1">
|
||||
<navLabel><text>Introduction</text></navLabel>
|
||||
<content src="Text/chapter1.xhtml"/>
|
||||
</navPoint>
|
||||
<navPoint id="n2" playOrder="2">
|
||||
<navLabel><text>Main Content</text></navLabel>
|
||||
<content src="Text/chapter2.xhtml"/>
|
||||
</navPoint>
|
||||
</navMap>
|
||||
</ncx>"""
|
||||
|
||||
_CONTAINER_XML = """\
|
||||
<?xml version='1.0'?>
|
||||
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf"
|
||||
media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>"""
|
||||
|
||||
def _make_epub_dir(self, tmp, *, use_nav: bool) -> Path:
|
||||
"""Write a minimal EPUB directory to *tmp* and return the path."""
|
||||
epub_dir = Path(tmp) / "epub"
|
||||
(epub_dir / "META-INF").mkdir(parents=True)
|
||||
(epub_dir / "OEBPS" / "Text").mkdir(parents=True)
|
||||
|
||||
(epub_dir / "META-INF" / "container.xml").write_text(self._CONTAINER_XML)
|
||||
(epub_dir / "OEBPS" / "Text" / "chapter1.xhtml").write_text(
|
||||
"<html><body><h1>Ch 1</h1></body></html>"
|
||||
)
|
||||
(epub_dir / "OEBPS" / "Text" / "chapter2.xhtml").write_text(
|
||||
"<html><body><h1>Ch 2</h1></body></html>"
|
||||
)
|
||||
|
||||
if use_nav:
|
||||
(epub_dir / "OEBPS" / "nav.xhtml").write_text(self._NAV_XHTML)
|
||||
opf = """\
|
||||
<?xml version='1.0'?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<manifest>
|
||||
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml"
|
||||
properties="nav"/>
|
||||
<item id="c1" href="Text/chapter1.xhtml"
|
||||
media-type="application/xhtml+xml"/>
|
||||
<item id="c2" href="Text/chapter2.xhtml"
|
||||
media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine>
|
||||
<itemref idref="c1"/>
|
||||
<itemref idref="c2"/>
|
||||
</spine>
|
||||
</package>"""
|
||||
else:
|
||||
(epub_dir / "OEBPS" / "toc.ncx").write_text(self._NCX)
|
||||
opf = """\
|
||||
<?xml version='1.0'?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<manifest>
|
||||
<item id="ncx" href="toc.ncx"
|
||||
media-type="application/x-dtbncx+xml"/>
|
||||
<item id="c1" href="Text/chapter1.xhtml"
|
||||
media-type="application/xhtml+xml"/>
|
||||
<item id="c2" href="Text/chapter2.xhtml"
|
||||
media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine toc="ncx">
|
||||
<itemref idref="c1"/>
|
||||
<itemref idref="c2"/>
|
||||
</spine>
|
||||
</package>"""
|
||||
|
||||
(epub_dir / "OEBPS" / "content.opf").write_text(opf)
|
||||
return epub_dir
|
||||
|
||||
# ── _parse_epub_chapters ────────────────────────────────────────────────
|
||||
|
||||
def test_parse_epub3_nav_titles_and_order(self):
|
||||
from cli_anything.calibre.core.export import _parse_epub_chapters
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
epub_dir = self._make_epub_dir(tmp, use_nav=True)
|
||||
chapters = _parse_epub_chapters(epub_dir)
|
||||
self.assertEqual(len(chapters), 2)
|
||||
self.assertEqual(chapters[0]["title"], "Chapter One")
|
||||
self.assertEqual(chapters[1]["title"], "Chapter Two")
|
||||
self.assertEqual(chapters[0]["order"], 1)
|
||||
self.assertEqual(chapters[1]["order"], 2)
|
||||
|
||||
def test_parse_epub2_ncx_titles_and_order(self):
|
||||
from cli_anything.calibre.core.export import _parse_epub_chapters
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
epub_dir = self._make_epub_dir(tmp, use_nav=False)
|
||||
chapters = _parse_epub_chapters(epub_dir)
|
||||
self.assertEqual(len(chapters), 2)
|
||||
self.assertEqual(chapters[0]["title"], "Introduction")
|
||||
self.assertEqual(chapters[1]["title"], "Main Content")
|
||||
|
||||
def test_parse_epub_src_is_relative(self):
|
||||
"""src paths must be relative to epub_dir, not absolute."""
|
||||
from cli_anything.calibre.core.export import _parse_epub_chapters
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
epub_dir = self._make_epub_dir(tmp, use_nav=True)
|
||||
chapters = _parse_epub_chapters(epub_dir)
|
||||
for ch in chapters:
|
||||
self.assertFalse(
|
||||
Path(ch["src"]).is_absolute(),
|
||||
f"src should be relative, got: {ch['src']}",
|
||||
)
|
||||
|
||||
def test_parse_epub_fallback_default_titles(self):
|
||||
"""When there is no nav or NCX, chapters get generic fallback titles."""
|
||||
from cli_anything.calibre.core.export import _parse_epub_chapters
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
epub_dir = Path(tmp) / "epub"
|
||||
(epub_dir / "META-INF").mkdir(parents=True)
|
||||
(epub_dir / "OEBPS").mkdir()
|
||||
(epub_dir / "META-INF" / "container.xml").write_text(
|
||||
self._CONTAINER_XML
|
||||
)
|
||||
(epub_dir / "OEBPS" / "solo.xhtml").write_text(
|
||||
"<html><body>solo</body></html>"
|
||||
)
|
||||
(epub_dir / "OEBPS" / "content.opf").write_text("""\
|
||||
<?xml version='1.0'?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<manifest>
|
||||
<item id="c1" href="solo.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine><itemref idref="c1"/></spine>
|
||||
</package>""")
|
||||
chapters = _parse_epub_chapters(epub_dir)
|
||||
self.assertEqual(len(chapters), 1)
|
||||
self.assertIn("Chapter", chapters[0]["title"])
|
||||
|
||||
def test_parse_epub_missing_container_raises(self):
|
||||
from cli_anything.calibre.core.export import _parse_epub_chapters
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
epub_dir = Path(tmp) / "bad_epub"
|
||||
epub_dir.mkdir()
|
||||
with self.assertRaises(RuntimeError):
|
||||
_parse_epub_chapters(epub_dir)
|
||||
|
||||
# ── export_chapters_pdf ─────────────────────────────────────────────────
|
||||
|
||||
def test_export_chapters_pdf_no_epub_raises(self):
|
||||
"""FileNotFoundError when the book has no EPUB format."""
|
||||
from cli_anything.calibre.core.export import export_chapters_pdf
|
||||
|
||||
def fake_calibredb(cmd, library_path=None, timeout=120):
|
||||
return {"stdout": "", "stderr": "", "returncode": 0}
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with mock.patch(
|
||||
"cli_anything.calibre.core.export.find_calibredb",
|
||||
return_value="/usr/bin/calibredb",
|
||||
), mock.patch(
|
||||
"cli_anything.calibre.core.export.find_ebook_convert",
|
||||
return_value="/usr/bin/ebook-convert",
|
||||
), mock.patch(
|
||||
"cli_anything.calibre.core.export.run_calibredb",
|
||||
side_effect=fake_calibredb,
|
||||
):
|
||||
with self.assertRaises(FileNotFoundError) as ctx:
|
||||
export_chapters_pdf("/lib", 42, tmp)
|
||||
self.assertIn("EPUB", str(ctx.exception))
|
||||
|
||||
# ── CLI command ─────────────────────────────────────────────────────────
|
||||
|
||||
def _run_cli(self, args):
|
||||
from click.testing import CliRunner
|
||||
from cli_anything.calibre.calibre_cli import main
|
||||
return CliRunner().invoke(main, args)
|
||||
|
||||
def test_books_export_chapters_help(self):
|
||||
result = self._run_cli(["books", "export-chapters", "--help"])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
self.assertIn("PDF", result.output)
|
||||
|
||||
def test_books_export_chapters_invalid_range(self):
|
||||
"""Non-numeric --chapters value exits with error."""
|
||||
with mock.patch(
|
||||
"cli_anything.calibre.core.session.load_session",
|
||||
return_value={"library_path": "/lib", "last_command": None},
|
||||
):
|
||||
result = self._run_cli(
|
||||
["books", "export-chapters", "1", "--to-dir", "/tmp", "--chapters", "abc"]
|
||||
)
|
||||
self.assertNotEqual(result.exit_code, 0)
|
||||
|
||||
|
||||
# ── CLI invocation tests ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCLIHelp(unittest.TestCase):
|
||||
def _run_cli(self, args):
|
||||
from click.testing import CliRunner
|
||||
from cli_anything.calibre.calibre_cli import main
|
||||
runner = CliRunner()
|
||||
return runner.invoke(main, args)
|
||||
|
||||
def test_cli_help_exits_zero(self):
|
||||
result = self._run_cli(["--help"])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
self.assertIn("calibre", result.output.lower())
|
||||
|
||||
def test_cli_version(self):
|
||||
result = self._run_cli(["--version"])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
self.assertIn("1.0.0", result.output)
|
||||
|
||||
def test_cli_missing_library_error(self):
|
||||
"""list command without a library set shows helpful error."""
|
||||
with mock.patch("cli_anything.calibre.core.session.load_session",
|
||||
return_value={"library_path": None, "last_command": None}), \
|
||||
mock.patch("cli_anything.calibre.utils.calibre_backend.find_calibredb",
|
||||
return_value="/usr/bin/calibredb"):
|
||||
result = self._run_cli(["books", "list"])
|
||||
# Should exit non-zero or print error about missing library
|
||||
self.assertTrue(
|
||||
result.exit_code != 0 or "library" in result.output.lower() or
|
||||
"library" in (result.output + (result.stderr or "")).lower()
|
||||
)
|
||||
|
||||
|
||||
class TestCLISubprocessSmoke(unittest.TestCase):
|
||||
"""Subprocess smoke tests that do not require Calibre to be installed."""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_cli():
|
||||
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
|
||||
installed = shutil.which("cli-anything-calibre")
|
||||
if installed:
|
||||
return [installed]
|
||||
if force:
|
||||
raise RuntimeError(
|
||||
"cli-anything-calibre not found in PATH. Install with: pip install -e ."
|
||||
)
|
||||
return [sys.executable, "-m", "cli_anything.calibre"]
|
||||
|
||||
def _run(self, args, *, home=None):
|
||||
env = os.environ.copy()
|
||||
harness_root = Path(__file__).resolve().parents[3]
|
||||
env["PYTHONPATH"] = (
|
||||
str(harness_root)
|
||||
if not env.get("PYTHONPATH")
|
||||
else f"{harness_root}{os.pathsep}{env['PYTHONPATH']}"
|
||||
)
|
||||
env.pop("CALIBRE_LIBRARY", None)
|
||||
if home:
|
||||
env["HOME"] = home
|
||||
return subprocess.run(
|
||||
self._resolve_cli() + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_installed_or_module_help_smoke(self):
|
||||
result = self._run(["--help"])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("Usage:", result.stdout)
|
||||
self.assertIn("library", result.stdout)
|
||||
|
||||
def test_installed_or_module_version_smoke(self):
|
||||
result = self._run(["--version"])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("cli-anything-calibre", result.stdout)
|
||||
|
||||
def test_missing_library_error_without_calibre(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = self._run(["books", "list"], home=tmp)
|
||||
combined = result.stdout + result.stderr
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("No Calibre library connected", combined)
|
||||
self.assertIn("CALIBRE_LIBRARY", combined)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,557 @@
|
||||
"""E2E tests for cli-anything-calibre.
|
||||
|
||||
These tests invoke the REAL Calibre tools (calibredb, ebook-convert) and verify
|
||||
that actual library operations work end-to-end.
|
||||
|
||||
Calibre is a HARD DEPENDENCY — tests fail (not skip) if it is not installed.
|
||||
|
||||
Run (dev mode):
|
||||
pytest cli_anything/calibre/tests/test_full_e2e.py -v -s
|
||||
|
||||
Run (installed binary verification):
|
||||
CLI_ANYTHING_FORCE_INSTALLED=1 pytest cli_anything/calibre/tests/test_full_e2e.py -v -s
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _english_env() -> dict[str, str]:
|
||||
"""Environment dict that forces Calibre to output in English."""
|
||||
env = os.environ.copy()
|
||||
env["CALIBRE_OVERRIDE_LANG"] = "en"
|
||||
return env
|
||||
|
||||
|
||||
# ── EPUB generation helpers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_minimal_epub(path: str, title: str = "Test Book", author: str = "Test Author"):
|
||||
"""Create a minimal but valid EPUB file for testing."""
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
# mimetype must be first and uncompressed
|
||||
z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip",
|
||||
compress_type=zipfile.ZIP_STORED)
|
||||
|
||||
# container.xml
|
||||
container = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>"""
|
||||
z.writestr("META-INF/container.xml", container)
|
||||
|
||||
# content.opf
|
||||
opf = f"""<?xml version='1.0' encoding='utf-8'?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="book-id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:identifier id="book-id">test-book-001</dc:identifier>
|
||||
<dc:title>{title}</dc:title>
|
||||
<dc:creator>{author}</dc:creator>
|
||||
<dc:language>en</dc:language>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item id="chapter1" href="chapter1.html" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine toc="ncx">
|
||||
<itemref idref="chapter1"/>
|
||||
</spine>
|
||||
</package>"""
|
||||
z.writestr("OEBPS/content.opf", opf)
|
||||
|
||||
# toc.ncx
|
||||
ncx = f"""<?xml version='1.0' encoding='utf-8'?>
|
||||
<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN"
|
||||
"http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<head>
|
||||
<meta name="dtb:uid" content="test-book-001"/>
|
||||
<meta name="dtb:depth" content="1"/>
|
||||
<meta name="dtb:totalPageCount" content="0"/>
|
||||
<meta name="dtb:maxPageNumber" content="0"/>
|
||||
</head>
|
||||
<docTitle><text>{title}</text></docTitle>
|
||||
<navMap>
|
||||
<navPoint id="navPoint-1" playOrder="1">
|
||||
<navLabel><text>Chapter 1</text></navLabel>
|
||||
<content src="chapter1.html"/>
|
||||
</navPoint>
|
||||
</navMap>
|
||||
</ncx>"""
|
||||
z.writestr("OEBPS/toc.ncx", ncx)
|
||||
|
||||
# chapter1.html
|
||||
html = f"""<?xml version='1.0' encoding='utf-8'?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>{title}</title></head>
|
||||
<body>
|
||||
<h1>{title}</h1>
|
||||
<p>This is a test e-book by {author}. It contains sample text for CLI testing.</p>
|
||||
<p>The quick brown fox jumps over the lazy dog.</p>
|
||||
</body>
|
||||
</html>"""
|
||||
z.writestr("OEBPS/chapter1.html", html)
|
||||
|
||||
|
||||
# ── CLI resolver ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_cli(name):
|
||||
"""Resolve installed CLI command; falls back to python -m for dev.
|
||||
|
||||
Set env CLI_ANYTHING_FORCE_INSTALLED=1 to require the installed command.
|
||||
"""
|
||||
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
print(f"[_resolve_cli] Using installed command: {path}")
|
||||
return [path]
|
||||
if force:
|
||||
raise RuntimeError(
|
||||
f"{name} not found in PATH. Install with:\n"
|
||||
" cd agent-harness && pip install -e ."
|
||||
)
|
||||
module = "cli_anything.calibre.calibre_cli"
|
||||
print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}")
|
||||
return [sys.executable, "-m", module]
|
||||
|
||||
|
||||
# ── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CalibreTestMixin:
|
||||
"""Mixin providing a temporary Calibre library with a seeded EPUB."""
|
||||
|
||||
lib_dir: str
|
||||
epub_path: str
|
||||
book_id: int
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
calibredb = shutil.which("calibredb")
|
||||
if not calibredb:
|
||||
raise unittest.SkipTest(
|
||||
"calibredb not found in PATH. Install Calibre to run E2E tests "
|
||||
"(e.g. `sudo apt-get install calibre`)."
|
||||
)
|
||||
|
||||
cls.tmp_root = tempfile.mkdtemp(prefix="cli-anything-calibre-test-")
|
||||
cls.lib_dir = os.path.join(cls.tmp_root, "TestLibrary")
|
||||
os.makedirs(cls.lib_dir)
|
||||
|
||||
# Create a test EPUB
|
||||
cls.epub_path = os.path.join(cls.tmp_root, "test_book.epub")
|
||||
make_minimal_epub(cls.epub_path, title="Foundation", author="Isaac Asimov")
|
||||
|
||||
# Add to library
|
||||
result = subprocess.run(
|
||||
[calibredb, "--with-library", cls.lib_dir, "add", cls.epub_path],
|
||||
capture_output=True, text=True, check=True, env=_english_env(),
|
||||
)
|
||||
# Extract the book ID from output "Added book ids: 1"
|
||||
cls.book_id = 1
|
||||
for line in result.stdout.splitlines():
|
||||
if "Added book ids:" in line:
|
||||
parts = line.split(":")[-1].strip()
|
||||
if parts.isdigit():
|
||||
cls.book_id = int(parts)
|
||||
break
|
||||
|
||||
print(f"\n[fixture] Library: {cls.lib_dir}")
|
||||
print(f"[fixture] EPUB: {cls.epub_path}")
|
||||
print(f"[fixture] Book ID: {cls.book_id}")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
shutil.rmtree(cls.tmp_root, ignore_errors=True)
|
||||
|
||||
|
||||
# ── TestLibraryOperations ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLibraryOperations(CalibreTestMixin, unittest.TestCase):
|
||||
def test_library_info(self):
|
||||
from cli_anything.calibre.core.library import library_info
|
||||
info = library_info(self.lib_dir)
|
||||
self.assertEqual(info["library_path"], self.lib_dir)
|
||||
self.assertGreater(info["book_count"], 0)
|
||||
self.assertGreater(info["db_size_bytes"], 0)
|
||||
print(f"\n library_info: {info}")
|
||||
|
||||
def test_list_books(self):
|
||||
from cli_anything.calibre.core.library import list_books
|
||||
books = list_books(self.lib_dir)
|
||||
self.assertGreater(len(books), 0)
|
||||
book = books[0]
|
||||
self.assertIn("id", book)
|
||||
self.assertIn("title", book)
|
||||
print(f"\n list_books[0]: {book}")
|
||||
|
||||
def test_list_books_custom_fields(self):
|
||||
"""list_books with explicit fields returns exactly those fields per book."""
|
||||
from cli_anything.calibre.core.library import list_books
|
||||
requested = ["id", "title", "tags"]
|
||||
books = list_books(self.lib_dir, fields=requested)
|
||||
self.assertGreater(len(books), 0)
|
||||
book = books[0]
|
||||
# Requested fields must be present
|
||||
for field in requested:
|
||||
self.assertIn(field, book, f"Missing field: {field}")
|
||||
# Non-requested fields must be absent
|
||||
for field in ("authors", "series", "rating"):
|
||||
self.assertNotIn(field, book, f"Unexpected field: {field}")
|
||||
print(f"\n list_books(fields={requested})[0]: {book}")
|
||||
|
||||
def test_search_books(self):
|
||||
from cli_anything.calibre.core.library import search_books
|
||||
ids = search_books(self.lib_dir, "title:Foundation")
|
||||
self.assertIsInstance(ids, list)
|
||||
print(f"\n search_books('title:Foundation'): {ids}")
|
||||
|
||||
def test_get_metadata(self):
|
||||
from cli_anything.calibre.core.metadata import get_metadata
|
||||
meta = get_metadata(self.lib_dir, self.book_id)
|
||||
self.assertEqual(meta["id"], self.book_id)
|
||||
self.assertIsInstance(meta.get("title", ""), str)
|
||||
print(f"\n get_metadata({self.book_id}): title={meta.get('title')}")
|
||||
|
||||
def test_export_books(self):
|
||||
"""Export a book to a directory and verify files were created."""
|
||||
from cli_anything.calibre.core.export import export_books
|
||||
out_dir = os.path.join(self.tmp_root, "export-test")
|
||||
result = export_books(self.lib_dir, [self.book_id], out_dir)
|
||||
self.assertTrue(os.path.isdir(out_dir))
|
||||
self.assertGreater(result["count"], 0)
|
||||
print(f"\n export_books: {result['count']} files to {out_dir}")
|
||||
for f in result["exported_files"]:
|
||||
print(f" {f} ({Path(f).stat().st_size:,} bytes)")
|
||||
|
||||
|
||||
# ── TestMetadataOperations ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMetadataOperations(CalibreTestMixin, unittest.TestCase):
|
||||
def test_set_metadata_title(self):
|
||||
from cli_anything.calibre.core.metadata import set_metadata, get_metadata
|
||||
new_title = "Foundation — Updated"
|
||||
set_metadata(self.lib_dir, self.book_id, "title", new_title)
|
||||
meta = get_metadata(self.lib_dir, self.book_id)
|
||||
self.assertEqual(meta.get("title"), new_title)
|
||||
print(f"\n set title: '{new_title}' → confirmed")
|
||||
|
||||
def test_set_metadata_tags(self):
|
||||
from cli_anything.calibre.core.metadata import set_metadata, get_metadata
|
||||
set_metadata(self.lib_dir, self.book_id, "tags", "scifi,classic")
|
||||
meta = get_metadata(self.lib_dir, self.book_id)
|
||||
tags = meta.get("tags", [])
|
||||
print(f"\n set tags → {tags}")
|
||||
# Tags confirmed via OPF parsing
|
||||
self.assertIsInstance(tags, list)
|
||||
|
||||
def test_set_series(self):
|
||||
from cli_anything.calibre.core.metadata import set_metadata, get_metadata
|
||||
set_metadata(self.lib_dir, self.book_id, "series", "Foundation")
|
||||
set_metadata(self.lib_dir, self.book_id, "series_index", "1.0")
|
||||
meta = get_metadata(self.lib_dir, self.book_id)
|
||||
print(f"\n series={meta.get('series')}, index={meta.get('series_index')}")
|
||||
self.assertEqual(meta.get("series"), "Foundation")
|
||||
|
||||
|
||||
# ── TestFormatConversion ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFormatConversion(CalibreTestMixin, unittest.TestCase):
|
||||
def test_convert_epub_to_txt(self):
|
||||
"""Convert EPUB to TXT and verify output file."""
|
||||
from cli_anything.calibre.core.formats import convert_format
|
||||
|
||||
# Create a dedicated epub for this test to avoid interference
|
||||
epub = os.path.join(self.tmp_root, "convert_test.epub")
|
||||
make_minimal_epub(epub, title="Convert Test", author="Test Author")
|
||||
|
||||
# Add it
|
||||
calibredb = shutil.which("calibredb")
|
||||
result = subprocess.run(
|
||||
[calibredb, "--with-library", self.lib_dir, "add", epub],
|
||||
capture_output=True, text=True, check=True, env=_english_env(),
|
||||
)
|
||||
# Find the new book ID
|
||||
new_id = None
|
||||
for line in result.stdout.splitlines():
|
||||
if "Added book ids:" in line:
|
||||
parts = line.split(":")[-1].strip()
|
||||
if parts.isdigit():
|
||||
new_id = int(parts)
|
||||
if new_id is None:
|
||||
self.skipTest("Could not determine added book ID")
|
||||
|
||||
out_txt = os.path.join(self.tmp_root, f"converted_{new_id}.txt")
|
||||
result = convert_format(
|
||||
self.lib_dir, new_id, "EPUB", "TXT",
|
||||
output_path=out_txt,
|
||||
add_to_library=False,
|
||||
)
|
||||
|
||||
self.assertTrue(os.path.exists(result["output_path"]))
|
||||
size = Path(result["output_path"]).stat().st_size
|
||||
self.assertGreater(size, 0)
|
||||
print(f"\n TXT: {result['output_path']} ({size:,} bytes)")
|
||||
|
||||
def test_convert_epub_to_mobi(self):
|
||||
"""Convert EPUB to MOBI and verify output file magic bytes."""
|
||||
from cli_anything.calibre.core.formats import convert_format
|
||||
|
||||
epub = os.path.join(self.tmp_root, "mobi_test.epub")
|
||||
make_minimal_epub(epub, title="MOBI Test", author="Test Author")
|
||||
calibredb = shutil.which("calibredb")
|
||||
r = subprocess.run(
|
||||
[calibredb, "--with-library", self.lib_dir, "add", epub],
|
||||
capture_output=True, text=True, check=True, env=_english_env(),
|
||||
)
|
||||
new_id = None
|
||||
for line in r.stdout.splitlines():
|
||||
if "Added book ids:" in line:
|
||||
parts = line.split(":")[-1].strip()
|
||||
if parts.isdigit():
|
||||
new_id = int(parts)
|
||||
if new_id is None:
|
||||
self.skipTest("Could not determine added book ID")
|
||||
|
||||
out_mobi = os.path.join(self.tmp_root, f"converted_{new_id}.mobi")
|
||||
result = convert_format(
|
||||
self.lib_dir, new_id, "EPUB", "MOBI",
|
||||
output_path=out_mobi,
|
||||
add_to_library=False,
|
||||
)
|
||||
|
||||
self.assertTrue(os.path.exists(result["output_path"]))
|
||||
size = Path(result["output_path"]).stat().st_size
|
||||
self.assertGreater(size, 0)
|
||||
print(f"\n MOBI: {result['output_path']} ({size:,} bytes)")
|
||||
|
||||
# MOBI files start with PalmDoc header: "BOOKMOBI" at offset 60
|
||||
with open(result["output_path"], "rb") as f:
|
||||
header = f.read(68)
|
||||
# MOBI/AZW3 may also be produced — just verify it's not empty
|
||||
self.assertGreater(len(header), 0)
|
||||
|
||||
def test_convert_adds_to_library(self):
|
||||
"""Convert and add back to library — verify format appears in list."""
|
||||
from cli_anything.calibre.core.formats import convert_format, list_formats
|
||||
|
||||
epub = os.path.join(self.tmp_root, "add_back_test.epub")
|
||||
make_minimal_epub(epub, title="Add Back Test", author="Test Author")
|
||||
calibredb = shutil.which("calibredb")
|
||||
r = subprocess.run(
|
||||
[calibredb, "--with-library", self.lib_dir, "add", epub],
|
||||
capture_output=True, text=True, check=True, env=_english_env(),
|
||||
)
|
||||
new_id = None
|
||||
for line in r.stdout.splitlines():
|
||||
if "Added book ids:" in line:
|
||||
parts = line.split(":")[-1].strip()
|
||||
if parts.isdigit():
|
||||
new_id = int(parts)
|
||||
if new_id is None:
|
||||
self.skipTest("Could not determine added book ID")
|
||||
|
||||
convert_format(
|
||||
self.lib_dir, new_id, "EPUB", "TXT",
|
||||
add_to_library=True,
|
||||
)
|
||||
|
||||
result = list_formats(self.lib_dir, new_id)
|
||||
print(f"\n formats after convert: {result['formats']}")
|
||||
# After adding TXT back, it should appear
|
||||
# (calibredb add_format may map TXT to uppercase)
|
||||
self.assertTrue(
|
||||
any(fmt in result["formats"] for fmt in ["TXT", "EPUB"]),
|
||||
f"Expected TXT or EPUB in {result['formats']}"
|
||||
)
|
||||
|
||||
|
||||
# ── TestCLISubprocess ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCLISubprocess(unittest.TestCase):
|
||||
"""Test the installed cli-anything-calibre command via subprocess."""
|
||||
|
||||
CLI_BASE = _resolve_cli("cli-anything-calibre")
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
calibredb = shutil.which("calibredb")
|
||||
if not calibredb:
|
||||
raise unittest.SkipTest(
|
||||
"calibredb not found. Calibre is required for E2E tests."
|
||||
)
|
||||
cls.tmp = tempfile.mkdtemp(prefix="cli-anything-calibre-subprocess-")
|
||||
cls.lib_dir = os.path.join(cls.tmp, "SubprocessLibrary")
|
||||
os.makedirs(cls.lib_dir)
|
||||
|
||||
cls.epub_path = os.path.join(cls.tmp, "subprocess_test.epub")
|
||||
make_minimal_epub(cls.epub_path, title="CLI Test Book", author="CLI Author")
|
||||
|
||||
print(f"\n[subprocess] Library: {cls.lib_dir}")
|
||||
print(f"[subprocess] EPUB: {cls.epub_path}")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
shutil.rmtree(cls.tmp, ignore_errors=True)
|
||||
|
||||
def _run(self, args, check=True, env=None):
|
||||
full_env = _english_env()
|
||||
if env:
|
||||
full_env.update(env)
|
||||
return subprocess.run(
|
||||
self.CLI_BASE + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check,
|
||||
env=full_env,
|
||||
)
|
||||
|
||||
def test_help(self):
|
||||
result = self._run(["--help"])
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("calibre", result.stdout.lower())
|
||||
|
||||
def test_version(self):
|
||||
result = self._run(["--version"])
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("1.0.0", result.stdout)
|
||||
|
||||
def test_library_connect(self):
|
||||
result = self._run(["library", "connect", self.lib_dir])
|
||||
self.assertEqual(result.returncode, 0)
|
||||
print(f"\n library connect: {result.stdout.strip()}")
|
||||
|
||||
def test_library_info_json(self):
|
||||
# First connect
|
||||
self._run(["library", "connect", self.lib_dir])
|
||||
result = self._run(["--json", "library", "info"])
|
||||
self.assertEqual(result.returncode, 0)
|
||||
data = json.loads(result.stdout)
|
||||
self.assertIn("library_path", data)
|
||||
self.assertIn("book_count", data)
|
||||
print(f"\n library info JSON: {data}")
|
||||
|
||||
def test_books_add_json(self):
|
||||
"""Add an EPUB via CLI and verify JSON response."""
|
||||
env = {"CALIBRE_LIBRARY": self.lib_dir}
|
||||
result = self._run(["--json", "books", "add", self.epub_path], env=env)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
data = json.loads(result.stdout)
|
||||
self.assertIn("added_ids", data)
|
||||
print(f"\n books add JSON: {data}")
|
||||
# Save the book ID for later tests
|
||||
if data["added_ids"]:
|
||||
TestCLISubprocess.added_book_id = data["added_ids"][0]
|
||||
else:
|
||||
TestCLISubprocess.added_book_id = 1
|
||||
|
||||
def test_books_list_json(self):
|
||||
"""List books and verify JSON array."""
|
||||
# Add book first if not done
|
||||
env = {"CALIBRE_LIBRARY": self.lib_dir}
|
||||
self._run(["books", "add", self.epub_path], env=env, check=False)
|
||||
|
||||
result = self._run(["--json", "books", "list"], env=env)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
data = json.loads(result.stdout)
|
||||
self.assertIsInstance(data, list)
|
||||
print(f"\n books list JSON: {len(data)} books")
|
||||
|
||||
def test_books_search_json(self):
|
||||
"""Search books with a query."""
|
||||
env = {"CALIBRE_LIBRARY": self.lib_dir}
|
||||
self._run(["books", "add", self.epub_path], env=env, check=False)
|
||||
|
||||
result = self._run(["--json", "books", "search", "title:CLI Test Book"], env=env)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
data = json.loads(result.stdout)
|
||||
self.assertIn("ids", data)
|
||||
print(f"\n books search JSON: {data}")
|
||||
|
||||
def test_meta_set_and_get(self):
|
||||
"""Set a metadata field and verify it's retrievable."""
|
||||
env = {"CALIBRE_LIBRARY": self.lib_dir}
|
||||
# Add book
|
||||
add_result = self._run(["--json", "books", "add", self.epub_path],
|
||||
env=env, check=False)
|
||||
if add_result.returncode == 0:
|
||||
try:
|
||||
book_id = json.loads(add_result.stdout)["added_ids"][0]
|
||||
except (json.JSONDecodeError, KeyError, IndexError):
|
||||
book_id = 1
|
||||
else:
|
||||
book_id = 1
|
||||
|
||||
# Set publisher
|
||||
result = self._run(
|
||||
["--json", "meta", "set", str(book_id), "publisher", "CLI Press"],
|
||||
env=env,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
data = json.loads(result.stdout)
|
||||
self.assertEqual(data["field"], "publisher")
|
||||
print(f"\n meta set: {data}")
|
||||
|
||||
# Get all metadata
|
||||
result = self._run(["--json", "meta", "get", str(book_id)], env=env)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
meta = json.loads(result.stdout)
|
||||
print(f"\n meta get: id={meta.get('id')}, title={meta.get('title')}")
|
||||
|
||||
def test_full_workflow(self):
|
||||
"""Full workflow: add → set metadata → search → export."""
|
||||
env = {"CALIBRE_LIBRARY": self.lib_dir}
|
||||
|
||||
# 1. Add book
|
||||
result = self._run(["--json", "books", "add", self.epub_path], env=env,
|
||||
check=False)
|
||||
try:
|
||||
book_id = json.loads(result.stdout)["added_ids"][0]
|
||||
except Exception:
|
||||
book_id = 1
|
||||
|
||||
print(f"\n [workflow] Added book_id={book_id}")
|
||||
|
||||
# 2. Set series
|
||||
self._run(["meta", "set", str(book_id), "series", "CLI Series"], env=env)
|
||||
self._run(["meta", "set", str(book_id), "series_index", "1"], env=env)
|
||||
|
||||
# 3. Search
|
||||
result = self._run(["--json", "books", "search", "title:CLI Test Book"],
|
||||
env=env)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
search_data = json.loads(result.stdout)
|
||||
print(f" [workflow] search: {search_data}")
|
||||
|
||||
# 4. Export
|
||||
out_dir = os.path.join(self.tmp, "workflow-export")
|
||||
result = self._run(
|
||||
["books", "export", str(book_id), "--to-dir", out_dir],
|
||||
env=env,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
if os.path.isdir(out_dir):
|
||||
exported = list(Path(out_dir).rglob("*.*"))
|
||||
print(f" [workflow] exported {len(exported)} files to {out_dir}")
|
||||
for f in exported:
|
||||
print(f" {f} ({f.stat().st_size:,} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility modules for cli-anything-calibre."""
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Backend utilities — invoke the real Calibre tools as subprocesses.
|
||||
|
||||
This module locates calibredb, ebook-convert, and ebook-meta on the system PATH
|
||||
and runs them with proper arguments. It never reimplements Calibre's logic.
|
||||
|
||||
Calibre is a HARD DEPENDENCY. If it is not installed, clear error messages are
|
||||
shown with install instructions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _english_env() -> dict[str, str]:
|
||||
"""Return a copy of the current environment with Calibre forced to English.
|
||||
|
||||
This ensures that output strings like 'Added book ids:' are always in
|
||||
English regardless of the system locale — critical for reliable parsing.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
env["CALIBRE_OVERRIDE_LANG"] = "en"
|
||||
return env
|
||||
|
||||
|
||||
# ── Tool discovery ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def find_calibredb() -> str:
|
||||
"""Return path to calibredb or raise with install instructions."""
|
||||
path = shutil.which("calibredb")
|
||||
if path:
|
||||
return path
|
||||
raise RuntimeError(
|
||||
"calibredb is not installed or not in PATH.\n\n"
|
||||
"Install Calibre:\n"
|
||||
" Ubuntu/Debian: sudo apt-get install calibre\n"
|
||||
" Fedora/RHEL: sudo dnf install calibre\n"
|
||||
" macOS: brew install --cask calibre\n"
|
||||
" Windows/Other: https://calibre-ebook.com/download\n\n"
|
||||
"After installing, ensure 'calibredb' is in your PATH."
|
||||
)
|
||||
|
||||
|
||||
def find_ebook_convert() -> str:
|
||||
"""Return path to ebook-convert or raise with install instructions."""
|
||||
path = shutil.which("ebook-convert")
|
||||
if path:
|
||||
return path
|
||||
raise RuntimeError(
|
||||
"ebook-convert is not installed or not in PATH.\n\n"
|
||||
"ebook-convert is part of Calibre.\n"
|
||||
"Install Calibre:\n"
|
||||
" Ubuntu/Debian: sudo apt-get install calibre\n"
|
||||
" macOS: brew install --cask calibre\n"
|
||||
" Other: https://calibre-ebook.com/download"
|
||||
)
|
||||
|
||||
|
||||
def find_ebook_meta() -> str:
|
||||
"""Return path to ebook-meta or raise with install instructions."""
|
||||
path = shutil.which("ebook-meta")
|
||||
if path:
|
||||
return path
|
||||
raise RuntimeError(
|
||||
"ebook-meta is not installed or not in PATH.\n\n"
|
||||
"ebook-meta is part of Calibre.\n"
|
||||
"Install Calibre:\n"
|
||||
" Ubuntu/Debian: sudo apt-get install calibre\n"
|
||||
" macOS: brew install --cask calibre\n"
|
||||
" Other: https://calibre-ebook.com/download"
|
||||
)
|
||||
|
||||
|
||||
# ── Tool execution ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_calibredb(
|
||||
args: list[str],
|
||||
library_path: str | None = None,
|
||||
timeout: int = 120,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Run calibredb with the given args and optional --with-library.
|
||||
|
||||
Returns dict with stdout, stderr, returncode.
|
||||
Raises RuntimeError on non-zero exit.
|
||||
"""
|
||||
calibredb = find_calibredb()
|
||||
cmd = [calibredb]
|
||||
if library_path:
|
||||
cmd.extend(["--with-library", library_path])
|
||||
cmd.extend(args)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=_english_env(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f"calibredb timed out after {timeout}s: {' '.join(cmd)}")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"calibredb failed (exit {result.returncode}):\n"
|
||||
f" Command: {' '.join(cmd)}\n"
|
||||
f" stderr: {result.stderr.strip()}\n"
|
||||
f" stdout: {result.stdout.strip()}"
|
||||
)
|
||||
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"returncode": result.returncode,
|
||||
}
|
||||
|
||||
|
||||
def run_ebook_convert(
|
||||
args: list[str],
|
||||
timeout: int = 300,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Run ebook-convert with the given args.
|
||||
|
||||
Returns dict with stdout, stderr, returncode.
|
||||
Raises RuntimeError on non-zero exit.
|
||||
"""
|
||||
ebook_convert = find_ebook_convert()
|
||||
cmd = [ebook_convert] + args
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=_english_env(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f"ebook-convert timed out after {timeout}s")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"ebook-convert failed (exit {result.returncode}):\n"
|
||||
f" Command: {' '.join(cmd)}\n"
|
||||
f" stderr: {result.stderr.strip()}\n"
|
||||
f" stdout: {result.stdout.strip()}"
|
||||
)
|
||||
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"returncode": result.returncode,
|
||||
}
|
||||
|
||||
|
||||
def run_ebook_meta(
|
||||
args: list[str],
|
||||
timeout: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Run ebook-meta with the given args.
|
||||
|
||||
Returns dict with stdout, stderr, returncode.
|
||||
Raises RuntimeError on non-zero exit.
|
||||
"""
|
||||
ebook_meta = find_ebook_meta()
|
||||
cmd = [ebook_meta] + args
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=_english_env(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f"ebook-meta timed out after {timeout}s")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"ebook-meta failed (exit {result.returncode}):\n"
|
||||
f" Command: {' '.join(cmd)}\n"
|
||||
f" stderr: {result.stderr.strip()}\n"
|
||||
f" stdout: {result.stdout.strip()}"
|
||||
)
|
||||
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"returncode": result.returncode,
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||
|
||||
Copy this file into your CLI package at:
|
||||
cli_anything/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("shotcut", version="1.0.0")
|
||||
skin.print_banner() # auto-detects skills/SKILL.md inside the package
|
||||
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
|
||||
skin.success("Project saved")
|
||||
skin.error("File not found")
|
||||
skin.warning("Unsaved changes")
|
||||
skin.info("Processing 24 clips...")
|
||||
skin.status("Track 1", "3 clips, 00:02:30")
|
||||
skin.table(headers, rows)
|
||||
skin.print_goodbye()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_ITALIC = "\033[3m"
|
||||
_UNDERLINE = "\033[4m"
|
||||
|
||||
# Brand colors
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN_BG = "\033[48;5;80m"
|
||||
_WHITE = "\033[97m"
|
||||
_GRAY = "\033[38;5;245m"
|
||||
_DARK_GRAY = "\033[38;5;240m"
|
||||
_LIGHT_GRAY = "\033[38;5;250m"
|
||||
|
||||
# Software accent colors — each software gets a unique accent
|
||||
_ACCENT_COLORS = {
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"calibre": "\033[38;5;166m", # warm brown-orange
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"obs_studio": "\033[38;5;55m", # purple
|
||||
"kdenlive": "\033[38;5;69m", # slate blue
|
||||
"shotcut": "\033[38;5;35m", # teal green
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
# Status colors
|
||||
_GREEN = "\033[38;5;78m"
|
||||
_YELLOW = "\033[38;5;220m"
|
||||
_RED = "\033[38;5;196m"
|
||||
_BLUE = "\033[38;5;75m"
|
||||
_MAGENTA = "\033[38;5;176m"
|
||||
|
||||
# ── Brand icon ────────────────────────────────────────────────────────
|
||||
|
||||
# The cli-anything icon: a small colored diamond/chevron mark
|
||||
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||||
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||||
|
||||
# ── Box drawing characters ────────────────────────────────────────────
|
||||
|
||||
_H_LINE = "─"
|
||||
_V_LINE = "│"
|
||||
_TL = "╭"
|
||||
_TR = "╮"
|
||||
_BL = "╰"
|
||||
_BR = "╯"
|
||||
_T_DOWN = "┬"
|
||||
_T_UP = "┴"
|
||||
_T_RIGHT = "├"
|
||||
_T_LEFT = "┤"
|
||||
_CROSS = "┼"
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes for length calculation."""
|
||||
import re
|
||||
return re.sub(r"\033\[[^m]*m", "", text)
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Get visible length of text (excluding ANSI codes)."""
|
||||
return len(_strip_ansi(text))
|
||||
|
||||
|
||||
class ReplSkin:
|
||||
"""Unified REPL skin for cli-anything CLIs.
|
||||
|
||||
Provides consistent branding, prompts, and message formatting
|
||||
across all CLI harnesses built with the cli-anything methodology.
|
||||
"""
|
||||
|
||||
def __init__(self, software: str, version: str = "1.0.0",
|
||||
history_file: str | None = None, skill_path: str | None = None):
|
||||
"""Initialize the REPL skin.
|
||||
|
||||
Args:
|
||||
software: Software name (e.g., "gimp", "shotcut", "blender").
|
||||
version: CLI version string.
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/history
|
||||
skill_path: Path to the SKILL.md file for agent discovery.
|
||||
Auto-detected from the package's skills/ directory if not provided.
|
||||
Displayed in banner for AI agents to know where to read skill info.
|
||||
"""
|
||||
self.software = software.lower().replace("-", "_")
|
||||
self.display_name = software.replace("_", " ").title()
|
||||
self.version = version
|
||||
|
||||
# Auto-detect skill path from package layout:
|
||||
# cli_anything/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
if skill_path is None:
|
||||
from pathlib import Path
|
||||
_auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
|
||||
if _auto.is_file():
|
||||
skill_path = str(_auto)
|
||||
self.skill_path = skill_path
|
||||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||
|
||||
# History file
|
||||
if history_file is None:
|
||||
from pathlib import Path
|
||||
hist_dir = Path.home() / f".cli-anything-{self.software}"
|
||||
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.history_file = str(hist_dir / "history")
|
||||
else:
|
||||
self.history_file = history_file
|
||||
|
||||
# Detect terminal capabilities
|
||||
self._color = self._detect_color_support()
|
||||
|
||||
def _detect_color_support(self) -> bool:
|
||||
"""Check if terminal supports color."""
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||||
return False
|
||||
if not hasattr(sys.stdout, "isatty"):
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def _c(self, code: str, text: str) -> str:
|
||||
"""Apply color code if colors are supported."""
|
||||
if not self._color:
|
||||
return text
|
||||
return f"{code}{text}{_RESET}"
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────
|
||||
|
||||
def print_banner(self):
|
||||
"""Print the startup banner with branding."""
|
||||
inner = 54
|
||||
|
||||
def _box_line(content: str) -> str:
|
||||
"""Wrap content in box drawing, padding to inner width."""
|
||||
pad = inner - _visible_len(content)
|
||||
vl = self._c(_DARK_GRAY, _V_LINE)
|
||||
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||||
|
||||
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||||
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||||
|
||||
# Title: ◆ cli-anything · Shotcut
|
||||
icon = self._c(_CYAN + _BOLD, "◆")
|
||||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||
dot = self._c(_DARK_GRAY, "·")
|
||||
name = self._c(self.accent + _BOLD, self.display_name)
|
||||
title = f" {icon} {brand} {dot} {name}"
|
||||
|
||||
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||||
empty = ""
|
||||
|
||||
# Skill path for agent discovery
|
||||
skill_line = None
|
||||
if self.skill_path:
|
||||
skill_icon = self._c(_MAGENTA, "◇")
|
||||
skill_label = self._c(_DARK_GRAY, " Skill:")
|
||||
skill_path_display = self._c(_LIGHT_GRAY, self.skill_path)
|
||||
skill_line = f" {skill_icon} {skill_label} {skill_path_display}"
|
||||
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
if skill_line:
|
||||
print(_box_line(skill_line))
|
||||
print(_box_line(empty))
|
||||
print(_box_line(tip))
|
||||
print(bot)
|
||||
print()
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def prompt(self, project_name: str = "", modified: bool = False,
|
||||
context: str = "") -> str:
|
||||
"""Build a styled prompt string for prompt_toolkit or input().
|
||||
|
||||
Args:
|
||||
project_name: Current project name (empty if none open).
|
||||
modified: Whether the project has unsaved changes.
|
||||
context: Optional extra context to show in prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Icon
|
||||
if self._color:
|
||||
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||
else:
|
||||
parts.append("> ")
|
||||
|
||||
# Software name
|
||||
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||
|
||||
# Project context
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||
parts.append(self._c(_DARK_GRAY, ']'))
|
||||
|
||||
parts.append(self._c(_GRAY, " ❯ "))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||
context: str = ""):
|
||||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||
|
||||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||
|
||||
Returns:
|
||||
list of (style, text) tuples for prompt_toolkit.
|
||||
"""
|
||||
tokens = []
|
||||
|
||||
tokens.append(("class:icon", "◆ "))
|
||||
tokens.append(("class:software", self.software))
|
||||
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
tokens.append(("class:bracket", " ["))
|
||||
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||
tokens.append(("class:bracket", "]"))
|
||||
|
||||
tokens.append(("class:arrow", " ❯ "))
|
||||
|
||||
return tokens
|
||||
|
||||
def get_prompt_style(self):
|
||||
"""Get a prompt_toolkit Style object matching the skin.
|
||||
|
||||
Returns:
|
||||
prompt_toolkit.styles.Style
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.styles import Style
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
|
||||
return Style.from_dict({
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
})
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────
|
||||
|
||||
def success(self, message: str):
|
||||
"""Print a success message with green checkmark."""
|
||||
icon = self._c(_GREEN + _BOLD, "✓")
|
||||
print(f" {icon} {self._c(_GREEN, message)}")
|
||||
|
||||
def error(self, message: str):
|
||||
"""Print an error message with red cross."""
|
||||
icon = self._c(_RED + _BOLD, "✗")
|
||||
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||
|
||||
def warning(self, message: str):
|
||||
"""Print a warning message with yellow triangle."""
|
||||
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||
|
||||
def info(self, message: str):
|
||||
"""Print an info message with blue dot."""
|
||||
icon = self._c(_BLUE, "●")
|
||||
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||
|
||||
def hint(self, message: str):
|
||||
"""Print a subtle hint message."""
|
||||
print(f" {self._c(_DARK_GRAY, message)}")
|
||||
|
||||
def section(self, title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||
|
||||
# ── Status display ────────────────────────────────────────────────
|
||||
|
||||
def status(self, label: str, value: str):
|
||||
"""Print a key-value status line."""
|
||||
lbl = self._c(_GRAY, f" {label}:")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def status_block(self, items: dict[str, str], title: str = ""):
|
||||
"""Print a block of status key-value pairs.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs.
|
||||
title: Optional title for the block.
|
||||
"""
|
||||
if title:
|
||||
self.section(title)
|
||||
|
||||
max_key = max(len(k) for k in items) if items else 0
|
||||
for label, value in items.items():
|
||||
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def progress(self, current: int, total: int, label: str = ""):
|
||||
"""Print a simple progress indicator.
|
||||
|
||||
Args:
|
||||
current: Current step number.
|
||||
total: Total number of steps.
|
||||
label: Optional label for the progress.
|
||||
"""
|
||||
pct = int(current / total * 100) if total > 0 else 0
|
||||
bar_width = 20
|
||||
filled = int(bar_width * current / total) if total > 0 else 0
|
||||
bar = "█" * filled + "░" * (bar_width - filled)
|
||||
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||
if label:
|
||||
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||
print(text)
|
||||
|
||||
# ── Table display ─────────────────────────────────────────────────
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]],
|
||||
max_col_width: int = 40):
|
||||
"""Print a formatted table with box-drawing characters.
|
||||
|
||||
Args:
|
||||
headers: Column header strings.
|
||||
rows: List of rows, each a list of cell strings.
|
||||
max_col_width: Maximum column width before truncation.
|
||||
"""
|
||||
if not headers:
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = min(
|
||||
max(col_widths[i], len(str(cell))), max_col_width
|
||||
)
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
t = str(text)[:width]
|
||||
return t + " " * (width - len(t))
|
||||
|
||||
# Header
|
||||
header_cells = [
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||
for i, h in enumerate(headers)
|
||||
]
|
||||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
header_line = f" {sep.join(header_cells)}"
|
||||
print(header_line)
|
||||
|
||||
# Separator
|
||||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||
print(sep_line)
|
||||
|
||||
# Rows
|
||||
for row in rows:
|
||||
cells = []
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
print(f" {row_sep.join(cells)}")
|
||||
|
||||
# ── Help display ──────────────────────────────────────────────────
|
||||
|
||||
def help(self, commands: dict[str, str]):
|
||||
"""Print a formatted help listing.
|
||||
|
||||
Args:
|
||||
commands: Dict of command -> description pairs.
|
||||
"""
|
||||
self.section("Commands")
|
||||
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||
for cmd, desc in commands.items():
|
||||
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||
desc_styled = self._c(_GRAY, f" {desc}")
|
||||
print(f"{cmd_styled}{desc_styled}")
|
||||
print()
|
||||
|
||||
# ── Goodbye ───────────────────────────────────────────────────────
|
||||
|
||||
def print_goodbye(self):
|
||||
"""Print a styled goodbye message."""
|
||||
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||
|
||||
# ── Prompt toolkit session factory ────────────────────────────────
|
||||
|
||||
def create_prompt_session(self):
|
||||
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||
|
||||
Returns:
|
||||
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
style = self.get_prompt_style()
|
||||
|
||||
session = PromptSession(
|
||||
history=FileHistory(self.history_file),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
style=style,
|
||||
enable_history_search=True,
|
||||
)
|
||||
return session
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def get_input(self, pt_session, project_name: str = "",
|
||||
modified: bool = False, context: str = "") -> str:
|
||||
"""Get input from user using prompt_toolkit or fallback.
|
||||
|
||||
Args:
|
||||
pt_session: A prompt_toolkit PromptSession (or None).
|
||||
project_name: Current project name.
|
||||
modified: Whether project has unsaved changes.
|
||||
context: Optional context string.
|
||||
|
||||
Returns:
|
||||
User input string (stripped).
|
||||
"""
|
||||
if pt_session is not None:
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
tokens = self.prompt_tokens(project_name, modified, context)
|
||||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||
else:
|
||||
raw_prompt = self.prompt(project_name, modified, context)
|
||||
return input(raw_prompt).strip()
|
||||
|
||||
# ── Toolbar builder ───────────────────────────────────────────────
|
||||
|
||||
def bottom_toolbar(self, items: dict[str, str]):
|
||||
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs to show in toolbar.
|
||||
|
||||
Returns:
|
||||
A callable that returns FormattedText for the toolbar.
|
||||
"""
|
||||
def toolbar():
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
parts = []
|
||||
for i, (k, v) in enumerate(items.items()):
|
||||
if i > 0:
|
||||
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||
parts.append(("class:bottom-toolbar", v))
|
||||
return FormattedText(parts)
|
||||
return toolbar
|
||||
|
||||
|
||||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||
|
||||
_ANSI_256_TO_HEX = {
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||
"\033[38;5;166m": "#d75f00", # calibre warm brown-orange
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
from pathlib import Path
|
||||
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
BASE_DIR = Path(__file__).parent
|
||||
long_description = (BASE_DIR / "cli_anything" / "calibre" / "README.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
setup(
|
||||
name="cli-anything-calibre",
|
||||
version="1.0.0",
|
||||
description="CLI harness for Calibre e-book manager — part of the cli-anything toolkit",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
include_package_data=True,
|
||||
package_data={"cli_anything.calibre": ["skills/*.md", "README.md"]},
|
||||
install_requires=[
|
||||
"click>=8.0.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-calibre=cli_anything.calibre.calibre_cli:main",
|
||||
],
|
||||
},
|
||||
python_requires=">=3.10",
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Topic :: Utilities",
|
||||
],
|
||||
)
|
||||
@@ -290,6 +290,25 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "calibre",
|
||||
"display_name": "Calibre",
|
||||
"version": "1.0.0",
|
||||
"description": "E-book library management — list, search, metadata editing, format conversion via calibredb, ebook-convert, ebook-meta",
|
||||
"requires": "Calibre (apt install calibre, brew install --cask calibre)",
|
||||
"homepage": "https://calibre-ebook.com",
|
||||
"source_url": null,
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=calibre/agent-harness",
|
||||
"entry_point": "cli-anything-calibre",
|
||||
"skill_md": "calibre/agent-harness/cli_anything/calibre/skills/SKILL.md",
|
||||
"category": "office",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "OGRLEAF",
|
||||
"url": "https://github.com/OGRLEAF"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "libreoffice",
|
||||
"display_name": "LibreOffice",
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
---
|
||||
name: "cli-anything-calibre"
|
||||
description: >-
|
||||
Command-line interface for Calibre - A stateful CLI harness for e-book library management, metadata editing, and format conversion wrapping the real Calibre tools (calibredb, ebook-convert, ebook-meta)...
|
||||
---
|
||||
|
||||
# cli-anything-calibre
|
||||
|
||||
A stateful CLI harness for Calibre e-book management. Wraps the real Calibre tools (`calibredb`, `ebook-convert`, `ebook-meta`) to give AI agents and scripts a clean, structured interface for library operations, metadata editing, and format conversion.
|
||||
|
||||
## Installation
|
||||
|
||||
This CLI is installed as part of the cli-anything-calibre package:
|
||||
|
||||
```bash
|
||||
pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=calibre/agent-harness
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- Calibre must be installed on your system (hard dependency)
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt-get install calibre
|
||||
|
||||
# macOS
|
||||
brew install --cask calibre
|
||||
|
||||
# Verify tools are in PATH
|
||||
which calibredb
|
||||
which ebook-convert
|
||||
which ebook-meta
|
||||
```
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-calibre --help
|
||||
|
||||
# Start interactive REPL mode
|
||||
cli-anything-calibre
|
||||
|
||||
# Connect to a Calibre library
|
||||
cli-anything-calibre library connect ~/Calibre\ Library
|
||||
|
||||
# Run with JSON output (for agent consumption)
|
||||
cli-anything-calibre --json books list
|
||||
```
|
||||
|
||||
### REPL Mode
|
||||
|
||||
When invoked without a subcommand, the CLI enters an interactive REPL session:
|
||||
|
||||
```bash
|
||||
cli-anything-calibre
|
||||
# Enter commands interactively with tab-completion and history
|
||||
# Use 'help' to see available commands
|
||||
# Use 'quit' or 'exit' to leave
|
||||
```
|
||||
|
||||
|
||||
## Command Groups
|
||||
|
||||
|
||||
### Library
|
||||
|
||||
Library management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `connect <path>` | Set active library path |
|
||||
| `info` | Show library statistics (book count, formats, db size) |
|
||||
| `check` | Verify library integrity using calibredb check_library |
|
||||
|
||||
|
||||
### Books
|
||||
|
||||
Book operations (wrap `calibredb`).
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List books with filtering and sorting |
|
||||
| `search <query>` | Search using Calibre query language |
|
||||
| `add <files>` | Add book files to library |
|
||||
| `remove <ids>` | Remove books (move to trash or permanent delete) |
|
||||
| `show <id>` | Show full metadata for a book |
|
||||
| `export <ids>` | Export books to directory |
|
||||
| `export-chapters <id>` | Export each chapter as separate PDF (requires EPUB format) |
|
||||
|
||||
|
||||
### Meta
|
||||
|
||||
Metadata editing (wrap `calibredb set_metadata`).
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `get <id> [field]` | Get metadata (all or specific field) |
|
||||
| `set <id> <field> <value>` | Set a metadata field |
|
||||
| `embed <ids>` | Embed metadata into book files |
|
||||
|
||||
|
||||
### Formats
|
||||
|
||||
Format management (wrap `calibredb` + `ebook-convert`).
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list <id>` | List available formats for a book |
|
||||
| `add <id> <file>` | Add a format to a book |
|
||||
| `remove <id> <fmt>` | Remove a format from a book |
|
||||
| `convert <id> <input_fmt> <output_fmt>` | Convert book format |
|
||||
|
||||
|
||||
### Custom
|
||||
|
||||
Custom columns (wrap `calibredb`).
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List all custom columns |
|
||||
| `add <label> <name> <type>` | Create custom column |
|
||||
| `remove <label>` | Delete custom column |
|
||||
| `set <id> <label> <value>` | Set custom field value |
|
||||
|
||||
|
||||
### Catalog
|
||||
|
||||
Catalog generation.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `catalog <output>` | Generate a catalog of the library (EPUB, CSV, or OPDS) |
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
### Connect and List Books
|
||||
|
||||
Connect to your Calibre library and list books.
|
||||
|
||||
```bash
|
||||
cli-anything-calibre library connect ~/Calibre\ Library
|
||||
cli-anything-calibre books list
|
||||
# Or with JSON output
|
||||
cli-anything-calibre --json books list --search "author:asimov"
|
||||
```
|
||||
|
||||
|
||||
### Search and Filter
|
||||
|
||||
Search books using Calibre query language.
|
||||
|
||||
```bash
|
||||
cli-anything-calibre books search "title:Foundation"
|
||||
cli-anything-calibre books search "author:asimov and tags:scifi"
|
||||
cli-anything-calibre books search "rating:>3"
|
||||
```
|
||||
|
||||
|
||||
### Metadata Editing
|
||||
|
||||
Set metadata fields on books.
|
||||
|
||||
```bash
|
||||
cli-anything-calibre meta set 42 title "New Title"
|
||||
cli-anything-calibre meta set 42 series "Foundation"
|
||||
cli-anything-calibre meta set 42 series_index 1
|
||||
cli-anything-calibre meta set 42 tags "scifi,classic"
|
||||
cli-anything-calibre meta set 42 rating 5
|
||||
```
|
||||
|
||||
|
||||
### Format Conversion
|
||||
|
||||
Convert between e-book formats.
|
||||
|
||||
```bash
|
||||
cli-anything-calibre formats convert 42 EPUB MOBI
|
||||
cli-anything-calibre formats convert 42 EPUB PDF --output /tmp/book.pdf
|
||||
```
|
||||
|
||||
|
||||
### Export Chapters as PDFs
|
||||
|
||||
Export each chapter of an EPUB as a separate PDF file.
|
||||
|
||||
```bash
|
||||
cli-anything-calibre books export-chapters 42 --to-dir ./pdfs
|
||||
cli-anything-calibre books export-chapters 42 --to-dir ./pdfs --chapters 1-5
|
||||
```
|
||||
|
||||
|
||||
## Calibre Query Language
|
||||
|
||||
Used with `books search` and `books list --search`:
|
||||
|
||||
```
|
||||
author:asimov # Author contains "asimov"
|
||||
title:"Foundation" # Title phrase
|
||||
tags:fiction # Tag match
|
||||
rating:>3 # Rating greater than 3
|
||||
series:"Foundation" # Series match
|
||||
pubdate:[2020-01-01,2021-12-31] # Date range
|
||||
identifiers:isbn:1234567890 # Specific identifier
|
||||
has:cover # Has cover image
|
||||
not:tags:fiction # Negation
|
||||
author:asimov and tags:scifi # Boolean AND
|
||||
```
|
||||
|
||||
## Key Metadata Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `title` | text | Book title |
|
||||
| `authors` | text | Author names (&-separated) |
|
||||
| `tags` | text | Comma-separated tags |
|
||||
| `series` | text | Series name |
|
||||
| `series_index` | float | Position in series |
|
||||
| `rating` | float | Rating 1-5 |
|
||||
| `publisher` | text | Publisher name |
|
||||
| `pubdate` | datetime | Publication date |
|
||||
| `comments` | text | Description/comments |
|
||||
| `languages` | text | Language codes |
|
||||
| `identifiers` | text | ISBN, ASIN, etc. (`type:value`) |
|
||||
|
||||
## Supported Formats
|
||||
|
||||
**Input:** EPUB, MOBI, AZW, AZW3, PDF, HTML, DOCX, ODT, FB2, TXT, RTF, LIT, and more
|
||||
|
||||
**Output (conversion):** EPUB, MOBI, AZW3, PDF, HTML, DOCX, TXT, and more
|
||||
|
||||
## State Management
|
||||
|
||||
The CLI maintains session state with:
|
||||
|
||||
- **Session file**: `~/.cli-anything-calibre/session.json`
|
||||
- **Library path persistence**: Active library is saved across sessions
|
||||
- **Environment override**: `CALIBRE_LIBRARY` environment variable
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support dual output modes:
|
||||
|
||||
- **Human-readable** (default): Tables, colors, formatted text
|
||||
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
|
||||
|
||||
```bash
|
||||
# Human output
|
||||
cli-anything-calibre books list
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-calibre --json books list
|
||||
```
|
||||
|
||||
## For AI Agents
|
||||
|
||||
When using this CLI programmatically:
|
||||
|
||||
1. **Always use `--json` flag** for parseable output
|
||||
2. **Check return codes** - 0 for success, non-zero for errors
|
||||
3. **Parse stderr** for error messages on failure
|
||||
4. **Verify outputs exist** after export/conversion operations
|
||||
5. **Use `--library` flag** or `CALIBRE_LIBRARY` env to specify library path
|
||||
6. **Chapter export requires EPUB format** - convert first if needed
|
||||
|
||||
## More Information
|
||||
|
||||
- Full documentation: See README.md in the package
|
||||
- Architecture SOP: See CALIBRE.md in the agent-harness directory
|
||||
- Test coverage: See test_core.py and test_full_e2e.py in the tests directory
|
||||
- Methodology: See HARNESS.md in the cli-anything-plugin
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
Reference in New Issue
Block a user