diff --git a/src/db/app_db.rs b/src/db/app_db.rs index e4d3654..7ab9141 100644 --- a/src/db/app_db.rs +++ b/src/db/app_db.rs @@ -378,6 +378,16 @@ impl AppDb { series_index REAL, description TEXT )"#, + // Soft-deleted books. A present row hides the book from the catalog + // and from every user's sync (removed from the device on next sync), + // without dropping the `books` row or its files. Kept in its own + // table (keyed by the preserved `books.id`) so it survives the + // startup Calibre re-ingest — the only way to keep a Calibre book + // hidden, since its row is rebuilt from the read-only metadata.db. + r#"CREATE TABLE IF NOT EXISTS deleted_books ( + book_id INTEGER NOT NULL PRIMARY KEY, + deleted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + )"#, ]; for sql in &statements { diff --git a/src/db/book_store.rs b/src/db/book_store.rs index b74622b..066901a 100644 --- a/src/db/book_store.rs +++ b/src/db/book_store.rs @@ -122,6 +122,9 @@ pub struct CatalogBook { /// Override-cover version (0 = none). Bumped each time the cover is replaced; /// used as a `/covers/{id}?v=` cache-buster in the web UI. pub cover_version: i64, + /// Whether the book is soft-deleted (hidden from the library + all syncs, + /// files retained). Shown only in the web UI's "Deleted" view. + pub deleted: bool, } /// The full `books` row, as read back from the app DB. @@ -236,11 +239,13 @@ impl BookStore { b.file_format, b.source, b.timestamp, (b.has_cover != 0 OR c.book_id IS NOT NULL) AS has_cover, COALESCE(c.version, 0) AS cover_version, - COALESCE(p.enabled, b.default_sync) AS sync_enabled + COALESCE(p.enabled, b.default_sync) AS sync_enabled, + (x.book_id IS NOT NULL) AS deleted FROM books b LEFT JOIN book_sync_prefs p ON p.book_id = b.id AND p.user_id = ?1 LEFT JOIN book_covers c ON c.book_id = b.id LEFT JOIN book_meta_overrides o ON o.book_id = b.id + LEFT JOIN deleted_books x ON x.book_id = b.id ORDER BY title COLLATE NOCASE ASC "#, ) @@ -281,6 +286,7 @@ impl BookStore { .and_then(parse_dt) .unwrap_or_else(Utc::now), cover_version: r.get::("cover_version"), + deleted: r.get::, _>("deleted").unwrap_or(0) != 0, } }) .collect()) @@ -319,6 +325,7 @@ impl BookStore { LEFT JOIN book_sync_prefs p ON p.book_id = b.id AND p.user_id = ?1 LEFT JOIN book_covers c ON c.book_id = b.id WHERE COALESCE(p.enabled, b.default_sync) = 1 + AND b.id NOT IN (SELECT book_id FROM deleted_books) ORDER BY b.id ASC "#, ) @@ -551,9 +558,42 @@ impl BookStore { .await .change_context(Error::Database) .attach("Failed to delete uploaded book")?; + // Drop any soft-delete marker so a later re-upload of the same id-space + // does not inherit a stale "deleted" flag. + sqlx::query("DELETE FROM deleted_books WHERE book_id = ?") + .bind(id) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to clear soft-delete marker")?; Ok(Some(locator)) } + /// Soft-delete a book: hide it from the catalog and from every user's sync + /// (removed from the device on next sync), keeping its row and files. Global + /// (not per-user), consistent with cover/metadata edits. Idempotent. + pub async fn soft_delete_book(&self, book_id: i64) -> Result<()> { + sqlx::query("INSERT OR IGNORE INTO deleted_books (book_id) VALUES (?)") + .bind(book_id) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to soft-delete book")?; + Ok(()) + } + + /// Restore a soft-deleted book (removes its `deleted_books` marker). + /// Idempotent. + pub async fn restore_book(&self, book_id: i64) -> Result<()> { + sqlx::query("DELETE FROM deleted_books WHERE book_id = ?") + .bind(book_id) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to restore book")?; + Ok(()) + } + /// Find an uploaded book by owner + content hash (re-upload dedupe). pub async fn find_by_hash(&self, owner_user_id: i64, hash: &str) -> Result> { let row = sqlx::query( diff --git a/src/web.rs b/src/web.rs index 147fa6a..9ed4816 100644 --- a/src/web.rs +++ b/src/web.rs @@ -55,6 +55,9 @@ struct BookJson { /// Override-cover version (0 = none). The UI appends it as `/covers/{id}?v=` /// to bust the browser cache when the cover changes. cover_version: i64, + /// Whether the book is soft-deleted (shown only in the UI's "Deleted" view; + /// hidden from normal views and from all device syncs). + deleted: bool, } /// JSON representation of the current user. @@ -132,6 +135,8 @@ pub fn router(max_upload_size: usize) -> Router { get(api_get_settings).put(api_update_settings), ) .route("/api/books/{book_id}/sync", put(api_set_book_sync)) + .route("/api/books/{book_id}", delete(api_soft_delete_book)) + .route("/api/books/{book_id}/restore", post(api_restore_book)) .route("/api/books/{book_id}/meta", put(api_set_book_meta)) .route("/api/books/{book_id}/meta/search", get(api_search_meta)) .route("/api/books/{book_id}/cover/search", get(api_search_cover)) @@ -241,6 +246,7 @@ async fn api_books( sync_enabled: b.sync_enabled, timestamp: b.timestamp.to_rfc3339(), cover_version: b.cover_version, + deleted: b.deleted, }) .collect(); @@ -323,6 +329,44 @@ async fn api_set_book_sync( Ok(StatusCode::NO_CONTENT) } +/// DELETE /api/books/{book_id} — Soft-delete a book: hide it from the library +/// and remove it from every device on next sync, keeping its row and files so it +/// can be restored. Global (any logged-in user), like cover/metadata edits. +async fn api_soft_delete_book( + auth_session: AuthSession, + State(state): State, + Path(book_id): Path, +) -> Result { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + ensure_book_exists(&state, book_id).await?; + + state.books.soft_delete_book(book_id).await.map_err(|e| { + error!("Failed to soft-delete book: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + })?; + Ok(StatusCode::NO_CONTENT) +} + +/// POST /api/books/{book_id}/restore — Undo a soft delete. +async fn api_restore_book( + auth_session: AuthSession, + State(state): State, + Path(book_id): Path, +) -> Result { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + ensure_book_exists(&state, book_id).await?; + + state.books.restore_book(book_id).await.map_err(|e| { + error!("Failed to restore book: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + })?; + Ok(StatusCode::NO_CONTENT) +} + /// PUT /api/books/{book_id}/meta — Update a book's editable metadata (title, /// authors, series, description). Stored as a per-book override so Calibre /// re-ingest does not clobber it; blank fields clear the override. Reflected in diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index ee61d76..3a4fd11 100644 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -35,6 +35,9 @@ pub struct Book { /// Override-cover version (0 = none). Used as `/covers/{id}?v=` to bust the /// browser cache after the cover changes. pub cover_version: i64, + /// Whether the book is soft-deleted (shown only in the "Deleted" view). + #[serde(default)] + pub deleted: bool, } /// A candidate cover from `GET /api/books/{id}/cover/search`. @@ -312,6 +315,23 @@ pub async fn register_device(token: &str) -> ApiResult<()> { check(resp).await.map(|_| ()) } +/// `DELETE /api/books/{id}` — soft-delete a book (hidden + dropped from sync, +/// row and files kept, restorable). +pub async fn delete_book(id: i64) -> ApiResult<()> { + let resp = gloo_net::http::Request::delete(&format!("/api/books/{id}")) + .send() + .await?; + check(resp).await.map(|_| ()) +} + +/// `POST /api/books/{id}/restore` — undo a soft delete. +pub async fn restore_book(id: i64) -> ApiResult<()> { + let resp = gloo_net::http::Request::post(&format!("/api/books/{id}/restore")) + .send() + .await?; + check(resp).await.map(|_| ()) +} + /// `POST /api/upload` — upload an EPUB via multipart form data. pub async fn upload(form: FormData) -> ApiResult { let resp = gloo_net::http::Request::post("/api/upload") diff --git a/web-ui/src/components/library.rs b/web-ui/src/components/library.rs index d2abcae..fecd290 100644 --- a/web-ui/src/components/library.rs +++ b/web-ui/src/components/library.rs @@ -29,7 +29,9 @@ enum Pending { /// Confirmed delete of a device-bound token (retries with `force`). The /// device serial is already in the prompt text stored alongside it. ForceDeleteToken(i64), - DeleteBook(i64), + /// Permanently delete an uploaded book (purge files); only reachable from the + /// "Deleted" view. Soft delete itself is one-click and needs no confirm. + PurgeBook(i64), } /// How the library grid is ordered. @@ -43,7 +45,8 @@ enum Sort { Sync, } -/// Which books the grid shows, by per-user sync state. +/// Which books the grid shows, by per-user sync state. All views except +/// [`Filter::Deleted`] hide soft-deleted books. #[derive(Clone, Copy, PartialEq)] enum Filter { /// Every book in the catalog. @@ -52,6 +55,8 @@ enum Filter { Synced, /// Only books that do not sync to this user's devices. Unsynced, + /// Only soft-deleted books (the "trash"), with restore/purge controls. + Deleted, } #[component] @@ -268,11 +273,13 @@ pub fn Library() -> impl IntoView { on:change=move |ev| filter.set(match event_target_value(&ev).as_str() { "synced" => Filter::Synced, "unsynced" => Filter::Unsynced, + "deleted" => Filter::Deleted, _ => Filter::All, })> + + + + -

{title.clone()}

-

{author}

- - + }.into_any() } }).collect_view(); view! { @@ -445,7 +494,7 @@ pub fn Library() -> impl IntoView { let _ = api::delete_token(id, true).await; reload_tokens(); }), - Pending::DeleteBook(id) => spawn_local(async move { + Pending::PurgeBook(id) => spawn_local(async move { let _ = api::delete_upload(id).await; reload_books(); }),