feat(search): tag-based page search surface + per-page tags & collections

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>
This commit is contained in:
MechaCat02
2026-06-13 15:51:38 +02:00
parent 9910a0a995
commit 6c901e64c9
50 changed files with 6971 additions and 132 deletions

View File

@@ -7,7 +7,7 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::collection::{Collection, CollectionSummary};
use crate::domain::collection::{Collection, CollectionPageItem, CollectionSummary};
use crate::domain::manga::Manga;
use crate::error::{AppError, AppResult};
@@ -278,3 +278,132 @@ pub async fn list_collections_containing(
.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())
}