use axum::{
extract::{Multipart, Path, Query, State},
http::{header::CONTENT_LENGTH, HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
Json,
};
use minijinja::context;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use tracing::{debug, error, info};
use uuid::Uuid;
use crate::entities::{ai_descriptions, ocr_results, tags, upload_tags, uploads};
use crate::jobs;
use crate::middleware::UserContext;
use crate::queue::{publish_job, JobKind, JobMessage};
use crate::storage::{LocalStorage, Storage};
use crate::tags as tagsvc;
use crate::AppState;
#[derive(Debug, Deserialize)]
pub struct UploadQuery {
/// Free-text tag filter, e.g. "desc:dog Series:Friends"
pub tags: Option<String>,
/// Restrict to a single namespace
pub namespace: Option<String>,
pub q: Option<String>,
pub page: Option<u64>,
/// Surfaced in the listing UI as a banner — populated by the upload
/// handler after a redirect on user-fixable failures (too large, etc).
pub error: Option<String>,
pub notice: Option<String>,
}
fn human_bytes(n: usize) -> String {
const MIB: usize = 1024 * 1024;
const KIB: usize = 1024;
if n >= MIB {
format!("{:.1} MiB", n as f64 / MIB as f64)
} else if n >= KIB {
format!("{:.1} KiB", n as f64 / KIB as f64)
} else {
format!("{} B", n)
}
}
fn upload_redirect_error(msg: &str) -> Redirect {
let encoded: String = msg
.as_bytes()
.iter()
.map(|&b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
b' ' => "+".to_string(),
_ => format!("%{:02X}", b),
})
.collect();
Redirect::to(&format!("/uploads?error={}", encoded))
}
const PAGE_SIZE: u64 = 24;
pub async fn list_uploads(
State(state): State<AppState>,
user: UserContext,
Query(qs): Query<UploadQuery>,
) -> Result<Html<String>, StatusCode> {
let (rows, total) = query_uploads(&state, &user, &qs)
.await
.map_err(|e| {
error!(error = %e, "list_uploads query failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let template = state
.env
.get_template("uploads/list.html")
.map_err(|e| { error!(error = %e, "load uploads/list.html failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
let rendered = template
.render(context! {
title => "Uploads",
user => user.user,
uploads => rows,
total => total,
page => qs.page.unwrap_or(1),
page_size => PAGE_SIZE,
filter_tags => qs.tags.clone().unwrap_or_default(),
filter_namespace => qs.namespace.clone().unwrap_or_default(),
filter_q => qs.q.clone().unwrap_or_default(),
error => qs.error.clone(),
notice => qs.notice.clone(),
})
.map_err(|e| {
error!(error = %e, "list_uploads render failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Html(rendered))
}
pub async fn upload_file(
State(state): State<AppState>,
user: UserContext,
headers: HeaderMap,
multipart: Multipart,
) -> Response {
// Cheap pre-check: trust Content-Length for an early bailout. The handler
// also re-checks the actual bytes — Content-Length is hint-only.
if let Some(len) = headers
.get(CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<usize>().ok())
{
if len > state.config.max_upload_bytes {
return upload_redirect_error(&format!(
"Upload too large ({}). Maximum is {}.",
human_bytes(len),
human_bytes(state.config.max_upload_bytes)
))
.into_response();
}
}
match ingest_multipart(&state, &user, multipart).await {
Ok(id) => Redirect::to(&format!("/uploads/{}", id)).into_response(),
Err(IngestError::TooLarge { actual, limit }) => upload_redirect_error(&format!(
"Upload too large ({}). Maximum is {}.",
human_bytes(actual),
human_bytes(limit)
))
.into_response(),
Err(IngestError::BadRequest(msg)) => upload_redirect_error(&msg).into_response(),
Err(IngestError::Status(code)) => code.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct AddTagsForm {
pub tags: Option<String>,
}
async fn add_tags_inner(
state: &AppState,
user: &UserContext,
upload_id: Uuid,
raw: &str,
) -> Result<Uuid, StatusCode> {
let upload = uploads::Entity::find_by_id(upload_id)
.filter(uploads::Column::UserId.eq(user.user.id))
.one(state.db.as_ref())
.await
.map_err(|e| { error!(error = %e, "add_tags: find upload failed"); StatusCode::INTERNAL_SERVER_ERROR })?
.ok_or(StatusCode::NOT_FOUND)?;
let parsed = tagsvc::parse_tags(raw);
if parsed.is_empty() {
return Ok(upload.id);
}
if let Err(e) = tagsvc::attach_tags(state.db.as_ref(), upload.id, &parsed, None).await {
error!(error = %e, "attach user tags failed");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
info!(upload_id = %upload.id, count = parsed.len(), "user tags attached");
Ok(upload.id)
}
/// POST /uploads/{id}/tags — HTML form path; redirects back to the detail page.
pub async fn add_tags(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
axum::extract::Form(form): axum::extract::Form<AddTagsForm>,
) -> Result<Redirect, StatusCode> {
let id = add_tags_inner(&state, &user, id, form.tags.as_deref().unwrap_or("")).await?;
Ok(Redirect::to(&format!("/uploads/{}", id)))
}
/// POST /api/uploads/{id}/tags — JSON path; returns the updated upload.
pub async fn api_add_tags(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
Json(form): Json<AddTagsForm>,
) -> Result<Json<serde_json::Value>, StatusCode> {
add_tags_inner(&state, &user, id, form.tags.as_deref().unwrap_or("")).await?;
// Reuse the existing detail JSON shape so the client can swap in the
// response without a second round-trip.
api_show_upload(State(state), user, Path(id)).await
}
#[derive(Debug, Deserialize)]
pub struct EnqueueJobForm {
pub kind: String,
/// When "on" / "true", clear the existing result row for this kind before
/// enqueuing so the worker doesn't short-circuit via its idempotency check.
pub force: Option<String>,
}
/// POST /uploads/{id}/jobs — enqueue a categorizer job for this upload.
pub async fn enqueue_job(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
axum::extract::Form(form): axum::extract::Form<EnqueueJobForm>,
) -> Result<Redirect, StatusCode> {
let upload = uploads::Entity::find_by_id(id)
.filter(uploads::Column::UserId.eq(user.user.id))
.one(state.db.as_ref())
.await
.map_err(|e| { error!(error = %e, "enqueue_job: find upload failed"); StatusCode::INTERNAL_SERVER_ERROR })?
.ok_or(StatusCode::NOT_FOUND)?;
let kind = JobKind::from_str(&form.kind).ok_or(StatusCode::BAD_REQUEST)?;
let force = form
.force
.as_deref()
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "1" | "true" | "yes"))
.unwrap_or(false);
if force {
match kind {
JobKind::Ocr => {
let _ = ocr_results::Entity::delete_many()
.filter(ocr_results::Column::UploadId.eq(upload.id))
.exec(state.db.as_ref())
.await;
}
JobKind::Describe => {
let _ = ai_descriptions::Entity::delete_many()
.filter(ai_descriptions::Column::UploadId.eq(upload.id))
.exec(state.db.as_ref())
.await;
}
_ => {}
}
}
// Reset (or create) the upload_jobs row for this kind. Always — manual
// re-trigger should reflect a fresh attempt regardless of force.
if let Err(e) = jobs::upsert_pending(state.db.as_ref(), upload.id, kind).await {
error!(error = %e, "upsert pending job row failed");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
let _ = uploads::Entity::update_many()
.filter(uploads::Column::Id.eq(upload.id))
.col_expr(uploads::Column::ProcessingStatus, sea_orm::sea_query::Expr::value(jobs::UPLOAD_STATUS_PROCESSING))
.exec(state.db.as_ref())
.await;
let conn = crate::queue::connect(&state.config.amqp_url)
.await
.map_err(|e| { error!(error = %e, "amqp connect failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
let msg = JobMessage::new(kind, upload.id, upload.user_id);
publish_job(&conn, &msg)
.await
.map_err(|e| { error!(error = %e, "publish job failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
info!(upload_id = %upload.id, kind = ?kind, force, "job enqueued");
Ok(Redirect::to(&format!("/uploads/{}", upload.id)))
}
pub async fn show_upload(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
let upload = uploads::Entity::find_by_id(id)
.filter(uploads::Column::UserId.eq(user.user.id))
.one(state.db.as_ref())
.await
.map_err(|e| { error!(error = %e, "show_upload: find failed"); StatusCode::INTERNAL_SERVER_ERROR })?
.ok_or(StatusCode::NOT_FOUND)?;
let attached_tags = load_tags_for_upload(state.db.as_ref(), id)
.await
.map_err(|e| { error!(error = %e, "show_upload: load tags failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
let ocr_rows = ocr_results::Entity::find()
.filter(ocr_results::Column::UploadId.eq(id))
.order_by_desc(ocr_results::Column::ProcessedAt)
.all(state.db.as_ref())
.await
.map_err(|e| { error!(error = %e, "load ocr rows failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
let description_rows = ai_descriptions::Entity::find()
.filter(ai_descriptions::Column::UploadId.eq(id))
.order_by_desc(ai_descriptions::Column::ProcessedAt)
.all(state.db.as_ref())
.await
.map_err(|e| { error!(error = %e, "load description rows failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
let job_rows = jobs::jobs_for_upload(state.db.as_ref(), id)
.await
.map_err(|e| { error!(error = %e, "load upload_jobs failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
let mut job_by_kind: std::collections::BTreeMap<String, _> = std::collections::BTreeMap::new();
for row in &job_rows {
job_by_kind.insert(row.kind.clone(), row.clone());
}
let template = state
.env
.get_template("uploads/show.html")
.map_err(|e| { error!(error = %e, "load uploads/show.html failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
let rendered = template
.render(context! {
title => upload.item_name.clone().unwrap_or_else(|| upload.file_name_original.clone()),
user => user.user,
upload => upload,
tags => attached_tags,
ocr_rows => ocr_rows,
description_rows => description_rows,
job_rows => job_rows,
job_by_kind => job_by_kind,
})
.map_err(|e| { error!(error = %e, "render uploads/show.html failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
Ok(Html(rendered))
}
#[derive(Debug, Serialize)]
pub struct UploadJson {
pub id: Uuid,
pub file_name_original: String,
pub item_path: String,
pub processing_status: String,
}
pub async fn api_list_uploads(
State(state): State<AppState>,
user: UserContext,
Query(qs): Query<UploadQuery>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let (rows, total) = query_uploads(&state, &user, &qs)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let json: Vec<_> = rows
.into_iter()
.map(|u| UploadJson {
id: u.id,
file_name_original: u.file_name_original,
item_path: u.item_path,
processing_status: u.processing_status,
})
.collect();
Ok(Json(serde_json::json!({
"total": total,
"page": qs.page.unwrap_or(1),
"page_size": PAGE_SIZE,
"items": json,
})))
}
pub async fn api_upload_file(
State(state): State<AppState>,
user: UserContext,
headers: HeaderMap,
multipart: Multipart,
) -> Response {
if let Some(len) = headers
.get(CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<usize>().ok())
{
if len > state.config.max_upload_bytes {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({
"error": "payload_too_large",
"max_bytes": state.config.max_upload_bytes,
"received_bytes": len,
})),
)
.into_response();
}
}
match ingest_multipart(&state, &user, multipart).await {
Ok(id) => Json(serde_json::json!({ "id": id })).into_response(),
Err(IngestError::TooLarge { actual, limit }) => (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({
"error": "payload_too_large",
"max_bytes": limit,
"received_bytes": actual,
})),
)
.into_response(),
Err(IngestError::BadRequest(msg)) => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": msg})),
)
.into_response(),
Err(IngestError::Status(code)) => code.into_response(),
}
}
pub async fn api_show_upload(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let upload = uploads::Entity::find_by_id(id)
.filter(uploads::Column::UserId.eq(user.user.id))
.one(state.db.as_ref())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let attached_tags = load_tags_for_upload(state.db.as_ref(), id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let tag_strings: Vec<String> = attached_tags
.iter()
.map(|t| match &t.namespace {
Some(ns) => format!("{}:{}", ns, t.tag_value),
None => t.tag_value.clone(),
})
.collect();
Ok(Json(serde_json::json!({
"id": upload.id,
"file_name_original": upload.file_name_original,
"item_path": upload.item_path,
"item_name": upload.item_name,
"item_description": upload.item_description,
"processing_status": upload.processing_status,
"date_uploaded": upload.date_uploaded,
"tags": tag_strings,
})))
}
pub enum IngestError {
TooLarge { actual: usize, limit: usize },
BadRequest(String),
Status(StatusCode),
}
impl From<StatusCode> for IngestError {
fn from(c: StatusCode) -> Self { IngestError::Status(c) }
}
async fn ingest_multipart(
state: &AppState,
user: &UserContext,
mut multipart: Multipart,
) -> Result<Uuid, IngestError> {
let mut file_bytes: Option<Vec<u8>> = None;
let mut original_name: Option<String> = None;
let mut content_type: Option<String> = None;
let mut item_name: Option<String> = None;
let mut item_description: Option<String> = None;
let mut tags_raw: Option<String> = None;
loop {
let field = match multipart.next_field().await {
Ok(Some(f)) => f,
Ok(None) => break,
Err(e) => {
// Map Axum's "body too large" rejection into our friendly
// TooLarge variant so the user sees a nice message.
let s = e.to_string();
error!(error = %s, "multipart next_field failed");
if s.to_ascii_lowercase().contains("length limit") || s.to_ascii_lowercase().contains("too large") {
return Err(IngestError::TooLarge {
actual: state.config.max_upload_bytes + 1,
limit: state.config.max_upload_bytes,
});
}
return Err(IngestError::BadRequest("Malformed multipart upload.".into()));
}
};
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"file" => {
original_name = field.file_name().map(|s| s.to_string());
content_type = field.content_type().map(|s| s.to_string());
let bytes = field.bytes().await.map_err(|e| {
let s = e.to_string();
error!(error = %s, "multipart read file bytes failed");
if s.to_ascii_lowercase().contains("length limit") || s.to_ascii_lowercase().contains("too large") {
IngestError::TooLarge {
actual: state.config.max_upload_bytes + 1,
limit: state.config.max_upload_bytes,
}
} else {
IngestError::BadRequest("Could not read uploaded file.".into())
}
})?;
if bytes.len() > state.config.max_upload_bytes {
return Err(IngestError::TooLarge {
actual: bytes.len(),
limit: state.config.max_upload_bytes,
});
}
file_bytes = Some(bytes.to_vec());
}
"item_name" => {
item_name = field.text().await.ok();
}
"item_description" => {
item_description = field.text().await.ok();
}
"tags" => {
tags_raw = field.text().await.ok();
}
_ => {
let _ = field.bytes().await;
}
}
}
let bytes = file_bytes.ok_or_else(|| IngestError::BadRequest("No file selected.".into()))?;
let original_name = original_name.unwrap_or_else(|| "upload.bin".to_string());
let extension = std::path::Path::new(&original_name)
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase());
let resolved_ct = content_type.clone().or_else(|| {
mime_guess::from_path(&original_name)
.first()
.map(|m| m.to_string())
});
let storage = LocalStorage::new(&state.config.storage_root);
let stored = storage
.put(&bytes, extension.as_deref())
.await
.map_err(|e| {
error!(error = %e, "storage put failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Dedupe per user via the unique (file_hash, user_id) index.
if let Some(existing) = uploads::Entity::find()
.filter(uploads::Column::UserId.eq(user.user.id))
.filter(uploads::Column::FileHash.eq(stored.sha256_hex.clone()))
.one(state.db.as_ref())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
info!(upload_id = %existing.id, "duplicate upload short-circuited");
return Ok(existing.id);
}
let active = uploads::ActiveModel {
id: Set(Uuid::now_v7()),
user_id: Set(user.user.id),
file_name_original: Set(original_name.clone()),
file_name_generated: Set(stored.relative_path.clone()),
file_size_bytes: Set(stored.size_bytes as i64),
content_type: Set(resolved_ct),
file_hash: Set(Some(stored.sha256_hex.clone())),
item_name: Set(item_name),
item_description: Set(item_description),
item_path: Set(stored.relative_path.clone()),
storage_backend: Set("local".to_string()),
processing_status: Set("pending".to_string()),
processing_error: Set(None),
date_uploaded: Set(chrono::Utc::now().into()),
date_updated: Set(chrono::Utc::now().into()),
};
let inserted = active
.insert(state.db.as_ref())
.await
.map_err(|e| {
error!(error = %e, "upload insert failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if let Some(raw) = tags_raw.as_deref() {
let parsed = tagsvc::parse_tags(raw);
if let Err(e) = tagsvc::attach_tags(state.db.as_ref(), inserted.id, &parsed, None).await {
error!(error = %e, "attach user tags failed");
}
}
enqueue_default_jobs(state, &inserted).await;
Ok(inserted.id)
}
async fn enqueue_default_jobs(state: &AppState, upload: &uploads::Model) {
let is_image = upload
.content_type
.as_deref()
.map(|ct| ct.starts_with("image/"))
.unwrap_or(false);
let mut kinds: Vec<JobKind> = Vec::new();
if is_image {
kinds.push(JobKind::Thumbnail);
kinds.push(JobKind::Ocr);
kinds.push(JobKind::Describe);
}
if kinds.is_empty() {
return;
}
// Create the pending rows BEFORE publishing so the aggregate computed by
// workers sees the full set up front.
for kind in &kinds {
if let Err(e) = jobs::upsert_pending(state.db.as_ref(), upload.id, *kind).await {
error!(error = %e, ?kind, "upsert pending job row failed");
}
}
// Bring uploads.processing_status in line with the pending rows.
let _ = uploads::Entity::update_many()
.filter(uploads::Column::Id.eq(upload.id))
.col_expr(uploads::Column::ProcessingStatus, sea_orm::sea_query::Expr::value(jobs::UPLOAD_STATUS_PROCESSING))
.exec(state.db.as_ref())
.await;
let conn = match crate::queue::connect(&state.config.amqp_url).await {
Ok(c) => c,
Err(e) => {
error!(error = %e, "amqp connect failed; jobs will not be dispatched");
return;
}
};
for kind in kinds {
let msg = JobMessage::new(kind, upload.id, upload.user_id);
if let Err(e) = publish_job(&conn, &msg).await {
error!(kind = ?kind, error = %e, "publish failed");
}
}
}
// ============================================================================
// Dedicated search page
// ============================================================================
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SearchSpec {
/// Bare value, e.g. "macro" — matches `tag_value LIKE %macro%` against
/// any selected tag-source namespace, plus any selected text fields.
Value(String),
/// `ns:value` — matches `namespace = ns AND tag_value LIKE %value%`.
NsValue { ns: String, val: String },
/// `ns:*` — matches every tag in the namespace, ignoring tag value.
/// Text-field sources are skipped entirely; only tag matches contribute.
NsWildcard(String),
}
/// Parse a query string into a SearchSpec.
pub fn parse_search_spec(q: &str) -> SearchSpec {
match q.split_once(':') {
Some((ns, val)) if !ns.trim().is_empty() => {
let ns = ns.trim().to_string();
let val = val.trim();
if val == "*" {
SearchSpec::NsWildcard(ns)
} else if val.is_empty() {
// "species:" — treat as bare so we don't surprise the user with
// an open-ended namespace wildcard.
SearchSpec::Value(q.to_string())
} else {
SearchSpec::NsValue { ns, val: val.to_string() }
}
}
_ => SearchSpec::Value(q.to_string()),
}
}
/// Backwards-compatible split kept for callers that only need the namespace
/// and value strings.
pub fn split_namespace_query(q: &str) -> (Option<String>, String) {
match parse_search_spec(q) {
SearchSpec::Value(v) => (None, v),
SearchSpec::NsValue { ns, val } => (Some(ns), val),
SearchSpec::NsWildcard(ns) => (Some(ns), "*".to_string()),
}
}
#[derive(Debug, Deserialize, Default)]
pub struct SearchQuery {
pub q: Option<String>,
/// Marker that the form was submitted, so we know to respect the absence
/// of an `s_*` checkbox as "this source is off". Without it, missing
/// fields default to all-on (first visit).
pub f: Option<String>,
pub s_filename: Option<String>,
pub s_item_name: Option<String>,
pub s_description: Option<String>,
pub s_user_tags: Option<String>,
pub s_ocr: Option<String>,
pub s_desc: Option<String>,
/// Any namespaced tag whose namespace isn't `ocr` or `desc` — covers tags
/// the user added by hand, future categorizers, etc.
pub s_other_ns: Option<String>,
pub page: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SearchSources {
pub filename: bool,
pub item_name: bool,
pub description: bool,
pub user_tags: bool,
pub ocr: bool,
pub desc: bool,
pub other_ns: bool,
}
impl SearchSources {
pub fn from_query(qs: &SearchQuery) -> Self {
let submitted = qs.f.as_deref().map(|v| !v.is_empty()).unwrap_or(false);
if !submitted {
return Self {
filename: true, item_name: true, description: true,
user_tags: true, ocr: true, desc: true, other_ns: true,
};
}
fn on(v: &Option<String>) -> bool {
v.as_deref().map(|s| !s.is_empty()).unwrap_or(false)
}
Self {
filename: on(&qs.s_filename),
item_name: on(&qs.s_item_name),
description: on(&qs.s_description),
user_tags: on(&qs.s_user_tags),
ocr: on(&qs.s_ocr),
desc: on(&qs.s_desc),
other_ns: on(&qs.s_other_ns),
}
}
pub fn any(&self) -> bool {
self.filename || self.item_name || self.description
|| self.user_tags || self.ocr || self.desc || self.other_ns
}
pub const fn all_on() -> Self {
Self {
filename: true, item_name: true, description: true,
user_tags: true, ocr: true, desc: true, other_ns: true,
}
}
}
#[cfg(test)]
mod search_unit_tests {
use super::*;
fn qs(s: SearchQuery) -> SearchQuery { s }
#[test]
fn split_bare_query() {
assert_eq!(split_namespace_query("macro"), (None, "macro".to_string()));
}
#[test]
fn split_namespaced_query() {
assert_eq!(split_namespace_query("species:macro"),
(Some("species".to_string()), "macro".to_string()));
}
#[test]
fn split_trims_whitespace() {
assert_eq!(split_namespace_query(" species : macro "),
(Some("species".to_string()), "macro".to_string()));
}
#[test]
fn split_empty_half_treated_as_bare() {
assert_eq!(split_namespace_query(":foo"), (None, ":foo".to_string()));
assert_eq!(split_namespace_query("foo:"), (None, "foo:".to_string()));
assert_eq!(split_namespace_query(":"), (None, ":".to_string()));
}
#[test]
fn split_only_first_colon() {
// Subsequent colons are part of the value half.
assert_eq!(
split_namespace_query("series:friends:s01"),
(Some("series".to_string()), "friends:s01".to_string())
);
}
#[test]
fn spec_bare() {
assert_eq!(parse_search_spec("macro"), SearchSpec::Value("macro".into()));
}
#[test]
fn spec_namespaced() {
assert_eq!(
parse_search_spec("species:macro"),
SearchSpec::NsValue { ns: "species".into(), val: "macro".into() }
);
}
#[test]
fn spec_wildcard() {
assert_eq!(parse_search_spec("species:*"), SearchSpec::NsWildcard("species".into()));
assert_eq!(parse_search_spec(" species : * "), SearchSpec::NsWildcard("species".into()));
}
#[test]
fn spec_trailing_colon_is_bare() {
// "species:" is treated as a bare query (against the literal "species:")
// rather than a namespace wildcard, so users can still search for
// text containing a colon without surprise.
assert!(matches!(parse_search_spec("species:"), SearchSpec::Value(_)));
}
#[test]
fn sources_default_all_on_when_unsubmitted() {
let s = SearchSources::from_query(&qs(SearchQuery::default()));
assert_eq!(s, SearchSources::all_on());
}
#[test]
fn sources_respect_explicit_off() {
// f=1 marks the form as submitted; missing checkboxes mean off.
let s = SearchSources::from_query(&qs(SearchQuery {
f: Some("1".into()),
s_filename: Some("on".into()),
// every other source omitted
..Default::default()
}));
assert!(s.filename);
assert!(!s.item_name);
assert!(!s.user_tags);
assert!(!s.ocr);
assert!(!s.desc);
assert!(!s.other_ns);
assert!(s.any());
}
#[test]
fn sources_no_box_means_no_search() {
let s = SearchSources::from_query(&qs(SearchQuery {
f: Some("1".into()),
..Default::default()
}));
assert!(!s.any());
}
}
pub async fn search_uploads(
State(state): State<AppState>,
user: UserContext,
Query(qs): Query<SearchQuery>,
) -> Result<Html<String>, StatusCode> {
let sources = SearchSources::from_query(&qs);
let q = qs.q.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
let result = if !sources.any() || q.is_none() {
SearchResult { rows: Vec::new(), total: 0, matched_tags: Default::default() }
} else {
run_search(&state, &user, q.as_deref().unwrap(), &sources, qs.page.unwrap_or(1))
.await
.map_err(|e| { error!(error = %e, "search query failed"); StatusCode::INTERNAL_SERVER_ERROR })?
};
let template = state.env.get_template("search.html")
.map_err(|e| { error!(error = %e, "load search.html failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
let rendered = template
.render(context! {
title => "Search",
user => user.user,
uploads => result.rows,
total => result.total,
matched_tags => result.matched_tags,
page => qs.page.unwrap_or(1),
page_size => PAGE_SIZE,
q => q.clone().unwrap_or_default(),
sources => serde_json::json!({
"filename": sources.filename,
"item_name": sources.item_name,
"description": sources.description,
"user_tags": sources.user_tags,
"ocr": sources.ocr,
"desc": sources.desc,
"other_ns": sources.other_ns,
}),
submitted => qs.f.is_some(),
})
.map_err(|e| { error!(error = %e, "render search.html failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
Ok(Html(rendered))
}
/// JSON sibling of `search_uploads` for the API surface. Same query params
/// (`q`, `f`, source toggles, `page`) so the Android client can hit the
/// same URL shape it builds for the web. Returns matched-tag info per row
/// so the client can render the same "matched on …" badge as the web.
pub async fn api_search_uploads(
State(state): State<AppState>,
user: UserContext,
Query(qs): Query<SearchQuery>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let sources = SearchSources::from_query(&qs);
let q = qs.q.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
let result = if !sources.any() || q.is_none() {
SearchResult { rows: Vec::new(), total: 0, matched_tags: Default::default() }
} else {
run_search(&state, &user, q.as_deref().unwrap(), &sources, qs.page.unwrap_or(1))
.await
.map_err(|e| { error!(error = %e, "api search query failed"); StatusCode::INTERNAL_SERVER_ERROR })?
};
let items: Vec<_> = result
.rows
.iter()
.map(|u| {
let matched: Vec<String> = result
.matched_tags
.get(&u.id)
.map(|ts| {
ts.iter()
.map(|t| match &t.namespace {
Some(ns) => format!("{}:{}", ns, t.tag_value),
None => t.tag_value.clone(),
})
.collect()
})
.unwrap_or_default();
serde_json::json!({
"id": u.id,
"file_name_original": u.file_name_original,
"item_path": u.item_path,
"processing_status": u.processing_status,
"matched_tags": matched,
})
})
.collect();
Ok(Json(serde_json::json!({
"total": result.total,
"page": qs.page.unwrap_or(1),
"page_size": PAGE_SIZE,
"q": q,
"sources": {
"filename": sources.filename,
"item_name": sources.item_name,
"description": sources.description,
"user_tags": sources.user_tags,
"ocr": sources.ocr,
"desc": sources.desc,
"other_ns": sources.other_ns,
},
"items": items,
})))
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub rows: Vec<uploads::Model>,
pub total: u64,
/// upload_id → tags that triggered the match (empty if the upload only
/// matched on a text field like file_name).
pub matched_tags: std::collections::HashMap<Uuid, Vec<tags::Model>>,
}
/// Public for integration tests; production code goes through `search_uploads`.
pub async fn run_search_for_user(
db: &DatabaseConnection,
user_id: Uuid,
q: &str,
sources: &SearchSources,
page: u64,
) -> Result<SearchResult, DbErr> {
run_search_inner(db, user_id, q, sources, page).await
}
async fn run_search(
state: &AppState,
user: &UserContext,
q: &str,
sources: &SearchSources,
page: u64,
) -> Result<SearchResult, DbErr> {
run_search_inner(state.db.as_ref(), user.user.id, q, sources, page).await
}
async fn run_search_inner(
db: &DatabaseConnection,
user_id: Uuid,
q: &str,
sources: &SearchSources,
page: u64,
) -> Result<SearchResult, DbErr> {
let spec = parse_search_spec(q);
debug!(?spec, ?sources, "search input");
// OR over the selected sources. Each source contributes a Condition node.
let mut any = Condition::any();
let mut matched_anything = false;
let mut matched_tag_models: Vec<tags::Model> = Vec::new();
let mut tag_ids_by_upload: std::collections::HashMap<Uuid, Vec<Uuid>> =
std::collections::HashMap::new();
// Namespace-wildcard queries (`species:*`) only target tags. Skipping the
// text columns avoids matching `LIKE '%species:*%'` against file names.
let text_searchable = !matches!(spec, SearchSpec::NsWildcard(_));
if text_searchable {
let pat = format!("%{}%", q.replace('%', "\\%"));
if sources.filename {
any = any.add(uploads::Column::FileNameOriginal.like(pat.clone()));
matched_anything = true;
}
if sources.item_name {
any = any.add(uploads::Column::ItemName.like(pat.clone()));
matched_anything = true;
}
if sources.description {
any = any.add(uploads::Column::ItemDescription.like(pat.clone()));
matched_anything = true;
}
}
// Tag-based sources: resolve tag IDs once, then use a single EXISTS over
// upload_tags. Combine all selected tag namespaces into one tag-id pool.
if sources.user_tags || sources.ocr || sources.desc || sources.other_ns {
let mut ns_cond = Condition::any();
if sources.user_tags {
ns_cond = ns_cond.add(tags::Column::Namespace.is_null());
}
if sources.ocr {
ns_cond = ns_cond.add(tags::Column::Namespace.eq("ocr"));
}
if sources.desc {
ns_cond = ns_cond.add(tags::Column::Namespace.eq("desc"));
}
if sources.other_ns {
// Any namespace that isn't already covered above. Always require
// the namespace to be non-null (otherwise it's a "user_tag").
let mut other = Condition::all().add(tags::Column::Namespace.is_not_null());
if !sources.ocr {
// ocr is off; including ocr here would defeat the user's choice
other = other.add(tags::Column::Namespace.ne("ocr"));
} else {
// ocr is already covered above; exclude here to avoid duplicate matches
other = other.add(tags::Column::Namespace.ne("ocr"));
}
if !sources.desc {
other = other.add(tags::Column::Namespace.ne("desc"));
} else {
other = other.add(tags::Column::Namespace.ne("desc"));
}
ns_cond = ns_cond.add(other);
}
// Build the tag query based on the parsed spec. The user-selected
// namespace checkboxes (ns_cond) always gate, so typing `ocr:foo`
// with the OCR box unchecked still won't surface ocr rows.
let mut tag_query = tags::Entity::find().filter(ns_cond);
match &spec {
SearchSpec::Value(v) => {
let pat = format!("%{}%", v.replace('%', "\\%"));
tag_query = tag_query.filter(tags::Column::TagValue.like(pat));
}
SearchSpec::NsValue { ns, val } => {
let pat = format!("%{}%", val.replace('%', "\\%"));
tag_query = tag_query
.filter(tags::Column::TagValue.like(pat))
.filter(tags::Column::Namespace.eq(ns.as_str()));
}
SearchSpec::NsWildcard(ns) => {
tag_query = tag_query.filter(tags::Column::Namespace.eq(ns.as_str()));
}
}
matched_tag_models = tag_query.all(db).await?;
debug!(
tag_count = matched_tag_models.len(),
sample = ?matched_tag_models.iter().take(5).map(|t| (t.namespace.clone(), t.tag_value.clone())).collect::<Vec<_>>(),
"tag matches before subquery"
);
let tag_ids: Vec<Uuid> = matched_tag_models.iter().map(|t| t.id).collect();
if !tag_ids.is_empty() {
// sea-orm's Expr::col(uploads::Column::Id) inside a subquery emits
// an unqualified "id" that resolves against the subquery's own
// FROM (upload_tags), so the correlation silently breaks. Avoid
// the foot-gun: resolve matching upload_ids up front and use IN.
let attaches = upload_tags::Entity::find()
.filter(upload_tags::Column::TagId.is_in(tag_ids.clone()))
.all(db)
.await?;
for a in &attaches {
tag_ids_by_upload.entry(a.upload_id).or_default().push(a.tag_id);
}
let upload_ids: Vec<Uuid> = tag_ids_by_upload.keys().copied().collect();
if !upload_ids.is_empty() {
any = any.add(uploads::Column::Id.is_in(upload_ids));
matched_anything = true;
}
}
}
if !matched_anything {
return Ok(SearchResult { rows: Vec::new(), total: 0, matched_tags: Default::default() });
}
let query = uploads::Entity::find()
.filter(uploads::Column::UserId.eq(user_id))
.filter(any);
let total = query.clone().count(db).await?;
let page = page.max(1);
let rows = query
.order_by_desc(uploads::Column::DateUploaded)
.paginate(db, PAGE_SIZE)
.fetch_page(page - 1)
.await?;
// Fan out matched_tag_models + tag_ids_by_upload into a per-upload tag list
// for just the rows on this page.
let tag_lookup: std::collections::HashMap<Uuid, tags::Model> = matched_tag_models
.into_iter()
.map(|t| (t.id, t))
.collect();
let mut matched_tags: std::collections::HashMap<Uuid, Vec<tags::Model>> =
std::collections::HashMap::new();
for row in &rows {
if let Some(ids) = tag_ids_by_upload.get(&row.id) {
let mut ts: Vec<tags::Model> = ids
.iter()
.filter_map(|id| tag_lookup.get(id).cloned())
.collect();
ts.sort_by(|a, b| {
(a.namespace.clone(), a.tag_value.clone())
.cmp(&(b.namespace.clone(), b.tag_value.clone()))
});
if !ts.is_empty() {
matched_tags.insert(row.id, ts);
}
}
}
Ok(SearchResult { rows, total, matched_tags })
}
async fn query_uploads(
state: &AppState,
user: &UserContext,
qs: &UploadQuery,
) -> Result<(Vec<uploads::Model>, u64), DbErr> {
let mut query = uploads::Entity::find().filter(uploads::Column::UserId.eq(user.user.id));
// Free-text on file name + item name + description
if let Some(q) = qs.q.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
let pat = format!("%{}%", q.replace('%', "\\%"));
query = query.filter(
Condition::any()
.add(uploads::Column::FileNameOriginal.like(pat.clone()))
.add(uploads::Column::ItemName.like(pat.clone()))
.add(uploads::Column::ItemDescription.like(pat)),
);
}
// Tag filtering — for each parsed tag, the upload must have a matching attach.
let parsed_tags = qs
.tags
.as_deref()
.map(tagsvc::parse_tags)
.unwrap_or_default();
let ns_filter = qs
.namespace
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
if !parsed_tags.is_empty() || ns_filter.is_some() {
// Resolve tag IDs once. An empty matched set with a tag filter present
// means "no results".
let mut tag_id_groups: Vec<Vec<Uuid>> = Vec::new();
for tag in &parsed_tags {
let mut q = tags::Entity::find().filter(tags::Column::TagValue.eq(&tag.value));
q = match &tag.namespace {
Some(ns) => q.filter(tags::Column::Namespace.eq(ns)),
None => q,
};
let ids: Vec<Uuid> = q.all(state.db.as_ref()).await?.into_iter().map(|t| t.id).collect();
if ids.is_empty() {
return Ok((Vec::new(), 0));
}
tag_id_groups.push(ids);
}
if let Some(ns) = ns_filter {
let ids: Vec<Uuid> = tags::Entity::find()
.filter(tags::Column::Namespace.eq(ns))
.all(state.db.as_ref())
.await?
.into_iter()
.map(|t| t.id)
.collect();
if ids.is_empty() {
return Ok((Vec::new(), 0));
}
tag_id_groups.push(ids);
}
// Each group must have at least one match. The correlated EXISTS
// form silently breaks with sea-orm (column refs don't qualify into
// the subquery's FROM), so resolve upload_id sets up front and AND
// them with IN. Slight extra round-trip; obviously correct.
for group in tag_id_groups {
let ids: Vec<Uuid> = upload_tags::Entity::find()
.filter(upload_tags::Column::TagId.is_in(group))
.all(state.db.as_ref())
.await?
.into_iter()
.map(|ut| ut.upload_id)
.collect();
if ids.is_empty() {
return Ok((Vec::new(), 0));
}
query = query.filter(uploads::Column::Id.is_in(ids));
}
}
let total = query.clone().count(state.db.as_ref()).await?;
let page = qs.page.unwrap_or(1).max(1);
let rows = query
.order_by_desc(uploads::Column::DateUploaded)
.paginate(state.db.as_ref(), PAGE_SIZE)
.fetch_page(page - 1)
.await?;
Ok((rows, total))
}
async fn load_tags_for_upload(
db: &DatabaseConnection,
upload_id: Uuid,
) -> Result<Vec<tags::Model>, DbErr> {
let attached = upload_tags::Entity::find()
.filter(upload_tags::Column::UploadId.eq(upload_id))
.find_also_related(tags::Entity)
.all(db)
.await?;
Ok(attached.into_iter().filter_map(|(_, t)| t).collect())
}
⭐
Log in to mark this repository.
43.7 KiB · text
History
94679c7