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:
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
374
backend/src/repo/page_tag.rs
Normal file
374
backend/src/repo/page_tag.rs
Normal 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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user