diff --git a/calibre-plugin/.gitignore b/calibre-plugin/.gitignore new file mode 100644 index 0000000..8c1c7a9 --- /dev/null +++ b/calibre-plugin/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +kobors-upload.zip diff --git a/calibre-plugin/README.md b/calibre-plugin/README.md new file mode 100644 index 0000000..95838be --- /dev/null +++ b/calibre-plugin/README.md @@ -0,0 +1,60 @@ +# Kobors Upload — Calibre plugin + +Sends selected books from Calibre to a [kobors](../) server with one click. +It authenticates with an API token (no password), and embeds Calibre's curated +metadata — title, authors, **series**, tags, description, and cover — into the +uploaded EPUB so kobors keeps it. Your Calibre library files are never modified. + +## Build + +```bash +./build.sh # produces kobors-upload.zip +``` + +(Requires the `zip` CLI. The zip is just the `.py` files plus the +`plugin-import-name-kobors.txt` marker at its root.) + +## Install + +1. Calibre → **Preferences → Plugins → Load plugin from file** → pick + `kobors-upload.zip`. +2. Restart Calibre if prompted. A **Send to kobors** button appears on the main + toolbar. (If it doesn't, add it via *Preferences → Toolbars & menus → The + main toolbar*.) + +## Configure + +1. In kobors' web UI, create an API token (the **Tokens** section — the same + tokens used for Kobo devices). +2. Calibre → **Preferences → Plugins → Kobors Upload → Customize plugin**. +3. Enter your server URL (e.g. `https://books.example.com`) and paste the token. + +## Use + +Select one or more books, click **Send to kobors**. A progress dialog runs the +upload; when finished it reports how many were uploaded, skipped, or failed. + +- **Skipped** means the book is already on the server (kobors dedupes by + content). So "select everything → Send to kobors" is a safe way to sync only + what's new. +- Only **EPUB** (or KEPUB) formats are sent. Books without one are listed as + skipped-for-no-format in the summary details. + +## How it works + +For each selected book the plugin: + +1. Reads the EPUB from Calibre and embeds the current Calibre metadata into an + in-memory copy (`calibre.ebooks.metadata.meta.set_metadata`). +2. `POST`s it as `multipart/form-data` to `/api/upload` with an + `Authorization: Bearer ` header. + +kobors resolves that token via its existing `auth_tokens` table (shared with +Kobo device auth) and stores the book as an upload owned by the token's user. +`409 Conflict` from the server means "already uploaded" and is reported as a +skip. + +## Requirements + +- Calibre 6.0+ (Qt6). +- A kobors server reachable over HTTP(S) with token upload support. diff --git a/calibre-plugin/__init__.py b/calibre-plugin/__init__.py new file mode 100644 index 0000000..7fdeab1 --- /dev/null +++ b/calibre-plugin/__init__.py @@ -0,0 +1,40 @@ +""" +Kobors Upload — a Calibre plugin that sends selected books to a kobors server. + +The plugin authenticates with an `Authorization: Bearer ` header using a +token minted in the kobors web UI (the same per-user tokens used for Kobo +devices), and POSTs each book to `/api/upload`. Before sending, Calibre's +curated metadata (title, authors, series, tags, description, cover) is embedded +into an in-memory copy of the EPUB so the server preserves it — the books in +your Calibre library are never modified. +""" + +from calibre.customize import InterfaceActionBase + + +class KoborsUploadBase(InterfaceActionBase): + """Plugin entry point: declares metadata and points at the GUI action.""" + + name = "Kobors Upload" + description = "Send selected books to a kobors server" + supported_platforms = ["windows", "osx", "linux"] + author = "kobors" + version = (1, 0, 0) + # Qt6-based (uses `qt.core` and scoped enums); calibre 6.0 is the first + # release on Qt6. + minimum_calibre_version = (6, 0, 0) + + # module_path:ClassName of the InterfaceAction. `kobors` matches the + # plugin-import-name-kobors.txt marker in the zip root. + actual_plugin = "calibre_plugins.kobors.ui:KoborsUploadAction" + + def is_customizable(self): + return True + + def config_widget(self): + from calibre_plugins.kobors.config import ConfigWidget + + return ConfigWidget() + + def save_settings(self, config_widget): + config_widget.save_settings() diff --git a/calibre-plugin/build.sh b/calibre-plugin/build.sh new file mode 100755 index 0000000..7589024 --- /dev/null +++ b/calibre-plugin/build.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Build the installable Calibre plugin zip from this directory's contents. +# +# The zip's files must live at its root (Calibre imports the package from the +# `plugin-import-name-kobors.txt` marker), so we zip the contents, not the dir. +set -euo pipefail + +cd "$(dirname "$0")" +out="kobors-upload.zip" +rm -f "$out" +zip -r "$out" \ + plugin-import-name-kobors.txt \ + __init__.py \ + ui.py \ + config.py \ + main.py +echo "Built $out" +echo "Install: Calibre → Preferences → Plugins → Load plugin from file → $out" diff --git a/calibre-plugin/config.py b/calibre-plugin/config.py new file mode 100644 index 0000000..de2f4ef --- /dev/null +++ b/calibre-plugin/config.py @@ -0,0 +1,40 @@ +"""Plugin configuration: the kobors server URL and API token.""" + +from qt.core import QLabel, QLineEdit, QVBoxLayout, QWidget + +from calibre.utils.config import JSONConfig + +# Persisted under calibre's config dir as plugins/kobors.json. +prefs = JSONConfig("plugins/kobors") +prefs.defaults["server_url"] = "" +prefs.defaults["token"] = "" + + +class ConfigWidget(QWidget): + """Preferences panel shown in Calibre's plugin customization dialog.""" + + def __init__(self): + QWidget.__init__(self) + layout = QVBoxLayout(self) + self.setLayout(layout) + + layout.addWidget(QLabel("kobors server URL (e.g. https://books.example.com):")) + self.server_url = QLineEdit(self) + self.server_url.setText(prefs["server_url"]) + self.server_url.setPlaceholderText("https://books.example.com") + layout.addWidget(self.server_url) + + layout.addWidget( + QLabel("API token (create one under Tokens in the kobors web UI):") + ) + self.token = QLineEdit(self) + self.token.setText(prefs["token"]) + self.token.setEchoMode(QLineEdit.EchoMode.Password) + self.token.setPlaceholderText("00000000-0000-0000-0000-000000000000") + layout.addWidget(self.token) + + layout.addStretch(1) + + def save_settings(self): + prefs["server_url"] = self.server_url.text().strip() + prefs["token"] = self.token.text().strip() diff --git a/calibre-plugin/main.py b/calibre-plugin/main.py new file mode 100644 index 0000000..edcd04e --- /dev/null +++ b/calibre-plugin/main.py @@ -0,0 +1,190 @@ +"""Upload logic for the 'Send to kobors' action. + +Runs the upload loop on the GUI thread, pumping events through a +``QProgressDialog`` so the UI stays responsive and cancellable. Network calls +use the Python standard library only (no extra plugin dependencies). +""" + +import io +import urllib.error +import urllib.request +import uuid + +from qt.core import QApplication, QProgressDialog, Qt + +from calibre.gui2 import error_dialog, info_dialog + + +def run_upload(gui): + """Entry point invoked by the toolbar action.""" + from calibre_plugins.kobors.config import prefs + + server = (prefs["server_url"] or "").strip().rstrip("/") + token = (prefs["token"] or "").strip() + if not server or not token: + return error_dialog( + gui, + "kobors not configured", + "Set the server URL and API token first: Preferences → Plugins " + "→ Kobors Upload → Customize plugin.", + show=True, + ) + + ids = list(gui.library_view.get_selected_ids()) + if not ids: + return error_dialog( + gui, + "No books selected", + "Select one or more books to send to kobors.", + show=True, + ) + + db = gui.current_db.new_api + + progress = QProgressDialog("Uploading to kobors…", "Cancel", 0, len(ids), gui) + progress.setWindowTitle("Send to kobors") + progress.setWindowModality(Qt.WindowModality.WindowModal) + progress.setMinimumDuration(0) + + uploaded = 0 + skipped = 0 + no_format = [] + failures = [] # list of (title, reason) + + for i, book_id in enumerate(ids): + if progress.wasCanceled(): + break + title = db.field_for("title", book_id) or ("Book %d" % book_id) + progress.setValue(i) + progress.setLabelText("Uploading: %s" % title) + QApplication.processEvents() + + prepared = _prepare_book(db, book_id) + if prepared is None: + no_format.append(title) + continue + + filename, data = prepared + code, message = _post_book(server, token, filename, data) + if code == 200: + uploaded += 1 + elif code == 409: + # Server dedupes by owner + content hash: already uploaded. + skipped += 1 + elif code in (401, 403): + # Authentication won't recover mid-run — stop and report. + failures.append((title, "authentication failed (check the API token)")) + break + else: + if code: + reason = "HTTP %s" % code + if message: + reason += ": " + message.strip()[:200] + else: + reason = "connection error: %s" % message + failures.append((title, reason)) + + progress.setValue(len(ids)) + + _report(gui, uploaded, skipped, no_format, failures) + + +def _prepare_book(db, book_id): + """Return ``(filename, epub_bytes)`` for a book, or ``None`` if it has no + EPUB/KEPUB format. Calibre's metadata is embedded into an in-memory copy; + the library file on disk is never touched.""" + formats = {f.upper() for f in (db.formats(book_id) or ())} + fmt = "EPUB" if "EPUB" in formats else ("KEPUB" if "KEPUB" in formats else None) + if fmt is None: + return None + + raw = db.format(book_id, fmt) + if not raw: + return None + + data = _embed_metadata(db, book_id, raw, fmt) + title = db.field_for("title", book_id) or ("book-%d" % book_id) + return _safe_filename(title) + ".epub", data + + +def _embed_metadata(db, book_id, raw, fmt): + """Embed Calibre's curated metadata (incl. series + cover) into the EPUB so + kobors' OPF parser preserves it. Falls back to the raw bytes on any error.""" + from calibre.ebooks.metadata.meta import set_metadata + + stream_type = "kepub" if fmt == "KEPUB" else "epub" + try: + mi = db.get_metadata(book_id, get_cover=True, cover_as_data=True) + stream = io.BytesIO(raw) + set_metadata(stream, mi, stream_type) + stream.seek(0) + return stream.getvalue() + except Exception: + return raw + + +def _post_book(server, token, filename, data, timeout=180): + """POST one book to ``/api/upload`` as multipart/form-data. + + Returns ``(status_code, message)``; ``status_code`` is ``None`` on a + transport-level failure (with the error text in ``message``).""" + boundary = "----kobors" + uuid.uuid4().hex + preamble = ( + '--%s\r\n' + 'Content-Disposition: form-data; name="file"; filename="%s"\r\n' + "Content-Type: application/epub+zip\r\n\r\n" % (boundary, filename) + ).encode("utf-8") + epilogue = ("\r\n--%s--\r\n" % boundary).encode("utf-8") + body = preamble + data + epilogue + + req = urllib.request.Request(server + "/api/upload", data=body, method="POST") + req.add_header("Content-Type", "multipart/form-data; boundary=" + boundary) + req.add_header("Authorization", "Bearer " + token) + + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.getcode(), "" + except urllib.error.HTTPError as e: + try: + message = e.read().decode("utf-8", "replace") + except Exception: + message = "" + return e.code, message + except Exception as e: # URLError, socket timeout, TLS failure, ... + return None, str(e) + + +def _safe_filename(title): + """A filesystem/header-safe stem for the upload filename. kobors only uses + it as a fallback title, so exact fidelity does not matter.""" + keep = [c if (c.isalnum() or c in " ._-") else "_" for c in title] + cleaned = "".join(keep).strip().strip(".") + return (cleaned or "book")[:120] + + +def _report(gui, uploaded, skipped, no_format, failures): + lines = [ + "Uploaded: %d" % uploaded, + "Already on server (skipped): %d" % skipped, + ] + if no_format: + lines.append("No EPUB/KEPUB format: %d" % len(no_format)) + if failures: + lines.append("Failed: %d" % len(failures)) + summary = "\n".join(lines) + + details = [] + if failures: + details.append("Failed:") + details.extend(" %s — %s" % (t, r) for t, r in failures) + if no_format: + details.append("No EPUB/KEPUB format:") + details.extend(" %s" % t for t in no_format) + det_msg = "\n".join(details) if details else "" + + if failures: + error_dialog( + gui, "kobors upload finished with errors", summary, det_msg=det_msg, show=True + ) + else: + info_dialog(gui, "kobors upload complete", summary, det_msg=det_msg, show=True) diff --git a/calibre-plugin/plugin-import-name-kobors.txt b/calibre-plugin/plugin-import-name-kobors.txt new file mode 100644 index 0000000..e69de29 diff --git a/calibre-plugin/ui.py b/calibre-plugin/ui.py new file mode 100644 index 0000000..a63aef3 --- /dev/null +++ b/calibre-plugin/ui.py @@ -0,0 +1,29 @@ +"""The toolbar action: 'Send to kobors'.""" + +from calibre.gui2.actions import InterfaceAction + + +class KoborsUploadAction(InterfaceAction): + name = "Kobors Upload" + # (text, icon, tooltip, keyboard shortcut). 'sync.png' is a built-in + # calibre theme icon, so no icon needs to be bundled. + action_spec = ( + "Send to kobors", + "sync.png", + "Upload the selected books to your kobors server", + None, + ) + action_type = "current" + + def genesis(self): + self.qaction.triggered.connect(self.upload_selected) + + def upload_selected(self): + # Imported lazily so a syntax/runtime error in the worker can't stop the + # action from loading at startup. + from calibre_plugins.kobors.main import run_upload + + run_upload(self.gui) + + def apply_settings(self): + pass diff --git a/src/auth.rs b/src/auth.rs index c6d83b7..778401f 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,11 +1,16 @@ use std::sync::Arc; +use axum::extract::FromRequestParts; +use axum::http::header::AUTHORIZATION; +use axum::http::request::Parts; +use axum::http::StatusCode; use axum_login::{AuthUser, AuthnBackend, UserId}; use password_auth::verify_password; use serde::Deserialize; use tokio::task; use crate::db::app_db::AppDb; +use crate::kobo::auth::AppState; /// A web UI user with session-based authentication. #[derive(Debug, Clone)] @@ -101,3 +106,60 @@ impl AuthnBackend for Backend { /// Type alias for the auth session used in handlers. pub type AuthSession = axum_login::AuthSession; + +/// The authenticated principal for web `/api` endpoints that accept either an +/// interactive browser session or a programmatic API client (e.g. the Calibre +/// upload plugin). +/// +/// Resolution order: +/// 1. An established `axum-login` session cookie (the browser UI). +/// 2. An `Authorization: Bearer ` header matched against `auth_tokens` +/// — the same per-user tokens minted for Kobo devices (`POST /api/tokens`). +/// Unlike the Kobo `/kobo/*` path, the token here is the raw value, not +/// JWT-wrapped. +/// +/// Yields the acting user's id. Endpoints using this extractor must sit outside +/// the `login_required!` layer, which would redirect a token-only client to the +/// HTML login page before this extractor ever runs. +#[derive(Debug, Clone, Copy)] +pub struct ApiUser { + pub id: i64, +} + +impl FromRequestParts for ApiUser { + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + // 1. Browser session cookie. + if let Ok(session) = AuthSession::from_request_parts(parts, state).await { + if let Some(user) = session.user { + return Ok(ApiUser { id: user.id() }); + } + } + + // 2. Bearer token (raw, looked up directly in `auth_tokens`). + let token = parts + .headers + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| { + v.strip_prefix("Bearer ") + .or_else(|| v.strip_prefix("bearer ")) + }) + .map(str::trim) + .filter(|t| !t.is_empty()) + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in"))?; + + let record = state + .app_db + .find_user_by_token(token) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Auth lookup failed"))? + .ok_or((StatusCode::UNAUTHORIZED, "Invalid API token"))?; + + Ok(ApiUser { id: record.user_id }) + } +} diff --git a/src/upload/handler.rs b/src/upload/handler.rs index c3b66c9..fa224e5 100644 --- a/src/upload/handler.rs +++ b/src/upload/handler.rs @@ -1,14 +1,13 @@ use axum::extract::{Multipart, Path, State}; use axum::http::StatusCode; use axum::Json; -use axum_login::AuthUser; use chrono::Utc; use serde::Serialize; use sha2::{Digest, Sha256}; use tracing::error; use uuid::Uuid; -use crate::auth::AuthSession; +use crate::auth::ApiUser; use crate::db::book_store::{BookSource, NewBook}; use crate::kobo::auth::AppState; use crate::upload::enrich::Enriched; @@ -29,14 +28,10 @@ pub struct UploadedJson { /// POST /api/upload — Accept an EPUB upload, extract metadata + cover, and add /// it to the catalog. `multipart/form-data` with a single `file` field. pub async fn api_upload( - auth_session: AuthSession, + user: ApiUser, State(state): State, mut multipart: Multipart, ) -> Result, (StatusCode, String)> { - let user = auth_session - .user - .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; - // Read the first file field from the multipart body. let mut bytes: Option> = None; let mut filename = String::from("book.epub"); @@ -75,7 +70,7 @@ pub async fn api_upload( let hash = sha256_hex(&bytes); if let Some(existing) = state .books - .find_by_hash(user.id(), &hash) + .find_by_hash(user.id, &hash) .await .map_err(internal)? { @@ -190,7 +185,7 @@ pub async fn api_upload( file_basename: "book".to_string(), content_hash: Some(hash), has_cover, - owner_user_id: Some(user.id()), + owner_user_id: Some(user.id), timestamp: now, last_modified: now, }; @@ -210,17 +205,13 @@ pub async fn api_upload( /// DELETE /api/upload/{id} — Remove an uploaded book owned by the current user. pub async fn api_delete_upload( - auth_session: AuthSession, + user: ApiUser, State(state): State, Path(id): Path, ) -> Result { - let user = auth_session - .user - .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; - let locator = state .books - .delete_upload_owned(id, user.id()) + .delete_upload_owned(id, user.id) .await .map_err(internal)? .ok_or((StatusCode::NOT_FOUND, "Upload not found".to_string()))?; diff --git a/src/web.rs b/src/web.rs index 775cfba..e7a5335 100644 --- a/src/web.rs +++ b/src/web.rs @@ -70,11 +70,6 @@ pub fn router(max_upload_size: usize) -> Router { .route("/api/tokens", post(api_create_token)) .route("/api/register-device", post(api_register_device)) .route("/api/ingest/calibre", post(api_ingest_calibre)) - .route( - "/api/upload", - post(api_upload).layer(DefaultBodyLimit::max(max_upload_size)), - ) - .route("/api/upload/{id}", axum::routing::delete(api_delete_upload)) .route( "/api/tokens/{token_id}", axum::routing::delete(api_delete_token), @@ -82,6 +77,18 @@ pub fn router(max_upload_size: usize) -> Router { .route("/covers/{book_id}", get(cover)) .route_layer(login_required!(Backend, login_url = "/login")); + // Upload routes authenticate via the `ApiUser` extractor (browser session + // cookie OR `Authorization: Bearer` token), so they must sit OUTSIDE the + // `login_required!` layer — that layer 302-redirects any request without a + // session to the HTML login page, before a token-only client (the Calibre + // plugin) could ever be authenticated. + let uploads = Router::new() + .route( + "/api/upload", + post(api_upload).layer(DefaultBodyLimit::max(max_upload_size)), + ) + .route("/api/upload/{id}", axum::routing::delete(api_delete_upload)); + let public = Router::new() .route("/login", get(login_page)) .route("/login", post(login)) @@ -92,7 +99,7 @@ pub fn router(max_upload_size: usize) -> Router { .route("/auth/oidc/callback", get(oidc_callback)) .route("/logout", get(logout)); - protected.merge(public) + protected.merge(uploads).merge(public) } // ---- Protected Handlers ---- -- 2.51.2 From 14789437acd20a67495d9e956d733293ab67fb5a Mon Sep 17 00:00:00 2001 From: servius Date: Mon, 27 Jul 2026 06:55:57 +0530 Subject: [PATCH 2/3] build: build the Calibre plugin zip with uv instead of bash Replace build.sh with a stdlib-only build.py (PEP 723 inline metadata), run via `uv run build.py`. Cross-platform and drops the `zip` CLI dependency. --- calibre-plugin/README.md | 5 +++-- calibre-plugin/build.py | 44 ++++++++++++++++++++++++++++++++++++++++ calibre-plugin/build.sh | 18 ---------------- 3 files changed, 47 insertions(+), 20 deletions(-) create mode 100644 calibre-plugin/build.py delete mode 100755 calibre-plugin/build.sh diff --git a/calibre-plugin/README.md b/calibre-plugin/README.md index 95838be..e2713ea 100644 --- a/calibre-plugin/README.md +++ b/calibre-plugin/README.md @@ -8,10 +8,11 @@ uploaded EPUB so kobors keeps it. Your Calibre library files are never modified. ## Build ```bash -./build.sh # produces kobors-upload.zip +uv run build.py # produces kobors-upload.zip ``` -(Requires the `zip` CLI. The zip is just the `.py` files plus the +(Needs [uv](https://docs.astral.sh/uv/); the script is stdlib-only, so no +dependencies are installed. The zip is just the `.py` files plus the `plugin-import-name-kobors.txt` marker at its root.) ## Install diff --git a/calibre-plugin/build.py b/calibre-plugin/build.py new file mode 100644 index 0000000..63d5afc --- /dev/null +++ b/calibre-plugin/build.py @@ -0,0 +1,44 @@ +# /// script +# requires-python = ">=3.8" +# dependencies = [] +# /// +"""Build the installable Calibre plugin zip from this directory's contents. + +Run with: + + uv run build.py + +The zip's files must live at its root (Calibre imports the package from the +`plugin-import-name-kobors.txt` marker), so we archive the files directly, not +the containing directory. +""" + +import zipfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +OUT = HERE / "kobors-upload.zip" +FILES = [ + "plugin-import-name-kobors.txt", + "__init__.py", + "ui.py", + "config.py", + "main.py", +] + + +def main() -> None: + if OUT.exists(): + OUT.unlink() + with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED) as z: + for name in FILES: + z.write(HERE / name, name) + print(f"Built {OUT.name}") + print( + "Install: Calibre → Preferences → Plugins → " + f"Load plugin from file → {OUT.name}" + ) + + +if __name__ == "__main__": + main() diff --git a/calibre-plugin/build.sh b/calibre-plugin/build.sh deleted file mode 100755 index 7589024..0000000 --- a/calibre-plugin/build.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build the installable Calibre plugin zip from this directory's contents. -# -# The zip's files must live at its root (Calibre imports the package from the -# `plugin-import-name-kobors.txt` marker), so we zip the contents, not the dir. -set -euo pipefail - -cd "$(dirname "$0")" -out="kobors-upload.zip" -rm -f "$out" -zip -r "$out" \ - plugin-import-name-kobors.txt \ - __init__.py \ - ui.py \ - config.py \ - main.py -echo "Built $out" -echo "Install: Calibre → Preferences → Plugins → Load plugin from file → $out" -- 2.51.2 From c932c5fc4b7e9551faacdc323209f38b04685274 Mon Sep 17 00:00:00 2001 From: servius Date: Mon, 27 Jul 2026 06:56:41 +0530 Subject: [PATCH 3/3] build: add pyproject.toml for the uv-managed plugin build Declares the plugin build as a uv project with no dependencies (stdlib only) and package = false, so uv manages the build environment without treating the Calibre plugin modules as an installable package. --- calibre-plugin/.gitignore | 1 + calibre-plugin/pyproject.toml | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 calibre-plugin/pyproject.toml diff --git a/calibre-plugin/.gitignore b/calibre-plugin/.gitignore index 8c1c7a9..4686aa3 100644 --- a/calibre-plugin/.gitignore +++ b/calibre-plugin/.gitignore @@ -1,3 +1,4 @@ __pycache__/ *.pyc kobors-upload.zip +.venv/ diff --git a/calibre-plugin/pyproject.toml b/calibre-plugin/pyproject.toml new file mode 100644 index 0000000..715c12c --- /dev/null +++ b/calibre-plugin/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "kobors-calibre-plugin" +version = "1.0.0" +description = "Calibre plugin that uploads selected books to a kobors server" +readme = "README.md" +requires-python = ">=3.8" +# The plugin uses only the Python standard library (plus Calibre's own runtime +# APIs, which are provided by the Calibre process, not installed here). +dependencies = [] + +[tool.uv] +# These files are a Calibre plugin, not an installable Python package: uv should +# manage the build environment without trying to build/install this directory. +package = false