Add the /search surface (Pages / Chapters / Mangas tabs) backed by per-user page tags and per-page collections: schema (migration 0023), backend endpoints for page tags/collections and tagged-page aggregations (with the OCR text-search param reserved at 501), plus the frontend API clients, library Page-tags tab, collection page sections, page context menu / AddTagsSheet, and reader long-press wiring. Includes the continuous-reader navigation fixes (?page=N handling, chapter-reset timing, back-button pops history) and tag-normalization hardening accumulated on the branch. Bump version 0.60.2 -> 0.62.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
410 lines
12 KiB
Rust
410 lines
12 KiB
Rust
//! Collection persistence.
|
|
//!
|
|
//! Same plain-function pattern as `repo::bookmark`. Ownership is
|
|
//! tracked via `collections.user_id`; handlers call `find_owner`
|
|
//! before mutations to keep 403/404 honest.
|
|
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
use crate::domain::collection::{Collection, CollectionPageItem, CollectionSummary};
|
|
use crate::domain::manga::Manga;
|
|
use crate::error::{AppError, AppResult};
|
|
|
|
pub async fn create(
|
|
pool: &PgPool,
|
|
user_id: Uuid,
|
|
name: &str,
|
|
description: Option<&str>,
|
|
) -> AppResult<Collection> {
|
|
let row = sqlx::query_as::<_, Collection>(
|
|
r#"
|
|
INSERT INTO collections (user_id, name, description)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id, user_id, name, description, created_at, updated_at
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.bind(name.trim())
|
|
.bind(description)
|
|
.fetch_one(pool)
|
|
.await
|
|
.map_err(|e| match e {
|
|
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
|
|
AppError::Conflict("a collection with this name already exists".into())
|
|
}
|
|
other => AppError::Database(other),
|
|
})?;
|
|
Ok(row)
|
|
}
|
|
|
|
pub async fn get(pool: &PgPool, id: Uuid) -> AppResult<Collection> {
|
|
sqlx::query_as::<_, Collection>(
|
|
r#"
|
|
SELECT id, user_id, name, description, created_at, updated_at
|
|
FROM collections
|
|
WHERE id = $1
|
|
"#,
|
|
)
|
|
.bind(id)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or(AppError::NotFound)
|
|
}
|
|
|
|
pub async fn find_owner(pool: &PgPool, id: Uuid) -> AppResult<Option<Uuid>> {
|
|
let row: Option<(Uuid,)> =
|
|
sqlx::query_as("SELECT user_id FROM collections WHERE id = $1")
|
|
.bind(id)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
Ok(row.map(|(u,)| u))
|
|
}
|
|
|
|
/// Paged list of one user's collections. Includes `manga_count` and up
|
|
/// to three sample cover image keys (newest-added first) so a card can
|
|
/// render without a follow-up fetch.
|
|
pub async fn list_for_user(
|
|
pool: &PgPool,
|
|
user_id: Uuid,
|
|
limit: i64,
|
|
offset: i64,
|
|
) -> AppResult<(Vec<CollectionSummary>, i64)> {
|
|
let rows = sqlx::query_as::<_, CollectionSummary>(
|
|
r#"
|
|
SELECT
|
|
c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at,
|
|
(SELECT count(*) FROM collection_mangas cm WHERE cm.collection_id = c.id)
|
|
AS manga_count,
|
|
COALESCE(
|
|
(
|
|
-- `array_agg(... ORDER BY ...)` is the only
|
|
-- spec-guaranteed way to preserve element order;
|
|
-- a subquery's ORDER BY isn't a contract the
|
|
-- outer aggregate has to honour. Adding manga_id
|
|
-- as a tiebreaker keeps the order stable when
|
|
-- multiple rows share `added_at` (bulk imports).
|
|
SELECT array_agg(cover_image_path ORDER BY added_at DESC, manga_id)
|
|
FROM (
|
|
SELECT m.cover_image_path, cm2.added_at, cm2.manga_id
|
|
FROM collection_mangas cm2
|
|
JOIN mangas m ON m.id = cm2.manga_id
|
|
WHERE cm2.collection_id = c.id
|
|
AND m.cover_image_path IS NOT NULL
|
|
ORDER BY cm2.added_at DESC, cm2.manga_id
|
|
LIMIT 3
|
|
) p
|
|
),
|
|
ARRAY[]::text[]
|
|
) AS sample_covers
|
|
FROM collections c
|
|
WHERE c.user_id = $1
|
|
ORDER BY c.updated_at DESC, c.id
|
|
LIMIT $2 OFFSET $3
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.bind(limit)
|
|
.bind(offset)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
|
|
let (total,): (i64,) =
|
|
sqlx::query_as("SELECT count(*) FROM collections WHERE user_id = $1")
|
|
.bind(user_id)
|
|
.fetch_one(pool)
|
|
.await?;
|
|
Ok((rows, total))
|
|
}
|
|
|
|
pub async fn update(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
name: Option<&str>,
|
|
description_provided: bool,
|
|
description: Option<&str>,
|
|
) -> AppResult<Collection> {
|
|
let row = sqlx::query_as::<_, Collection>(
|
|
r#"
|
|
UPDATE collections
|
|
SET name = COALESCE($2, name),
|
|
description = CASE WHEN $3::boolean THEN $4 ELSE description END,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING id, user_id, name, description, created_at, updated_at
|
|
"#,
|
|
)
|
|
.bind(id)
|
|
.bind(name.map(str::trim))
|
|
.bind(description_provided)
|
|
.bind(description)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.map_err(|e| match e {
|
|
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
|
|
AppError::Conflict("a collection with this name already exists".into())
|
|
}
|
|
other => AppError::Database(other),
|
|
})?
|
|
.ok_or(AppError::NotFound)?;
|
|
Ok(row)
|
|
}
|
|
|
|
pub async fn delete(pool: &PgPool, id: Uuid) -> AppResult<()> {
|
|
sqlx::query("DELETE FROM collections WHERE id = $1")
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Add a manga to a collection. Returns `true` if a new attachment was
|
|
/// created (handler picks 201), `false` if the manga was already in
|
|
/// the collection (handler picks 200). Touches `updated_at` so the
|
|
/// "recent collections" sort reflects activity.
|
|
///
|
|
/// FK violations (manga deleted between the handler's `exists` check
|
|
/// and this insert — a race the API can't fully close from the
|
|
/// outside) are remapped to `NotFound` so the handler returns 404
|
|
/// rather than 500.
|
|
pub async fn add_manga(
|
|
pool: &PgPool,
|
|
collection_id: Uuid,
|
|
manga_id: Uuid,
|
|
) -> AppResult<bool> {
|
|
let mut tx = pool.begin().await?;
|
|
let inserted = sqlx::query(
|
|
r#"
|
|
INSERT INTO collection_mangas (collection_id, manga_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT DO NOTHING
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.bind(manga_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|e| match e {
|
|
sqlx::Error::Database(ref db_err) if db_err.is_foreign_key_violation() => {
|
|
AppError::NotFound
|
|
}
|
|
other => AppError::Database(other),
|
|
})?;
|
|
let rows_affected = inserted.rows_affected();
|
|
if rows_affected > 0 {
|
|
sqlx::query("UPDATE collections SET updated_at = now() WHERE id = $1")
|
|
.bind(collection_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
tx.commit().await?;
|
|
Ok(rows_affected > 0)
|
|
}
|
|
|
|
pub async fn remove_manga(
|
|
pool: &PgPool,
|
|
collection_id: Uuid,
|
|
manga_id: Uuid,
|
|
) -> AppResult<()> {
|
|
let mut tx = pool.begin().await?;
|
|
let rows_affected = sqlx::query(
|
|
"DELETE FROM collection_mangas WHERE collection_id = $1 AND manga_id = $2",
|
|
)
|
|
.bind(collection_id)
|
|
.bind(manga_id)
|
|
.execute(&mut *tx)
|
|
.await?
|
|
.rows_affected();
|
|
if rows_affected > 0 {
|
|
sqlx::query("UPDATE collections SET updated_at = now() WHERE id = $1")
|
|
.bind(collection_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_mangas(
|
|
pool: &PgPool,
|
|
collection_id: Uuid,
|
|
limit: i64,
|
|
offset: i64,
|
|
) -> AppResult<(Vec<Manga>, i64)> {
|
|
let rows = sqlx::query_as::<_, Manga>(
|
|
r#"
|
|
SELECT m.id, m.title, m.status, m.alt_titles, m.description,
|
|
m.cover_image_path, m.created_at, m.updated_at
|
|
FROM collection_mangas cm
|
|
JOIN mangas m ON m.id = cm.manga_id
|
|
WHERE cm.collection_id = $1
|
|
ORDER BY cm.added_at DESC, m.id
|
|
LIMIT $2 OFFSET $3
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.bind(limit)
|
|
.bind(offset)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
let (total,): (i64,) =
|
|
sqlx::query_as("SELECT count(*) FROM collection_mangas WHERE collection_id = $1")
|
|
.bind(collection_id)
|
|
.fetch_one(pool)
|
|
.await?;
|
|
Ok((rows, total))
|
|
}
|
|
|
|
/// Which of `user_id`'s collections currently contain `manga_id`?
|
|
/// Used by the "Add to collection" modal to pre-check the boxes.
|
|
pub async fn list_collections_containing(
|
|
pool: &PgPool,
|
|
user_id: Uuid,
|
|
manga_id: Uuid,
|
|
) -> AppResult<Vec<Uuid>> {
|
|
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
|
r#"
|
|
SELECT c.id
|
|
FROM collections c
|
|
JOIN collection_mangas cm ON cm.collection_id = c.id
|
|
WHERE c.user_id = $1
|
|
AND cm.manga_id = $2
|
|
ORDER BY c.updated_at DESC
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.bind(manga_id)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows.into_iter().map(|(id,)| id).collect())
|
|
}
|
|
|
|
/// Add a page to a collection. Same `(true → 201, false → 200)`
|
|
/// idempotency contract as `add_manga`. FK violations (page deleted
|
|
/// between the handler's existence check and this insert) surface as
|
|
/// `NotFound`, not a 500.
|
|
pub async fn add_page(
|
|
pool: &PgPool,
|
|
collection_id: Uuid,
|
|
page_id: Uuid,
|
|
) -> AppResult<bool> {
|
|
let mut tx = pool.begin().await?;
|
|
let inserted = sqlx::query(
|
|
r#"
|
|
INSERT INTO collection_pages (collection_id, page_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT DO NOTHING
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.bind(page_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|e| match e {
|
|
sqlx::Error::Database(ref db_err) if db_err.is_foreign_key_violation() => {
|
|
AppError::NotFound
|
|
}
|
|
other => AppError::Database(other),
|
|
})?;
|
|
let rows_affected = inserted.rows_affected();
|
|
if rows_affected > 0 {
|
|
sqlx::query("UPDATE collections SET updated_at = now() WHERE id = $1")
|
|
.bind(collection_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
tx.commit().await?;
|
|
Ok(rows_affected > 0)
|
|
}
|
|
|
|
pub async fn remove_page(
|
|
pool: &PgPool,
|
|
collection_id: Uuid,
|
|
page_id: Uuid,
|
|
) -> AppResult<()> {
|
|
let mut tx = pool.begin().await?;
|
|
let rows_affected = sqlx::query(
|
|
"DELETE FROM collection_pages WHERE collection_id = $1 AND page_id = $2",
|
|
)
|
|
.bind(collection_id)
|
|
.bind(page_id)
|
|
.execute(&mut *tx)
|
|
.await?
|
|
.rows_affected();
|
|
if rows_affected > 0 {
|
|
sqlx::query("UPDATE collections SET updated_at = now() WHERE id = $1")
|
|
.bind(collection_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Paged list of `collection_id`'s pages, JOINed through chapters and
|
|
/// mangas so each row carries the breadcrumb the detail view needs.
|
|
pub async fn list_pages(
|
|
pool: &PgPool,
|
|
collection_id: Uuid,
|
|
limit: i64,
|
|
offset: i64,
|
|
) -> AppResult<(Vec<CollectionPageItem>, i64)> {
|
|
let rows = sqlx::query_as::<_, CollectionPageItem>(
|
|
r#"
|
|
SELECT
|
|
p.id AS page_id,
|
|
p.chapter_id AS chapter_id,
|
|
ch.manga_id AS manga_id,
|
|
p.page_number AS page_number,
|
|
ch.number AS chapter_number,
|
|
ch.title AS chapter_title,
|
|
m.title AS manga_title,
|
|
p.storage_key AS storage_key,
|
|
cp.added_at AS added_at
|
|
FROM collection_pages cp
|
|
JOIN pages p ON p.id = cp.page_id
|
|
JOIN chapters ch ON ch.id = p.chapter_id
|
|
JOIN mangas m ON m.id = ch.manga_id
|
|
WHERE cp.collection_id = $1
|
|
ORDER BY cp.added_at DESC, p.id
|
|
LIMIT $2 OFFSET $3
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.bind(limit)
|
|
.bind(offset)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
let (total,): (i64,) =
|
|
sqlx::query_as("SELECT count(*) FROM collection_pages WHERE collection_id = $1")
|
|
.bind(collection_id)
|
|
.fetch_one(pool)
|
|
.await?;
|
|
Ok((rows, total))
|
|
}
|
|
|
|
/// Which of `user_id`'s collections currently contain `page_id`?
|
|
/// Powers the reader context menu's "In N collections" line and the
|
|
/// "Add to collection" pre-check.
|
|
pub async fn list_collections_containing_page(
|
|
pool: &PgPool,
|
|
user_id: Uuid,
|
|
page_id: Uuid,
|
|
) -> AppResult<Vec<Uuid>> {
|
|
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
|
r#"
|
|
SELECT c.id
|
|
FROM collections c
|
|
JOIN collection_pages cp ON cp.collection_id = c.id
|
|
WHERE c.user_id = $1
|
|
AND cp.page_id = $2
|
|
ORDER BY c.updated_at DESC
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.bind(page_id)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows.into_iter().map(|(id,)| id).collect())
|
|
}
|