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

@@ -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"
);
}
}