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:
@@ -10,7 +10,7 @@ use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::CurrentUser;
|
||||
use crate::domain::collection::{
|
||||
Collection, CollectionPatch, CollectionSummary, NewCollection,
|
||||
Collection, CollectionPageItem, CollectionPatch, CollectionSummary, NewCollection,
|
||||
};
|
||||
use crate::domain::manga::Manga;
|
||||
use crate::domain::patch::Patch;
|
||||
@@ -27,10 +27,16 @@ pub fn routes() -> Router<AppState> {
|
||||
"/collections/:id/mangas/:manga_id",
|
||||
delete(remove_manga),
|
||||
)
|
||||
.route("/collections/:id/pages", get(list_pages).post(add_page))
|
||||
.route("/collections/:id/pages/:page_id", delete(remove_page))
|
||||
.route(
|
||||
"/mangas/:id/my-collections",
|
||||
get(list_my_collections_containing),
|
||||
)
|
||||
.route(
|
||||
"/pages/:id/my-collections",
|
||||
get(list_my_collections_containing_page),
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_NAME_LEN: usize = 64;
|
||||
@@ -54,11 +60,21 @@ pub struct AddMangaBody {
|
||||
pub manga_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddPageBody {
|
||||
pub page_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MangaCollectionIds {
|
||||
pub collection_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PageCollectionIds {
|
||||
pub collection_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
fn validate_name(name: &str) -> AppResult<()> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -218,6 +234,57 @@ async fn list_my_collections_containing(
|
||||
Ok(Json(MangaCollectionIds { collection_ids: ids }))
|
||||
}
|
||||
|
||||
async fn list_pages(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(params): Query<ListParams>,
|
||||
) -> AppResult<Json<PagedResponse<CollectionPageItem>>> {
|
||||
require_owner_id(&state, user.id, id).await?;
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
let (items, total) =
|
||||
repo::collection::list_pages(&state.db, id, limit, offset).await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
async fn add_page(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<AddPageBody>,
|
||||
) -> AppResult<StatusCode> {
|
||||
require_owner_id(&state, user.id, id).await?;
|
||||
// FK violation in `repo::collection::add_page` maps to NotFound, so
|
||||
// no separate `repo::page::exists` check is needed — the insert is
|
||||
// the existence check.
|
||||
let created = repo::collection::add_page(&state.db, id, body.page_id).await?;
|
||||
Ok(if created { StatusCode::CREATED } else { StatusCode::OK })
|
||||
}
|
||||
|
||||
async fn remove_page(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path((collection_id, page_id)): Path<(Uuid, Uuid)>,
|
||||
) -> AppResult<StatusCode> {
|
||||
require_owner_id(&state, user.id, collection_id).await?;
|
||||
repo::collection::remove_page(&state.db, collection_id, page_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn list_my_collections_containing_page(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(page_id): Path<Uuid>,
|
||||
) -> AppResult<Json<PageCollectionIds>> {
|
||||
// Mirrors `list_my_collections_containing` for pages: unknown page
|
||||
// returns an empty list, not 404 — keeps the endpoint side-effect-
|
||||
// free and skips a distinguishing-status oracle.
|
||||
let ids = repo::collection::list_collections_containing_page(&state.db, user.id, page_id)
|
||||
.await?;
|
||||
Ok(Json(PageCollectionIds { collection_ids: ids }))
|
||||
}
|
||||
|
||||
/// Returns the row iff the caller owns it. Both "doesn't exist" and
|
||||
/// "exists but belongs to someone else" surface as `NotFound` so the
|
||||
/// API doesn't disclose collection existence to non-owners — the
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod genres;
|
||||
pub mod health;
|
||||
pub mod history;
|
||||
pub mod mangas;
|
||||
pub mod page_tags;
|
||||
pub mod pagination;
|
||||
pub mod tags;
|
||||
|
||||
@@ -28,6 +29,7 @@ pub fn routes() -> Router<AppState> {
|
||||
.merge(tags::routes())
|
||||
.merge(authors::routes())
|
||||
.merge(collections::routes())
|
||||
.merge(page_tags::routes())
|
||||
.merge(history::routes())
|
||||
.merge(admin::routes())
|
||||
}
|
||||
|
||||
443
backend/src/api/page_tags.rs
Normal file
443
backend/src/api/page_tags.rs
Normal file
@@ -0,0 +1,443 @@
|
||||
//! Per-page tag endpoints. See `migration 0023` for the underlying
|
||||
//! schema and `repo::page_tag` for the query layer. All endpoints
|
||||
//! require `CurrentUser` — every byte of this data is owned by the
|
||||
//! caller.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::CurrentUser;
|
||||
use crate::domain::page_tag::{
|
||||
NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate,
|
||||
TaggedPageItem,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
use crate::repo::page_tag::Order;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/pages/:id/tags", post(add))
|
||||
.route("/pages/:id/tags/:tag", delete(remove))
|
||||
// GET uses `/my-tags` to mirror the `mangas/:id/my-collections`
|
||||
// convention — the URL says whose tags we're reading even
|
||||
// though the cookie already implies it.
|
||||
.route("/pages/:id/my-tags", get(list_for_page))
|
||||
.route("/me/page-tags", get(list_mine))
|
||||
.route("/me/page-tags/distinct", get(list_distinct_mine))
|
||||
.route("/me/page-tags/chapters", get(list_chapters_for_tag))
|
||||
.route("/me/page-tags/mangas", get(list_mangas_for_tag))
|
||||
}
|
||||
|
||||
const MAX_TAG_LEN: usize = 64;
|
||||
/// Hard wire-level byte cap, applied before any allocation in
|
||||
/// `normalize_tag`. A 10MB tag in the JSON body would otherwise
|
||||
/// allocate twice in `to_lowercase()` + `split_whitespace().collect()`
|
||||
/// before the 64-char post-normalize cap rejected it. 4 KiB is a
|
||||
/// generous ~64x the post-normalize char cap and well below anything
|
||||
/// a user could fairly call a "tag".
|
||||
const MAX_TAG_BYTES: usize = 4096;
|
||||
const DEFAULT_LIMIT: i64 = 50;
|
||||
const DEFAULT_DISTINCT_LIMIT: i64 = 200;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListMineParams {
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
/// Restrict to this exact tag (chip filter in the library tab).
|
||||
#[serde(default)]
|
||||
pub tag: Option<String>,
|
||||
/// Prefix filter (autocomplete-style).
|
||||
#[serde(default)]
|
||||
pub q: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DistinctParams {
|
||||
#[serde(default)]
|
||||
pub q: Option<String>,
|
||||
#[serde(default = "default_distinct_limit")]
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TagsResponse {
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
DEFAULT_LIMIT
|
||||
}
|
||||
fn default_distinct_limit() -> i64 {
|
||||
DEFAULT_DISTINCT_LIMIT
|
||||
}
|
||||
|
||||
/// Normalize a user-supplied tag for storage. Lowercases, trims,
|
||||
/// collapses internal whitespace, rejects control chars and edge
|
||||
/// colons, caps at 64 chars. Single source of truth so the same input
|
||||
/// produces the same row regardless of which client sent it.
|
||||
/// True for Unicode format / invisible chars that would let two
|
||||
/// visually-identical tags coexist as distinct rows — `"funny"` and
|
||||
/// `"funny\u{200d}"` are different codepoints but render the same, and
|
||||
/// the autocomplete + chip cloud would split them. We hardcode the
|
||||
/// well-known offenders (a subset of the Unicode `Cf` general category
|
||||
/// plus the bidi-override block) so the file stays free of new deps.
|
||||
fn is_invisible_format(c: char) -> bool {
|
||||
matches!(c,
|
||||
// Soft hyphen, Arabic letter mark, Mongolian vowel separator.
|
||||
'\u{00AD}' | '\u{061C}' | '\u{180E}'
|
||||
// ZWSP, ZWNJ, ZWJ, LRM, RLM.
|
||||
| '\u{200B}'..='\u{200F}'
|
||||
// LRE / RLE / PDF / LRO / RLO.
|
||||
| '\u{202A}'..='\u{202E}'
|
||||
// Word joiner, function-application markers, deprecated
|
||||
// formatting (U+206A..U+206F).
|
||||
| '\u{2060}'..='\u{2064}'
|
||||
| '\u{2066}'..='\u{206F}'
|
||||
// BOM / ZWNBSP.
|
||||
| '\u{FEFF}'
|
||||
// Plane-14 LANGUAGE TAG + TAG characters. These can tunnel
|
||||
// ASCII semantics invisibly (the same mechanism used in 2024's
|
||||
// LLM prompt-injection smuggling work). Storing them would
|
||||
// let two visually-identical tags coexist while carrying
|
||||
// distinct hidden payloads.
|
||||
| '\u{E0001}'
|
||||
| '\u{E0020}'..='\u{E007F}'
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_tag(input: &str) -> AppResult<String> {
|
||||
// Fast-fail on absurd input before allocating in lowercase /
|
||||
// whitespace passes. Cheap and bounds worst-case work.
|
||||
if input.len() > MAX_TAG_BYTES {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag too long".into(),
|
||||
details: json!({ "tag": format!("max {MAX_TAG_BYTES} bytes") }),
|
||||
});
|
||||
}
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag is required".into(),
|
||||
details: json!({ "tag": "required" }),
|
||||
});
|
||||
}
|
||||
if trimmed.chars().any(|c| c.is_control()) {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag contains control characters".into(),
|
||||
details: json!({ "tag": "control_chars" }),
|
||||
});
|
||||
}
|
||||
if trimmed.chars().any(is_invisible_format) {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag contains invisible / format characters".into(),
|
||||
details: json!({ "tag": "invisible_chars" }),
|
||||
});
|
||||
}
|
||||
// Reject characters that would break the DELETE URL path
|
||||
// (`/v1/pages/:id/tags/:tag`). axum decodes %2F back to `/` after
|
||||
// routing, so a tag containing one of these would be silently
|
||||
// unreachable to the delete handler — store-only. Reject up front
|
||||
// so every stored tag is removable. `%` and `_` are also rejected
|
||||
// since they are LIKE wildcards in the `list_for_user` prefix
|
||||
// filter and would otherwise let a user accidentally search for
|
||||
// anything.
|
||||
if trimmed.chars().any(|c| matches!(c, '/' | '\\' | '?' | '#' | '%' | '_')) {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag contains a forbidden character (/ \\ ? # % _)".into(),
|
||||
details: json!({ "tag": "forbidden_chars" }),
|
||||
});
|
||||
}
|
||||
// Lowercase + collapse internal whitespace runs (any kind: spaces,
|
||||
// tabs, fullwidth space, etc.) into a single ASCII space. This
|
||||
// keeps "Foo Bar" and "foo bar" identifying the same tag.
|
||||
let normalized: String = trimmed
|
||||
.to_lowercase()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
// `namespace:value` is permitted but a bare leading/trailing colon
|
||||
// is nonsense and would break any future split.
|
||||
if normalized.starts_with(':') || normalized.ends_with(':') {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag must not start or end with ':'".into(),
|
||||
details: json!({ "tag": "edge_colon" }),
|
||||
});
|
||||
}
|
||||
if normalized.chars().count() > MAX_TAG_LEN {
|
||||
// "After normalization" because Unicode case-folding can
|
||||
// expand chars (Turkish capital `İ` → `i\u{307}`, so 33 İ's
|
||||
// pass the wire-level 33-char input but trip this 64 cap).
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag too long after normalization".into(),
|
||||
details: json!({ "tag": format!("max {MAX_TAG_LEN} characters") }),
|
||||
});
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
async fn add(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(page_id): Path<Uuid>,
|
||||
Json(input): Json<NewPageTag>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let tag = normalize_tag(&input.tag)?;
|
||||
let created = repo::page_tag::upsert(&state.db, user.id, page_id, &tag).await?;
|
||||
Ok(if created { StatusCode::CREATED } else { StatusCode::OK })
|
||||
}
|
||||
|
||||
async fn remove(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path((page_id, tag)): Path<(Uuid, String)>,
|
||||
) -> AppResult<StatusCode> {
|
||||
// Normalize the URL-decoded tag the same way add() did, so
|
||||
// DELETE /pages/.../tags/Funny removes the row stored as "funny".
|
||||
let normalized = normalize_tag(&tag)?;
|
||||
repo::page_tag::remove(&state.db, user.id, page_id, &normalized).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn list_for_page(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(page_id): Path<Uuid>,
|
||||
) -> AppResult<Json<TagsResponse>> {
|
||||
let tags = repo::page_tag::list_for_page(&state.db, user.id, page_id).await?;
|
||||
Ok(Json(TagsResponse { tags }))
|
||||
}
|
||||
|
||||
async fn list_mine(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<ListMineParams>,
|
||||
) -> AppResult<Json<PagedResponse<TaggedPageItem>>> {
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
// Filters from the wire arrive raw — normalize them so a filter on
|
||||
// "Funny" matches rows stored as "funny".
|
||||
let tag_filter = params
|
||||
.tag
|
||||
.as_deref()
|
||||
.map(normalize_tag)
|
||||
.transpose()?;
|
||||
let prefix_filter = params
|
||||
.q
|
||||
.as_deref()
|
||||
.map(normalize_tag)
|
||||
.transpose()?;
|
||||
let (items, total) = repo::page_tag::list_for_user(
|
||||
&state.db,
|
||||
user.id,
|
||||
tag_filter.as_deref(),
|
||||
prefix_filter.as_deref(),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
async fn list_distinct_mine(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<DistinctParams>,
|
||||
) -> AppResult<Json<DistinctResponse>> {
|
||||
let limit = params.limit.clamp(1, 500);
|
||||
let prefix = params
|
||||
.q
|
||||
.as_deref()
|
||||
.map(normalize_tag)
|
||||
.transpose()?;
|
||||
let items = repo::page_tag::distinct_tags_for_user(
|
||||
&state.db,
|
||||
user.id,
|
||||
prefix.as_deref(),
|
||||
limit,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(DistinctResponse { items }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DistinctResponse {
|
||||
items: Vec<PageTagSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AggregateParams {
|
||||
/// Required. Exact tag to aggregate by. Empty / whitespace-only
|
||||
/// inputs are rejected with 422 via `normalize_tag`.
|
||||
pub tag: String,
|
||||
/// `desc` (default) or `asc`. Anything else → 422.
|
||||
#[serde(default)]
|
||||
pub order: Option<String>,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
/// Reserved for the planned OCR text-search input. Accepted on
|
||||
/// the wire so adding OCR later won't break the API shape, but
|
||||
/// rejected with 501 `text_search_not_yet_supported` if non-empty
|
||||
/// until the backend supports it.
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_order(raw: Option<&str>) -> AppResult<Order> {
|
||||
match raw.map(str::trim) {
|
||||
None | Some("") | Some("desc") => Ok(Order::Desc),
|
||||
Some("asc") => Ok(Order::Asc),
|
||||
Some(other) => Err(AppError::ValidationFailed {
|
||||
message: format!("order must be 'desc' or 'asc' (got {other:?})"),
|
||||
details: json!({ "order": "invalid" }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_text_unsupported(text: Option<&str>) -> AppResult<()> {
|
||||
// Future OCR search will plug in here. Until then, return a
|
||||
// distinct code (`text_search_not_yet_supported`) so clients can
|
||||
// detect "feature pending" vs. a generic 4xx — the code is the
|
||||
// wire contract, not the message.
|
||||
if text.is_some_and(|s| !s.trim().is_empty()) {
|
||||
return Err(AppError::NotImplemented {
|
||||
code: "text_search_not_yet_supported",
|
||||
message: "text search is reserved for the planned OCR input but not yet supported",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_chapters_for_tag(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<AggregateParams>,
|
||||
) -> AppResult<Json<PagedResponse<TaggedChapterAggregate>>> {
|
||||
ensure_text_unsupported(params.text.as_deref())?;
|
||||
let tag = normalize_tag(¶ms.tag)?;
|
||||
let order = parse_order(params.order.as_deref())?;
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
let (items, total) = repo::page_tag::aggregate_chapters_for_tag(
|
||||
&state.db, user.id, &tag, order, limit, offset,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
async fn list_mangas_for_tag(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<AggregateParams>,
|
||||
) -> AppResult<Json<PagedResponse<TaggedMangaAggregate>>> {
|
||||
ensure_text_unsupported(params.text.as_deref())?;
|
||||
let tag = normalize_tag(¶ms.tag)?;
|
||||
let order = parse_order(params.order.as_deref())?;
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
let (items, total) = repo::page_tag::aggregate_mangas_for_tag(
|
||||
&state.db, user.id, &tag, order, limit, offset,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_tag;
|
||||
|
||||
#[test]
|
||||
fn lowercases_and_collapses_whitespace() {
|
||||
assert_eq!(normalize_tag("Funny").unwrap(), "funny");
|
||||
assert_eq!(normalize_tag(" Foo Bar ").unwrap(), "foo bar");
|
||||
assert_eq!(normalize_tag("character:Askeladd").unwrap(), "character:askeladd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_blank_input() {
|
||||
assert!(normalize_tag("").is_err());
|
||||
assert!(normalize_tag(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_control_chars() {
|
||||
assert!(normalize_tag("bad\ntag").is_err());
|
||||
assert!(normalize_tag("a\tb").is_err());
|
||||
// NEL (U+0085, Cc) — rejected via `char::is_control()`. Locked
|
||||
// here so a future stdlib drift surfaces as a test fail rather
|
||||
// than a silent acceptance.
|
||||
assert!(normalize_tag("a\u{0085}b").is_err());
|
||||
// NULL byte.
|
||||
assert!(normalize_tag("a\u{0000}b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_edge_colon() {
|
||||
assert!(normalize_tag(":foo").is_err());
|
||||
assert!(normalize_tag("foo:").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invisible_format_chars() {
|
||||
// ZWJ — visually indistinguishable from no-zwj, would split
|
||||
// "funny" and "funny\u{200d}" into distinct rows.
|
||||
assert!(normalize_tag("funny\u{200d}").is_err());
|
||||
// RLO — bidi override could let a tag display backwards.
|
||||
assert!(normalize_tag("a\u{202e}b").is_err());
|
||||
// ZWNBSP / BOM at the boundary survives `trim()`.
|
||||
assert!(normalize_tag("a\u{feff}b").is_err());
|
||||
// ZWSP, RLM also rejected.
|
||||
assert!(normalize_tag("a\u{200b}b").is_err());
|
||||
assert!(normalize_tag("a\u{200f}b").is_err());
|
||||
// Plane-14 LANGUAGE TAG + TAG-character block. These tunnel
|
||||
// ASCII invisibly via the supplementary tag mechanism.
|
||||
assert!(normalize_tag("a\u{E0001}b").is_err());
|
||||
assert!(normalize_tag("a\u{E0020}b").is_err());
|
||||
assert!(normalize_tag("a\u{E007F}b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_path_breaking_and_like_wildcards() {
|
||||
// `/` would survive %-encoding round-trip but axum decodes
|
||||
// %2F back to `/` after path routing, so the DELETE path
|
||||
// would never match. Same for `?`, `#`, `\`.
|
||||
assert!(normalize_tag("a/b").is_err());
|
||||
assert!(normalize_tag("a\\b").is_err());
|
||||
assert!(normalize_tag("a?b").is_err());
|
||||
assert!(normalize_tag("a#b").is_err());
|
||||
// `%` and `_` are SQL LIKE wildcards in the prefix filter.
|
||||
assert!(normalize_tag("a%b").is_err());
|
||||
assert!(normalize_tag("a_b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_long() {
|
||||
let long = "a".repeat(65);
|
||||
assert!(normalize_tag(&long).is_err());
|
||||
let ok_len = "a".repeat(64);
|
||||
assert!(normalize_tag(&ok_len).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_pathologically_long_input_before_allocating() {
|
||||
// 10 MiB of `a` — should fail-fast on the byte cap, not
|
||||
// allocate twice through the lowercase/whitespace passes.
|
||||
let huge = "a".repeat(10 * 1024 * 1024);
|
||||
assert!(normalize_tag(&huge).is_err());
|
||||
// Just under cap still goes through the rest of the pipeline
|
||||
// and fails on the char count.
|
||||
let near_cap = "a".repeat(super::MAX_TAG_BYTES);
|
||||
assert!(normalize_tag(&near_cap).is_err());
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,22 @@ pub struct CollectionSummary {
|
||||
pub sample_covers: Vec<String>,
|
||||
}
|
||||
|
||||
/// Row returned by `GET /collections/:id/pages`. Joins through
|
||||
/// `chapters` and `mangas` so the collection detail view can render a
|
||||
/// thumbnail + breadcrumb without per-row follow-up fetches.
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct CollectionPageItem {
|
||||
pub page_id: Uuid,
|
||||
pub chapter_id: Uuid,
|
||||
pub manga_id: Uuid,
|
||||
pub page_number: i32,
|
||||
pub chapter_number: i32,
|
||||
pub chapter_title: Option<String>,
|
||||
pub manga_title: String,
|
||||
pub storage_key: String,
|
||||
pub added_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NewCollection {
|
||||
pub name: String,
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod collection;
|
||||
pub mod genre;
|
||||
pub mod manga;
|
||||
pub mod page;
|
||||
pub mod page_tag;
|
||||
pub mod patch;
|
||||
pub mod read_progress;
|
||||
pub mod session;
|
||||
@@ -21,10 +22,14 @@ pub use api_token::ApiToken;
|
||||
pub use author::{Author, AuthorRef, AuthorWithCount};
|
||||
pub use bookmark::{Bookmark, BookmarkSummary};
|
||||
pub use chapter::Chapter;
|
||||
pub use collection::{Collection, CollectionSummary};
|
||||
pub use collection::{Collection, CollectionPageItem, CollectionSummary};
|
||||
pub use genre::{Genre, GenreRef};
|
||||
pub use manga::{Manga, MangaCard, MangaDetail};
|
||||
pub use page::Page;
|
||||
pub use page_tag::{
|
||||
NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate,
|
||||
TaggedPageItem,
|
||||
};
|
||||
pub use patch::Patch;
|
||||
pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary};
|
||||
pub use session::Session;
|
||||
|
||||
63
backend/src/domain/page_tag.rs
Normal file
63
backend/src/domain/page_tag.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NewPageTag {
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
/// Returned by `GET /v1/me/page-tags`. Joins through chapters and
|
||||
/// mangas so the library Page-tags tab can render the breadcrumb
|
||||
/// without follow-up requests.
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct TaggedPageItem {
|
||||
pub tag: String,
|
||||
pub page_id: Uuid,
|
||||
pub chapter_id: Uuid,
|
||||
pub manga_id: Uuid,
|
||||
pub page_number: i32,
|
||||
pub chapter_number: i32,
|
||||
pub chapter_title: Option<String>,
|
||||
pub manga_title: String,
|
||||
pub storage_key: String,
|
||||
pub tagged_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Distinct-tags histogram row. Used both for autocomplete in the
|
||||
/// "Add tag" sheet and for the chip cloud in the library Page-tags
|
||||
/// tab.
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct PageTagSummary {
|
||||
pub tag: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// One chapter (with breadcrumb) ranked by how many of its pages the
|
||||
/// caller has tagged with a given tag. Returned by the `/search`
|
||||
/// page's Chapters tab via `GET /v1/me/page-tags/chapters`.
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct TaggedChapterAggregate {
|
||||
pub chapter_id: Uuid,
|
||||
pub manga_id: Uuid,
|
||||
pub manga_title: String,
|
||||
pub chapter_number: i32,
|
||||
pub chapter_title: Option<String>,
|
||||
pub match_count: i64,
|
||||
/// Up to 3 storage keys of matching pages in this chapter,
|
||||
/// page-number ascending. Powers the thumbnail strip in the row.
|
||||
pub sample_storage_keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// One manga ranked by how many of its pages (across all chapters)
|
||||
/// the caller has tagged with a given tag. Returned by the `/search`
|
||||
/// page's Mangas tab.
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct TaggedMangaAggregate {
|
||||
pub manga_id: Uuid,
|
||||
pub manga_title: String,
|
||||
pub manga_cover_image_path: Option<String>,
|
||||
pub match_count: i64,
|
||||
pub sample_storage_keys: Vec<String>,
|
||||
}
|
||||
@@ -38,6 +38,16 @@ pub enum AppError {
|
||||
message: String,
|
||||
details: serde_json::Value,
|
||||
},
|
||||
/// 501 — the wire shape is accepted but the feature isn't built yet.
|
||||
/// Carries a `&'static str` snake_case code so clients can detect
|
||||
/// the specific pending feature (`text_search_not_yet_supported`,
|
||||
/// etc.) without parsing the message. Used today by the `?text=`
|
||||
/// reservation on the page-tag aggregation endpoints.
|
||||
#[error("not implemented: {code}")]
|
||||
NotImplemented {
|
||||
code: &'static str,
|
||||
message: &'static str,
|
||||
},
|
||||
#[error(transparent)]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error(transparent)]
|
||||
@@ -64,6 +74,7 @@ impl AppError {
|
||||
AppError::ServiceUnavailable(_) => "service_unavailable",
|
||||
AppError::TooManyRequests { .. } => "too_many_requests",
|
||||
AppError::ValidationFailed { .. } => "validation_failed",
|
||||
AppError::NotImplemented { code, .. } => code,
|
||||
AppError::Database(sqlx::Error::RowNotFound) => "not_found",
|
||||
AppError::Database(_) => "internal_error",
|
||||
AppError::Storage(StorageError::NotFound) => "not_found",
|
||||
@@ -124,6 +135,11 @@ impl IntoResponse for AppError {
|
||||
message.clone(),
|
||||
Some(details.clone()),
|
||||
),
|
||||
AppError::NotImplemented { message, .. } => (
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
(*message).to_string(),
|
||||
None,
|
||||
),
|
||||
AppError::Database(sqlx::Error::RowNotFound) => {
|
||||
(StatusCode::NOT_FOUND, "not found".to_string(), None)
|
||||
}
|
||||
@@ -180,5 +196,15 @@ mod tests {
|
||||
assert_eq!(AppError::Storage(StorageError::NotFound).code(), "not_found");
|
||||
assert_eq!(AppError::Database(sqlx::Error::RowNotFound).code(), "not_found");
|
||||
assert_eq!(AppError::Other(anyhow::anyhow!("oops")).code(), "internal_error");
|
||||
// NotImplemented carries the code through so each pending
|
||||
// feature gets its own stable identifier on the wire.
|
||||
assert_eq!(
|
||||
AppError::NotImplemented {
|
||||
code: "text_search_not_yet_supported",
|
||||
message: "x"
|
||||
}
|
||||
.code(),
|
||||
"text_search_not_yet_supported"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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