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())
}

View File

@@ -9,6 +9,7 @@ pub mod crawler;
pub mod genre;
pub mod manga;
pub mod page;
pub mod page_tag;
pub mod read_progress;
pub mod session;
pub mod tag;

View File

@@ -0,0 +1,374 @@
//! Per-user, per-page tag persistence.
//!
//! Same plain-function pattern as the rest of `repo`. Idempotent
//! upserts via `ON CONFLICT DO NOTHING` (the caller distinguishes
//! 201/200 from the returned `bool`), FK-violation remap to NotFound so
//! the handler can return 404 when the page was deleted between an
//! existence check and the insert.
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::page_tag::{
PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate, TaggedPageItem,
};
use crate::error::{AppError, AppResult};
/// Sort direction for the per-chapter / per-manga aggregations. We
/// inline this into the SQL via `format!()` because Postgres won't
/// accept ASC/DESC as a parameter — and the value space is closed
/// (just two variants), so there's no SQL-injection vector.
#[derive(Debug, Clone, Copy)]
pub enum Order {
Desc,
Asc,
}
impl Order {
fn as_sql(self) -> &'static str {
match self {
Order::Desc => "DESC",
Order::Asc => "ASC",
}
}
}
/// Insert a normalized tag for `(user_id, page_id)`. Returns `true` if
/// a new row was inserted (handler → 201), `false` if the tag was
/// already present (handler → 200).
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
page_id: Uuid,
tag: &str,
) -> AppResult<bool> {
let result = sqlx::query(
r#"
INSERT INTO page_tags (user_id, page_id, tag)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, page_id, tag) DO NOTHING
"#,
)
.bind(user_id)
.bind(page_id)
.bind(tag)
.execute(pool)
.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),
})?;
Ok(result.rows_affected() > 0)
}
pub async fn remove(
pool: &PgPool,
user_id: Uuid,
page_id: Uuid,
tag: &str,
) -> AppResult<()> {
sqlx::query(
"DELETE FROM page_tags WHERE user_id = $1 AND page_id = $2 AND tag = $3",
)
.bind(user_id)
.bind(page_id)
.bind(tag)
.execute(pool)
.await?;
Ok(())
}
/// `user_id`'s tags on `page_id`, oldest-first so the context-menu
/// summary line reads in the order the user added them.
pub async fn list_for_page(
pool: &PgPool,
user_id: Uuid,
page_id: Uuid,
) -> AppResult<Vec<String>> {
let rows: Vec<(String,)> = sqlx::query_as(
r#"
SELECT tag
FROM page_tags
WHERE user_id = $1 AND page_id = $2
ORDER BY created_at ASC, tag
"#,
)
.bind(user_id)
.bind(page_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(t,)| t).collect())
}
/// Escape a string for use as a LIKE pattern fragment: `%`, `_`, and
/// `\` get a leading backslash so they're matched literally rather
/// than as wildcards / escapes. The matching queries below pair this
/// with `ESCAPE '\'` for explicitness — a single backslash, since the
/// SQL lives in a raw string and Postgres treats `\\` in a single-
/// quoted literal as one backslash under `standard_conforming_strings`.
///
/// The public API rejects `%`/`_`/`\` in `normalize_tag` before
/// they reach this repo, so this is defence-in-depth — a future
/// internal caller (worker, CLI) that bypasses the normalizer can't
/// turn a prefix filter into a wildcard search by accident.
fn escape_like_prefix(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if matches!(ch, '\\' | '%' | '_') {
out.push('\\');
}
out.push(ch);
}
out
}
/// Paged list of `user_id`'s tagged pages, with breadcrumb. When
/// `tag_filter` is `Some(_)`, restrict to that exact tag (used by the
/// library Page-tags chip filter). `prefix_filter` does a `LIKE
/// 'prefix%'` against the normalized tag (used by autocomplete when
/// the user is typing a chip). The prefix is LIKE-escaped here so
/// stray `%` / `_` from a bypass-the-API caller don't widen the
/// pattern.
pub async fn list_for_user(
pool: &PgPool,
user_id: Uuid,
tag_filter: Option<&str>,
prefix_filter: Option<&str>,
limit: i64,
offset: i64,
) -> AppResult<(Vec<TaggedPageItem>, i64)> {
let escaped_prefix = prefix_filter.map(escape_like_prefix);
let rows = sqlx::query_as::<_, TaggedPageItem>(
r#"
SELECT
pt.tag AS tag,
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,
pt.created_at AS tagged_at
FROM page_tags pt
JOIN pages p ON p.id = pt.page_id
JOIN chapters ch ON ch.id = p.chapter_id
JOIN mangas m ON m.id = ch.manga_id
WHERE pt.user_id = $1
AND ($2::text IS NULL OR pt.tag = $2)
AND ($3::text IS NULL OR pt.tag LIKE $3 || '%' ESCAPE '\')
ORDER BY pt.created_at DESC, pt.id
LIMIT $4 OFFSET $5
"#,
)
.bind(user_id)
.bind(tag_filter)
.bind(escaped_prefix.as_deref())
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let escaped_prefix = prefix_filter.map(escape_like_prefix);
let (total,): (i64,) = sqlx::query_as(
r#"
SELECT count(*)
FROM page_tags
WHERE user_id = $1
AND ($2::text IS NULL OR tag = $2)
AND ($3::text IS NULL OR tag LIKE $3 || '%' ESCAPE '\')
"#,
)
.bind(user_id)
.bind(tag_filter)
.bind(escaped_prefix.as_deref())
.fetch_one(pool)
.await?;
Ok((rows, total))
}
/// Distinct tag list for the user, with per-tag counts. When `prefix`
/// is `Some(_)`, restrict to tags starting with that prefix — drives
/// the autocomplete dropdown in the "Add tag" sheet. The prefix is
/// LIKE-escaped here so stray `%` / `_` from a bypass-the-API caller
/// don't widen the pattern.
pub async fn distinct_tags_for_user(
pool: &PgPool,
user_id: Uuid,
prefix: Option<&str>,
limit: i64,
) -> AppResult<Vec<PageTagSummary>> {
let escaped_prefix = prefix.map(escape_like_prefix);
let rows = sqlx::query_as::<_, PageTagSummary>(
r#"
SELECT tag, count(*) AS count
FROM page_tags
WHERE user_id = $1
AND ($2::text IS NULL OR tag LIKE $2 || '%' ESCAPE '\')
GROUP BY tag
ORDER BY count DESC, tag
LIMIT $3
"#,
)
.bind(user_id)
.bind(escaped_prefix.as_deref())
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Paged list of chapters that contain pages tagged `tag` for `user_id`,
/// ranked by `match_count`. Each row carries up to 3 sample page
/// storage keys (page-number ascending) so the row can render a
/// thumbnail strip without a follow-up fetch.
///
/// `order` is inlined via `format!()` — the enum value space is
/// closed (`ASC` / `DESC`) so this is not a SQL-injection vector.
pub async fn aggregate_chapters_for_tag(
pool: &PgPool,
user_id: Uuid,
tag: &str,
order: Order,
limit: i64,
offset: i64,
) -> AppResult<(Vec<TaggedChapterAggregate>, i64)> {
let sql = format!(
r#"
SELECT
ch.id AS chapter_id,
ch.manga_id AS manga_id,
m.title AS manga_title,
ch.number AS chapter_number,
ch.title AS chapter_title,
count(*) AS match_count,
COALESCE(
(
SELECT array_agg(p2.storage_key ORDER BY p2.page_number ASC)
FROM (
SELECT p.storage_key, p.page_number
FROM pages p
JOIN page_tags pt2 ON pt2.page_id = p.id
WHERE p.chapter_id = ch.id
AND pt2.user_id = $1
AND pt2.tag = $2
ORDER BY p.page_number ASC
LIMIT 3
) p2
),
ARRAY[]::text[]
) AS sample_storage_keys
FROM page_tags pt
JOIN pages p ON p.id = pt.page_id
JOIN chapters ch ON ch.id = p.chapter_id
JOIN mangas m ON m.id = ch.manga_id
WHERE pt.user_id = $1
AND pt.tag = $2
GROUP BY ch.id, ch.manga_id, m.title, ch.number, ch.title
ORDER BY match_count {dir}, ch.id
LIMIT $3 OFFSET $4
"#,
dir = order.as_sql(),
);
let rows = sqlx::query_as::<_, TaggedChapterAggregate>(&sql)
.bind(user_id)
.bind(tag)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (total,): (i64,) = sqlx::query_as(
r#"
SELECT count(*) FROM (
SELECT 1
FROM page_tags pt
JOIN pages p ON p.id = pt.page_id
WHERE pt.user_id = $1 AND pt.tag = $2
GROUP BY p.chapter_id
) c
"#,
)
.bind(user_id)
.bind(tag)
.fetch_one(pool)
.await?;
Ok((rows, total))
}
/// Paged list of mangas containing pages tagged `tag` for `user_id`,
/// ranked by `match_count` summed across all their chapters.
pub async fn aggregate_mangas_for_tag(
pool: &PgPool,
user_id: Uuid,
tag: &str,
order: Order,
limit: i64,
offset: i64,
) -> AppResult<(Vec<TaggedMangaAggregate>, i64)> {
let sql = format!(
r#"
SELECT
m.id AS manga_id,
m.title AS manga_title,
m.cover_image_path AS manga_cover_image_path,
count(*) AS match_count,
COALESCE(
(
SELECT array_agg(p2.storage_key ORDER BY p2.page_number ASC)
FROM (
SELECT p.storage_key, p.page_number
FROM pages p
JOIN chapters ch2 ON ch2.id = p.chapter_id
JOIN page_tags pt2 ON pt2.page_id = p.id
WHERE ch2.manga_id = m.id
AND pt2.user_id = $1
AND pt2.tag = $2
ORDER BY p.page_number ASC
LIMIT 3
) p2
),
ARRAY[]::text[]
) AS sample_storage_keys
FROM page_tags pt
JOIN pages p ON p.id = pt.page_id
JOIN chapters ch ON ch.id = p.chapter_id
JOIN mangas m ON m.id = ch.manga_id
WHERE pt.user_id = $1
AND pt.tag = $2
GROUP BY m.id, m.title, m.cover_image_path
ORDER BY match_count {dir}, m.id
LIMIT $3 OFFSET $4
"#,
dir = order.as_sql(),
);
let rows = sqlx::query_as::<_, TaggedMangaAggregate>(&sql)
.bind(user_id)
.bind(tag)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (total,): (i64,) = sqlx::query_as(
r#"
SELECT count(*) FROM (
SELECT 1
FROM page_tags pt
JOIN pages p ON p.id = pt.page_id
JOIN chapters ch ON ch.id = p.chapter_id
WHERE pt.user_id = $1 AND pt.tag = $2
GROUP BY ch.manga_id
) m
"#,
)
.bind(user_id)
.bind(tag)
.fetch_one(pool)
.await?;
Ok((rows, total))
}