From 560e34ffbec12fad1e1988882a6f75b3ee61ef07 Mon Sep 17 00:00:00 2001 From: whatever-industries <77seventy77@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:02:29 -0700 Subject: [PATCH 1/5] Stabilize table layouts --- src/db/models.rs | 3 +- src/routes/discs.rs | 172 +++++++++++++++++++++++++++++++--- src/routes/queue.rs | 5 + src/services/queue_service.rs | 27 ++++++ static/css/app.css | 82 ++++++++++++++-- templates/discs.html | 6 +- templates/main.html | 11 ++- templates/queue.html | 8 +- 8 files changed, 285 insertions(+), 29 deletions(-) diff --git a/src/db/models.rs b/src/db/models.rs index 5c28739..a2136f2 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -519,7 +519,7 @@ pub struct DiscListRow { pub language_flags: Vec, } -#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct FlagInfo { pub code: String, pub name: String, @@ -1038,6 +1038,7 @@ pub struct SubmissionListRow { pub submission_type: SubmissionType, pub display_kind: SubmissionDisplayKind, pub title: String, + pub region_flags: Vec, pub system_code: String, pub system_display: String, pub submitter: String, diff --git a/src/routes/discs.rs b/src/routes/discs.rs index 90263a1..88a5421 100644 --- a/src/routes/discs.rs +++ b/src/routes/discs.rs @@ -90,6 +90,137 @@ fn quick_search_terms(input: &str) -> Vec { .collect() } +const LATIN_FOLD_PAIRS: &[(char, char)] = &[ + ('á', 'a'), + ('à', 'a'), + ('â', 'a'), + ('ã', 'a'), + ('ä', 'a'), + ('å', 'a'), + ('ā', 'a'), + ('ă', 'a'), + ('ą', 'a'), + ('ǎ', 'a'), + ('ạ', 'a'), + ('ả', 'a'), + ('ấ', 'a'), + ('ầ', 'a'), + ('ẩ', 'a'), + ('ẫ', 'a'), + ('ậ', 'a'), + ('ắ', 'a'), + ('ằ', 'a'), + ('ẳ', 'a'), + ('ẵ', 'a'), + ('ặ', 'a'), + ('æ', 'a'), + ('ç', 'c'), + ('ć', 'c'), + ('č', 'c'), + ('ď', 'd'), + ('đ', 'd'), + ('é', 'e'), + ('è', 'e'), + ('ê', 'e'), + ('ë', 'e'), + ('ē', 'e'), + ('ĕ', 'e'), + ('ė', 'e'), + ('ę', 'e'), + ('ě', 'e'), + ('ẹ', 'e'), + ('ẻ', 'e'), + ('ẽ', 'e'), + ('ế', 'e'), + ('ề', 'e'), + ('ể', 'e'), + ('ễ', 'e'), + ('ệ', 'e'), + ('í', 'i'), + ('ì', 'i'), + ('î', 'i'), + ('ï', 'i'), + ('ĩ', 'i'), + ('ī', 'i'), + ('ĭ', 'i'), + ('į', 'i'), + ('ı', 'i'), + ('ǐ', 'i'), + ('ị', 'i'), + ('ỉ', 'i'), + ('ñ', 'n'), + ('ń', 'n'), + ('ň', 'n'), + ('ņ', 'n'), + ('ó', 'o'), + ('ò', 'o'), + ('ô', 'o'), + ('õ', 'o'), + ('ö', 'o'), + ('ō', 'o'), + ('ŏ', 'o'), + ('ő', 'o'), + ('ơ', 'o'), + ('ǒ', 'o'), + ('ọ', 'o'), + ('ỏ', 'o'), + ('ố', 'o'), + ('ồ', 'o'), + ('ổ', 'o'), + ('ỗ', 'o'), + ('ộ', 'o'), + ('ớ', 'o'), + ('ờ', 'o'), + ('ở', 'o'), + ('ỡ', 'o'), + ('ợ', 'o'), + ('ø', 'o'), + ('œ', 'o'), + ('ŕ', 'r'), + ('ř', 'r'), + ('ś', 's'), + ('š', 's'), + ('ş', 's'), + ('ș', 's'), + ('ß', 's'), + ('ú', 'u'), + ('ù', 'u'), + ('û', 'u'), + ('ü', 'u'), + ('ũ', 'u'), + ('ū', 'u'), + ('ŭ', 'u'), + ('ů', 'u'), + ('ű', 'u'), + ('ų', 'u'), + ('ư', 'u'), + ('ǔ', 'u'), + ('ụ', 'u'), + ('ủ', 'u'), + ('ứ', 'u'), + ('ừ', 'u'), + ('ử', 'u'), + ('ữ', 'u'), + ('ự', 'u'), + ('ý', 'y'), + ('ỳ', 'y'), + ('ŷ', 'y'), + ('ÿ', 'y'), + ('ỹ', 'y'), + ('ȳ', 'y'), + ('ỵ', 'y'), + ('ỷ', 'y'), + ('ź', 'z'), + ('ż', 'z'), + ('ž', 'z'), +]; + +fn latin_fold_sql(expr: &str) -> String { + let from_chars: String = LATIN_FOLD_PAIRS.iter().map(|(from, _)| *from).collect(); + let to_chars: String = LATIN_FOLD_PAIRS.iter().map(|(_, to)| *to).collect(); + format!("TRANSLATE(LOWER(COALESCE(({expr})::text, '')), '{from_chars}', '{to_chars}')") +} + fn hash_field_for_term(term: &str) -> Option { if !term.chars().all(|c| c.is_ascii_hexdigit()) { return None; @@ -105,10 +236,14 @@ fn hash_field_for_term(term: &str) -> Option { fn quick_search_clause(bind_idx: u32, hash_field: Option) -> String { let bind = format!("${bind_idx}"); + let folded_bind = latin_fold_sql(&bind); let mut clause = format!( - r#"(LOWER(d.title) LIKE '%' || {bind} || '%' - OR LOWER(d.title_foreign) LIKE '%' || {bind} || '%' - OR LOWER(arr_to_str(d.serial, ' ')) LIKE '%' || {bind} || '%'"# + r#"({title} LIKE '%' || {folded_bind} || '%' + OR {foreign_title} LIKE '%' || {folded_bind} || '%' + OR {serial} LIKE '%' || {folded_bind} || '%'"#, + title = latin_fold_sql("d.title"), + foreign_title = latin_fold_sql("d.title_foreign"), + serial = latin_fold_sql("arr_to_str(d.serial, ' ')") ); if let Some(field) = hash_field { @@ -138,11 +273,19 @@ fn active_advanced_filter(value: Option<&String>) -> Option { } fn edition_search_clause(bind_idx: u32) -> String { - format!("LOWER(arr_to_str(d.edition, ' ')) LIKE '%' || LOWER(${bind_idx}) || '%'") + format!( + "{} LIKE '%' || {} || '%'", + latin_fold_sql("arr_to_str(d.edition, ' ')"), + latin_fold_sql(&format!("${bind_idx}")) + ) } fn comments_search_clause(bind_idx: u32) -> String { - format!("LOWER(d.comments) LIKE '%' || LOWER(${bind_idx}) || '%'") + format!( + "{} LIKE '%' || {} || '%'", + latin_fold_sql("d.comments"), + latin_fold_sql(&format!("${bind_idx}")) + ) } fn display_title_sort_sql() -> &'static str { @@ -881,9 +1024,10 @@ mod tests { fn quick_search_clause_for_text_terms_uses_only_indexed_title_foreign_title_and_serial() { let clause = quick_search_clause(3, None); - assert!(clause.contains("LOWER(d.title) LIKE")); - assert!(clause.contains("LOWER(d.title_foreign) LIKE")); - assert!(clause.contains("LOWER(arr_to_str(d.serial, ' ')) LIKE")); + assert!(clause.contains("TRANSLATE(LOWER(COALESCE((d.title)::text")); + assert!(clause.contains("TRANSLATE(LOWER(COALESCE((d.title_foreign)::text")); + assert!(clause.contains("TRANSLATE(LOWER(COALESCE((arr_to_str(d.serial, ' '))::text")); + assert!(clause.contains("TRANSLATE(LOWER(COALESCE(($3)::text")); assert!(!clause.contains("disc_title")); assert!(!clause.contains("barcode")); assert!(!clause.contains("FROM files")); @@ -918,14 +1062,12 @@ mod tests { let edition_clause = edition_search_clause(5); let comments_clause = comments_search_clause(6); - assert_eq!( - edition_clause, - "LOWER(arr_to_str(d.edition, ' ')) LIKE '%' || LOWER($5) || '%'" - ); - assert_eq!( - comments_clause, - "LOWER(d.comments) LIKE '%' || LOWER($6) || '%'" + assert!( + edition_clause.contains("TRANSLATE(LOWER(COALESCE((arr_to_str(d.edition, ' '))::text") ); + assert!(edition_clause.contains("TRANSLATE(LOWER(COALESCE(($5)::text")); + assert!(comments_clause.contains("TRANSLATE(LOWER(COALESCE((d.comments)::text")); + assert!(comments_clause.contains("TRANSLATE(LOWER(COALESCE(($6)::text")); } #[test] diff --git a/src/routes/queue.rs b/src/routes/queue.rs index aed96c6..a825965 100644 --- a/src/routes/queue.rs +++ b/src/routes/queue.rs @@ -2255,6 +2255,7 @@ mod tests { submission_type, display_kind, title: "Test Game".to_string(), + region_flags: Vec::new(), system_code: "DVD".to_string(), system_display: "DVD".to_string(), submitter: "submitter".to_string(), @@ -2863,6 +2864,10 @@ impl sqlx::FromRow<'_, sqlx::postgres::PgRow> for SubmissionListRow { submission_has_dat_add, ), title: row.try_get("title")?, + region_flags: serde_json::from_value( + row.try_get::("region_flags")?, + ) + .unwrap_or_default(), system_code, system_display, submitter: row.try_get("submitter")?, diff --git a/src/services/queue_service.rs b/src/services/queue_service.rs index a87d7e3..95d28fe 100644 --- a/src/services/queue_service.rs +++ b/src/services/queue_service.rs @@ -1390,6 +1390,7 @@ pub async fn list_submissions( "SELECT ds.id, ds.submission_type, {dat_add_expr} AS submission_has_dat_add, {title_expr} AS title, + region_flags.region_flags, COALESCE(d.system_code, ds.changes->'system_code'->'add'->>'new', ds.changes->'system_code'->'modify'->>'new', '') AS system_code, COALESCE(s.short_name, '') AS system_short_name, u.username AS submitter, @@ -1405,6 +1406,32 @@ pub async fn list_submissions( LEFT JOIN discs d ON d.id = ds.target_disc_id LEFT JOIN systems s ON s.code = COALESCE(d.system_code, ds.changes->'system_code'->'add'->>'new', ds.changes->'system_code'->'modify'->>'new') + LEFT JOIN LATERAL ( + SELECT COALESCE( + jsonb_agg(jsonb_build_object('code', r.flag_code, 'name', r.name) ORDER BY r.sort_order), + '[]'::jsonb + ) AS region_flags + FROM regions r + JOIN ( + SELECT DISTINCT region_code + FROM ( + SELECT jsonb_array_elements_text(ds.changes->'regions') AS region_code + WHERE jsonb_typeof(ds.changes->'regions') = 'array' + UNION + SELECT dr.region_code + FROM disc_regions dr + WHERE dr.disc_id = ds.target_disc_id + AND COALESCE(jsonb_typeof(ds.changes->'regions'), '') <> 'array' + UNION + SELECT jsonb_array_elements_text(ds.changes->'regions'->'add') AS region_code + WHERE jsonb_typeof(ds.changes->'regions'->'add') = 'array' + ) region_candidates + WHERE region_code NOT IN ( + SELECT jsonb_array_elements_text(ds.changes->'regions'->'remove') + WHERE jsonb_typeof(ds.changes->'regions'->'remove') = 'array' + ) + ) effective_regions ON effective_regions.region_code = r.code + ) region_flags ON TRUE WHERE {} ORDER BY {sort_col} {sort_dir}{nulls_order} LIMIT {page_size} OFFSET {offset}", diff --git a/static/css/app.css b/static/css/app.css index bb4f4de..7668446 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -269,8 +269,14 @@ main > h3 { } .disc-table .language-col { - text-align: left; - max-width: 8.8em; + width: 5.75rem; + max-width: 5.75rem; + white-space: normal; + line-height: 1; +} + +.disc-table td.language-col { + text-align: center; } .disc-table td.region-col { @@ -289,14 +295,35 @@ main > h3 { /* Ellipsis truncation for Edition and Serial */ .disc-table .truncate { - max-width: 10rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.disc-table .version-col { max-width: 7rem; } -.disc-table .edition-col { max-width: 7.5rem; } -.disc-table .serial-col { max-width: 7rem; } +.disc-table .system-col { + width: 6rem; + max-width: 6rem; +} +.disc-table .version-col { + width: 5.25rem; + max-width: 5.25rem; + padding-right: 0.25rem; +} +.disc-table .edition-col { + width: 7.5rem; + max-width: 7.5rem; + padding-left: 0.25rem; +} +.disc-table .serial-col { + width: 7rem; + max-width: 7rem; + padding-right: 0; +} +.disc-table .status-col { + width: 4.3rem; + max-width: 4.3rem; + padding-left: 0; + padding-right: 0; +} /* Striped table rows — matches home page alternating row style */ table.striped tbody tr:nth-child(odd) td { @@ -410,9 +437,21 @@ details + p small { font-size: 0.8rem; } -/* Center pagination */ +/* Align pagination with the left edge of its result table. */ +nav[aria-label="Pagination"] { + width: 100%; +} +nav[aria-label="Pagination"] ul { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + width: 100%; + margin-left: 0; + margin-right: 0; + padding-left: 0; +} #disc-results nav[aria-label="Pagination"] ul { - justify-content: center; + justify-content: flex-start; } #disc-results nav[aria-label="Pagination"] ul li, #disc-results nav[aria-label="Pagination"] ul li a { @@ -524,6 +563,16 @@ details + p small { max-width: 100%; } +#filter-form.queue-filter-form { + grid-template-columns: max-content; + width: fit-content; + max-width: 100%; +} + +#filter-form.queue-filter-form label { + grid-template-columns: 5rem max-content; +} + #filter-form > .advanced-search-toggle { -webkit-appearance: none; appearance: none; @@ -688,6 +737,7 @@ details + p small { margin-right: auto; margin-top: 0; margin-bottom: 0.35rem; + padding-top: 0.25rem; } .letter-picker a { display: inline-flex; @@ -1266,6 +1316,7 @@ details + p small { grid-template-columns: minmax(0, 1fr); align-items: center; gap: 0.5rem; + margin-top: 0.75rem; margin-bottom: 2.5rem; } .disc-title-row.has-title-action { @@ -2223,6 +2274,17 @@ table.striped tbody tr.clickable-row.is-row-hover td { } /* Queue status dots */ +#queue-results { + width: max-content; + max-width: 100%; + margin-left: auto; + margin-right: auto; +} + +#queue-results > nav[aria-label="Pagination"] { + width: 100%; +} + .queue-table-scroll { width: 100%; text-align: center; @@ -2242,6 +2304,10 @@ table.striped tbody tr.clickable-row.is-row-hover td { padding-inline-end: 0.75rem; } +.queue-table .submission-title-col { + padding-inline-end: 1.5rem; +} + .queue-table th:last-child, .queue-table td:last-child { padding-inline-end: 0.35rem; diff --git a/templates/discs.html b/templates/discs.html index dc4d74d..dc866b0 100644 --- a/templates/discs.html +++ b/templates/discs.html @@ -139,10 +139,10 @@

Disc Database

Region{% if sort_column == "region" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} Title{% if sort_column == "title" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} System{% if sort_column == "system" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} - Version{% if sort_column == "version" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} - Edition{% if sort_column == "edition" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} + Version{% if sort_column == "version" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} + Edition{% if sort_column == "edition" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} Language{% if sort_column == "language" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} - Serial{% if sort_column == "serial" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} + Serial{% if sort_column == "serial" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} Status{% if sort_column == "status" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} diff --git a/templates/main.html b/templates/main.html index 0f2c88f..ca04a50 100644 --- a/templates/main.html +++ b/templates/main.html @@ -108,15 +108,21 @@

{{ item.title }}

{% endblock %} diff --git a/templates/queue.html b/templates/queue.html index 6e47e31..d3dae18 100644 --- a/templates/queue.html +++ b/templates/queue.html @@ -88,6 +88,7 @@

{{ page_title }}

Date{% if sort_column == "date" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} + Region Type{% if sort_column == "type" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} Submission Title{% if sort_column == "title" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} Disc ID{% if sort_column == "disc_id" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} @@ -101,6 +102,11 @@

{{ page_title }}

{% for entry in entries %} {{ entry.created_at.format("%Y-%m-%d %H:%M") }} + + {% for rf in entry.region_flags %} + {{ rf.name }} + {% endfor %} + {% if self.can_open_entry(entry) %}{{ entry.title }}{% else %}{{ entry.title }}{% endif %} {% match entry.target_disc_id %}{% when Some with (disc_id) %}{{ disc_id }}{% when None %}{% endmatch %} @@ -111,7 +117,7 @@

{{ page_title }}

{% endfor %} {% if entries.is_empty() %} - No submissions found. + No submissions found. {% endif %} From 0b71e9de7e2589a56afeab1e61522d19a2c08893 Mon Sep 17 00:00:00 2001 From: whatever-industries <77seventy77@users.noreply.github.com> Date: Sat, 27 Jun 2026 09:02:38 -0700 Subject: [PATCH 2/5] Use any_ascii for search folding --- src/routes/discs.rs | 226 ++++++++++++++++++++------------------------ 1 file changed, 100 insertions(+), 126 deletions(-) diff --git a/src/routes/discs.rs b/src/routes/discs.rs index 88a5421..c101d55 100644 --- a/src/routes/discs.rs +++ b/src/routes/discs.rs @@ -6,6 +6,7 @@ use axum::{ Router, }; use serde::Deserialize; +use std::sync::OnceLock; use crate::auth::middleware::{AuthenticatedUser, CurrentUser}; use crate::config::SiteConfig; @@ -90,134 +91,80 @@ fn quick_search_terms(input: &str) -> Vec { .collect() } -const LATIN_FOLD_PAIRS: &[(char, char)] = &[ - ('á', 'a'), - ('à', 'a'), - ('â', 'a'), - ('ã', 'a'), - ('ä', 'a'), - ('å', 'a'), - ('ā', 'a'), - ('ă', 'a'), - ('ą', 'a'), - ('ǎ', 'a'), - ('ạ', 'a'), - ('ả', 'a'), - ('ấ', 'a'), - ('ầ', 'a'), - ('ẩ', 'a'), - ('ẫ', 'a'), - ('ậ', 'a'), - ('ắ', 'a'), - ('ằ', 'a'), - ('ẳ', 'a'), - ('ẵ', 'a'), - ('ặ', 'a'), - ('æ', 'a'), - ('ç', 'c'), - ('ć', 'c'), - ('č', 'c'), - ('ď', 'd'), - ('đ', 'd'), - ('é', 'e'), - ('è', 'e'), - ('ê', 'e'), - ('ë', 'e'), - ('ē', 'e'), - ('ĕ', 'e'), - ('ė', 'e'), - ('ę', 'e'), - ('ě', 'e'), - ('ẹ', 'e'), - ('ẻ', 'e'), - ('ẽ', 'e'), - ('ế', 'e'), - ('ề', 'e'), - ('ể', 'e'), - ('ễ', 'e'), - ('ệ', 'e'), - ('í', 'i'), - ('ì', 'i'), - ('î', 'i'), - ('ï', 'i'), - ('ĩ', 'i'), - ('ī', 'i'), - ('ĭ', 'i'), - ('į', 'i'), - ('ı', 'i'), - ('ǐ', 'i'), - ('ị', 'i'), - ('ỉ', 'i'), - ('ñ', 'n'), - ('ń', 'n'), - ('ň', 'n'), - ('ņ', 'n'), - ('ó', 'o'), - ('ò', 'o'), - ('ô', 'o'), - ('õ', 'o'), - ('ö', 'o'), - ('ō', 'o'), - ('ŏ', 'o'), - ('ő', 'o'), - ('ơ', 'o'), - ('ǒ', 'o'), - ('ọ', 'o'), - ('ỏ', 'o'), - ('ố', 'o'), - ('ồ', 'o'), - ('ổ', 'o'), - ('ỗ', 'o'), - ('ộ', 'o'), - ('ớ', 'o'), - ('ờ', 'o'), - ('ở', 'o'), - ('ỡ', 'o'), - ('ợ', 'o'), - ('ø', 'o'), - ('œ', 'o'), - ('ŕ', 'r'), - ('ř', 'r'), - ('ś', 's'), - ('š', 's'), - ('ş', 's'), - ('ș', 's'), - ('ß', 's'), - ('ú', 'u'), - ('ù', 'u'), - ('û', 'u'), - ('ü', 'u'), - ('ũ', 'u'), - ('ū', 'u'), - ('ŭ', 'u'), - ('ů', 'u'), - ('ű', 'u'), - ('ų', 'u'), - ('ư', 'u'), - ('ǔ', 'u'), - ('ụ', 'u'), - ('ủ', 'u'), - ('ứ', 'u'), - ('ừ', 'u'), - ('ử', 'u'), - ('ữ', 'u'), - ('ự', 'u'), - ('ý', 'y'), - ('ỳ', 'y'), - ('ŷ', 'y'), - ('ÿ', 'y'), - ('ỹ', 'y'), - ('ȳ', 'y'), - ('ỵ', 'y'), - ('ỷ', 'y'), - ('ź', 'z'), - ('ż', 'z'), - ('ž', 'z'), -]; +struct LatinFoldSqlMap { + from_chars: String, + to_chars: String, +} + +static LATIN_FOLD_SQL_MAP: OnceLock = OnceLock::new(); + +fn latin_fold_sql_map() -> &'static LatinFoldSqlMap { + LATIN_FOLD_SQL_MAP.get_or_init(build_latin_fold_sql_map) +} + +fn build_latin_fold_sql_map() -> LatinFoldSqlMap { + let mut mapped_chars = Vec::new(); + let mut deleted_chars = String::new(); + + for ch in latin_fold_candidate_chars() { + let Some(lower) = single_lowercase_char(ch) else { + continue; + }; + if lower.is_ascii() { + continue; + } + + let ascii = any_ascii::any_ascii_char(lower).to_ascii_lowercase(); + if let Some(replacement) = ascii.chars().find(|c| c.is_ascii_alphanumeric()) { + mapped_chars.push((lower, replacement)); + } else if is_combining_mark(lower) { + deleted_chars.push(lower); + } + } + + mapped_chars.sort_unstable_by_key(|(from, _)| *from); + mapped_chars.dedup_by_key(|(from, _)| *from); + + let mut from_chars: String = mapped_chars.iter().map(|(from, _)| *from).collect(); + let to_chars: String = mapped_chars.iter().map(|(_, to)| *to).collect(); + from_chars.push_str(&deleted_chars); + + LatinFoldSqlMap { + from_chars, + to_chars, + } +} + +fn latin_fold_candidate_chars() -> impl Iterator { + [ + 0x00c0..=0x024f, // Latin-1 Supplement, Latin Extended-A/B + 0x0300..=0x036f, // Combining Diacritical Marks + 0x1e00..=0x1eff, // Latin Extended Additional + ] + .into_iter() + .flatten() + .filter_map(char::from_u32) +} + +fn single_lowercase_char(ch: char) -> Option { + let mut lower = ch.to_lowercase(); + let first = lower.next()?; + if lower.next().is_none() { + Some(first) + } else { + None + } +} + +fn is_combining_mark(ch: char) -> bool { + matches!(ch as u32, 0x0300..=0x036f) +} fn latin_fold_sql(expr: &str) -> String { - let from_chars: String = LATIN_FOLD_PAIRS.iter().map(|(from, _)| *from).collect(); - let to_chars: String = LATIN_FOLD_PAIRS.iter().map(|(_, to)| *to).collect(); + let LatinFoldSqlMap { + from_chars, + to_chars, + } = latin_fold_sql_map(); format!("TRANSLATE(LOWER(COALESCE(({expr})::text, '')), '{from_chars}', '{to_chars}')") } @@ -1033,6 +980,33 @@ mod tests { assert!(!clause.contains("FROM files")); } + #[test] + fn latin_fold_sql_map_is_generated_from_any_ascii() { + let map = build_latin_fold_sql_map(); + + assert_eq!(latin_fold_replacement(&map, 'é'), Some('e')); + assert_eq!(latin_fold_replacement(&map, 'ệ'), Some('e')); + assert_eq!(latin_fold_replacement(&map, 'ñ'), Some('n')); + assert_eq!(latin_fold_replacement(&map, 'ø'), Some('o')); + assert_eq!(latin_fold_replacement(&map, 'ß'), Some('s')); + assert_eq!(latin_fold_replacement(&map, 'œ'), Some('o')); + assert!(latin_fold_deletes(&map, '\u{0301}')); + } + + fn latin_fold_replacement(map: &LatinFoldSqlMap, ch: char) -> Option { + map.from_chars + .chars() + .position(|candidate| candidate == ch) + .and_then(|idx| map.to_chars.chars().nth(idx)) + } + + fn latin_fold_deletes(map: &LatinFoldSqlMap, ch: char) -> bool { + map.from_chars + .chars() + .position(|candidate| candidate == ch) + .is_some_and(|idx| idx >= map.to_chars.chars().count()) + } + #[test] fn quick_search_clause_for_hash_terms_adds_exact_file_hash_lookup() { let clause = quick_search_clause(4, Some(HashField::Sha1)); From 56b22646a0271f4ec1535636967438e7cef8c693 Mon Sep 17 00:00:00 2001 From: whatever-industries <77seventy77@users.noreply.github.com> Date: Sat, 27 Jun 2026 09:39:12 -0700 Subject: [PATCH 3/5] Revert "Use any_ascii for search folding" This reverts commit 0b71e9de7e2589a56afeab1e61522d19a2c08893. --- src/routes/discs.rs | 226 ++++++++++++++++++++++++-------------------- 1 file changed, 126 insertions(+), 100 deletions(-) diff --git a/src/routes/discs.rs b/src/routes/discs.rs index c101d55..88a5421 100644 --- a/src/routes/discs.rs +++ b/src/routes/discs.rs @@ -6,7 +6,6 @@ use axum::{ Router, }; use serde::Deserialize; -use std::sync::OnceLock; use crate::auth::middleware::{AuthenticatedUser, CurrentUser}; use crate::config::SiteConfig; @@ -91,80 +90,134 @@ fn quick_search_terms(input: &str) -> Vec { .collect() } -struct LatinFoldSqlMap { - from_chars: String, - to_chars: String, -} - -static LATIN_FOLD_SQL_MAP: OnceLock = OnceLock::new(); - -fn latin_fold_sql_map() -> &'static LatinFoldSqlMap { - LATIN_FOLD_SQL_MAP.get_or_init(build_latin_fold_sql_map) -} - -fn build_latin_fold_sql_map() -> LatinFoldSqlMap { - let mut mapped_chars = Vec::new(); - let mut deleted_chars = String::new(); - - for ch in latin_fold_candidate_chars() { - let Some(lower) = single_lowercase_char(ch) else { - continue; - }; - if lower.is_ascii() { - continue; - } - - let ascii = any_ascii::any_ascii_char(lower).to_ascii_lowercase(); - if let Some(replacement) = ascii.chars().find(|c| c.is_ascii_alphanumeric()) { - mapped_chars.push((lower, replacement)); - } else if is_combining_mark(lower) { - deleted_chars.push(lower); - } - } - - mapped_chars.sort_unstable_by_key(|(from, _)| *from); - mapped_chars.dedup_by_key(|(from, _)| *from); - - let mut from_chars: String = mapped_chars.iter().map(|(from, _)| *from).collect(); - let to_chars: String = mapped_chars.iter().map(|(_, to)| *to).collect(); - from_chars.push_str(&deleted_chars); - - LatinFoldSqlMap { - from_chars, - to_chars, - } -} - -fn latin_fold_candidate_chars() -> impl Iterator { - [ - 0x00c0..=0x024f, // Latin-1 Supplement, Latin Extended-A/B - 0x0300..=0x036f, // Combining Diacritical Marks - 0x1e00..=0x1eff, // Latin Extended Additional - ] - .into_iter() - .flatten() - .filter_map(char::from_u32) -} - -fn single_lowercase_char(ch: char) -> Option { - let mut lower = ch.to_lowercase(); - let first = lower.next()?; - if lower.next().is_none() { - Some(first) - } else { - None - } -} - -fn is_combining_mark(ch: char) -> bool { - matches!(ch as u32, 0x0300..=0x036f) -} +const LATIN_FOLD_PAIRS: &[(char, char)] = &[ + ('á', 'a'), + ('à', 'a'), + ('â', 'a'), + ('ã', 'a'), + ('ä', 'a'), + ('å', 'a'), + ('ā', 'a'), + ('ă', 'a'), + ('ą', 'a'), + ('ǎ', 'a'), + ('ạ', 'a'), + ('ả', 'a'), + ('ấ', 'a'), + ('ầ', 'a'), + ('ẩ', 'a'), + ('ẫ', 'a'), + ('ậ', 'a'), + ('ắ', 'a'), + ('ằ', 'a'), + ('ẳ', 'a'), + ('ẵ', 'a'), + ('ặ', 'a'), + ('æ', 'a'), + ('ç', 'c'), + ('ć', 'c'), + ('č', 'c'), + ('ď', 'd'), + ('đ', 'd'), + ('é', 'e'), + ('è', 'e'), + ('ê', 'e'), + ('ë', 'e'), + ('ē', 'e'), + ('ĕ', 'e'), + ('ė', 'e'), + ('ę', 'e'), + ('ě', 'e'), + ('ẹ', 'e'), + ('ẻ', 'e'), + ('ẽ', 'e'), + ('ế', 'e'), + ('ề', 'e'), + ('ể', 'e'), + ('ễ', 'e'), + ('ệ', 'e'), + ('í', 'i'), + ('ì', 'i'), + ('î', 'i'), + ('ï', 'i'), + ('ĩ', 'i'), + ('ī', 'i'), + ('ĭ', 'i'), + ('į', 'i'), + ('ı', 'i'), + ('ǐ', 'i'), + ('ị', 'i'), + ('ỉ', 'i'), + ('ñ', 'n'), + ('ń', 'n'), + ('ň', 'n'), + ('ņ', 'n'), + ('ó', 'o'), + ('ò', 'o'), + ('ô', 'o'), + ('õ', 'o'), + ('ö', 'o'), + ('ō', 'o'), + ('ŏ', 'o'), + ('ő', 'o'), + ('ơ', 'o'), + ('ǒ', 'o'), + ('ọ', 'o'), + ('ỏ', 'o'), + ('ố', 'o'), + ('ồ', 'o'), + ('ổ', 'o'), + ('ỗ', 'o'), + ('ộ', 'o'), + ('ớ', 'o'), + ('ờ', 'o'), + ('ở', 'o'), + ('ỡ', 'o'), + ('ợ', 'o'), + ('ø', 'o'), + ('œ', 'o'), + ('ŕ', 'r'), + ('ř', 'r'), + ('ś', 's'), + ('š', 's'), + ('ş', 's'), + ('ș', 's'), + ('ß', 's'), + ('ú', 'u'), + ('ù', 'u'), + ('û', 'u'), + ('ü', 'u'), + ('ũ', 'u'), + ('ū', 'u'), + ('ŭ', 'u'), + ('ů', 'u'), + ('ű', 'u'), + ('ų', 'u'), + ('ư', 'u'), + ('ǔ', 'u'), + ('ụ', 'u'), + ('ủ', 'u'), + ('ứ', 'u'), + ('ừ', 'u'), + ('ử', 'u'), + ('ữ', 'u'), + ('ự', 'u'), + ('ý', 'y'), + ('ỳ', 'y'), + ('ŷ', 'y'), + ('ÿ', 'y'), + ('ỹ', 'y'), + ('ȳ', 'y'), + ('ỵ', 'y'), + ('ỷ', 'y'), + ('ź', 'z'), + ('ż', 'z'), + ('ž', 'z'), +]; fn latin_fold_sql(expr: &str) -> String { - let LatinFoldSqlMap { - from_chars, - to_chars, - } = latin_fold_sql_map(); + let from_chars: String = LATIN_FOLD_PAIRS.iter().map(|(from, _)| *from).collect(); + let to_chars: String = LATIN_FOLD_PAIRS.iter().map(|(_, to)| *to).collect(); format!("TRANSLATE(LOWER(COALESCE(({expr})::text, '')), '{from_chars}', '{to_chars}')") } @@ -980,33 +1033,6 @@ mod tests { assert!(!clause.contains("FROM files")); } - #[test] - fn latin_fold_sql_map_is_generated_from_any_ascii() { - let map = build_latin_fold_sql_map(); - - assert_eq!(latin_fold_replacement(&map, 'é'), Some('e')); - assert_eq!(latin_fold_replacement(&map, 'ệ'), Some('e')); - assert_eq!(latin_fold_replacement(&map, 'ñ'), Some('n')); - assert_eq!(latin_fold_replacement(&map, 'ø'), Some('o')); - assert_eq!(latin_fold_replacement(&map, 'ß'), Some('s')); - assert_eq!(latin_fold_replacement(&map, 'œ'), Some('o')); - assert!(latin_fold_deletes(&map, '\u{0301}')); - } - - fn latin_fold_replacement(map: &LatinFoldSqlMap, ch: char) -> Option { - map.from_chars - .chars() - .position(|candidate| candidate == ch) - .and_then(|idx| map.to_chars.chars().nth(idx)) - } - - fn latin_fold_deletes(map: &LatinFoldSqlMap, ch: char) -> bool { - map.from_chars - .chars() - .position(|candidate| candidate == ch) - .is_some_and(|idx| idx >= map.to_chars.chars().count()) - } - #[test] fn quick_search_clause_for_hash_terms_adds_exact_file_hash_lookup() { let clause = quick_search_clause(4, Some(HashField::Sha1)); From 809888042382b9ee146b653c61e711e26ac48a1e Mon Sep 17 00:00:00 2001 From: whatever-industries <77seventy77@users.noreply.github.com> Date: Sat, 27 Jun 2026 09:41:13 -0700 Subject: [PATCH 4/5] Drop search query folding changes --- src/routes/discs.rs | 172 ++++---------------------------------------- 1 file changed, 15 insertions(+), 157 deletions(-) diff --git a/src/routes/discs.rs b/src/routes/discs.rs index 88a5421..90263a1 100644 --- a/src/routes/discs.rs +++ b/src/routes/discs.rs @@ -90,137 +90,6 @@ fn quick_search_terms(input: &str) -> Vec { .collect() } -const LATIN_FOLD_PAIRS: &[(char, char)] = &[ - ('á', 'a'), - ('à', 'a'), - ('â', 'a'), - ('ã', 'a'), - ('ä', 'a'), - ('å', 'a'), - ('ā', 'a'), - ('ă', 'a'), - ('ą', 'a'), - ('ǎ', 'a'), - ('ạ', 'a'), - ('ả', 'a'), - ('ấ', 'a'), - ('ầ', 'a'), - ('ẩ', 'a'), - ('ẫ', 'a'), - ('ậ', 'a'), - ('ắ', 'a'), - ('ằ', 'a'), - ('ẳ', 'a'), - ('ẵ', 'a'), - ('ặ', 'a'), - ('æ', 'a'), - ('ç', 'c'), - ('ć', 'c'), - ('č', 'c'), - ('ď', 'd'), - ('đ', 'd'), - ('é', 'e'), - ('è', 'e'), - ('ê', 'e'), - ('ë', 'e'), - ('ē', 'e'), - ('ĕ', 'e'), - ('ė', 'e'), - ('ę', 'e'), - ('ě', 'e'), - ('ẹ', 'e'), - ('ẻ', 'e'), - ('ẽ', 'e'), - ('ế', 'e'), - ('ề', 'e'), - ('ể', 'e'), - ('ễ', 'e'), - ('ệ', 'e'), - ('í', 'i'), - ('ì', 'i'), - ('î', 'i'), - ('ï', 'i'), - ('ĩ', 'i'), - ('ī', 'i'), - ('ĭ', 'i'), - ('į', 'i'), - ('ı', 'i'), - ('ǐ', 'i'), - ('ị', 'i'), - ('ỉ', 'i'), - ('ñ', 'n'), - ('ń', 'n'), - ('ň', 'n'), - ('ņ', 'n'), - ('ó', 'o'), - ('ò', 'o'), - ('ô', 'o'), - ('õ', 'o'), - ('ö', 'o'), - ('ō', 'o'), - ('ŏ', 'o'), - ('ő', 'o'), - ('ơ', 'o'), - ('ǒ', 'o'), - ('ọ', 'o'), - ('ỏ', 'o'), - ('ố', 'o'), - ('ồ', 'o'), - ('ổ', 'o'), - ('ỗ', 'o'), - ('ộ', 'o'), - ('ớ', 'o'), - ('ờ', 'o'), - ('ở', 'o'), - ('ỡ', 'o'), - ('ợ', 'o'), - ('ø', 'o'), - ('œ', 'o'), - ('ŕ', 'r'), - ('ř', 'r'), - ('ś', 's'), - ('š', 's'), - ('ş', 's'), - ('ș', 's'), - ('ß', 's'), - ('ú', 'u'), - ('ù', 'u'), - ('û', 'u'), - ('ü', 'u'), - ('ũ', 'u'), - ('ū', 'u'), - ('ŭ', 'u'), - ('ů', 'u'), - ('ű', 'u'), - ('ų', 'u'), - ('ư', 'u'), - ('ǔ', 'u'), - ('ụ', 'u'), - ('ủ', 'u'), - ('ứ', 'u'), - ('ừ', 'u'), - ('ử', 'u'), - ('ữ', 'u'), - ('ự', 'u'), - ('ý', 'y'), - ('ỳ', 'y'), - ('ŷ', 'y'), - ('ÿ', 'y'), - ('ỹ', 'y'), - ('ȳ', 'y'), - ('ỵ', 'y'), - ('ỷ', 'y'), - ('ź', 'z'), - ('ż', 'z'), - ('ž', 'z'), -]; - -fn latin_fold_sql(expr: &str) -> String { - let from_chars: String = LATIN_FOLD_PAIRS.iter().map(|(from, _)| *from).collect(); - let to_chars: String = LATIN_FOLD_PAIRS.iter().map(|(_, to)| *to).collect(); - format!("TRANSLATE(LOWER(COALESCE(({expr})::text, '')), '{from_chars}', '{to_chars}')") -} - fn hash_field_for_term(term: &str) -> Option { if !term.chars().all(|c| c.is_ascii_hexdigit()) { return None; @@ -236,14 +105,10 @@ fn hash_field_for_term(term: &str) -> Option { fn quick_search_clause(bind_idx: u32, hash_field: Option) -> String { let bind = format!("${bind_idx}"); - let folded_bind = latin_fold_sql(&bind); let mut clause = format!( - r#"({title} LIKE '%' || {folded_bind} || '%' - OR {foreign_title} LIKE '%' || {folded_bind} || '%' - OR {serial} LIKE '%' || {folded_bind} || '%'"#, - title = latin_fold_sql("d.title"), - foreign_title = latin_fold_sql("d.title_foreign"), - serial = latin_fold_sql("arr_to_str(d.serial, ' ')") + r#"(LOWER(d.title) LIKE '%' || {bind} || '%' + OR LOWER(d.title_foreign) LIKE '%' || {bind} || '%' + OR LOWER(arr_to_str(d.serial, ' ')) LIKE '%' || {bind} || '%'"# ); if let Some(field) = hash_field { @@ -273,19 +138,11 @@ fn active_advanced_filter(value: Option<&String>) -> Option { } fn edition_search_clause(bind_idx: u32) -> String { - format!( - "{} LIKE '%' || {} || '%'", - latin_fold_sql("arr_to_str(d.edition, ' ')"), - latin_fold_sql(&format!("${bind_idx}")) - ) + format!("LOWER(arr_to_str(d.edition, ' ')) LIKE '%' || LOWER(${bind_idx}) || '%'") } fn comments_search_clause(bind_idx: u32) -> String { - format!( - "{} LIKE '%' || {} || '%'", - latin_fold_sql("d.comments"), - latin_fold_sql(&format!("${bind_idx}")) - ) + format!("LOWER(d.comments) LIKE '%' || LOWER(${bind_idx}) || '%'") } fn display_title_sort_sql() -> &'static str { @@ -1024,10 +881,9 @@ mod tests { fn quick_search_clause_for_text_terms_uses_only_indexed_title_foreign_title_and_serial() { let clause = quick_search_clause(3, None); - assert!(clause.contains("TRANSLATE(LOWER(COALESCE((d.title)::text")); - assert!(clause.contains("TRANSLATE(LOWER(COALESCE((d.title_foreign)::text")); - assert!(clause.contains("TRANSLATE(LOWER(COALESCE((arr_to_str(d.serial, ' '))::text")); - assert!(clause.contains("TRANSLATE(LOWER(COALESCE(($3)::text")); + assert!(clause.contains("LOWER(d.title) LIKE")); + assert!(clause.contains("LOWER(d.title_foreign) LIKE")); + assert!(clause.contains("LOWER(arr_to_str(d.serial, ' ')) LIKE")); assert!(!clause.contains("disc_title")); assert!(!clause.contains("barcode")); assert!(!clause.contains("FROM files")); @@ -1062,12 +918,14 @@ mod tests { let edition_clause = edition_search_clause(5); let comments_clause = comments_search_clause(6); - assert!( - edition_clause.contains("TRANSLATE(LOWER(COALESCE((arr_to_str(d.edition, ' '))::text") + assert_eq!( + edition_clause, + "LOWER(arr_to_str(d.edition, ' ')) LIKE '%' || LOWER($5) || '%'" + ); + assert_eq!( + comments_clause, + "LOWER(d.comments) LIKE '%' || LOWER($6) || '%'" ); - assert!(edition_clause.contains("TRANSLATE(LOWER(COALESCE(($5)::text")); - assert!(comments_clause.contains("TRANSLATE(LOWER(COALESCE((d.comments)::text")); - assert!(comments_clause.contains("TRANSLATE(LOWER(COALESCE(($6)::text")); } #[test] From 8b72d72743214959280b74799728c92a715017ab Mon Sep 17 00:00:00 2001 From: whatever-industries <77seventy77@users.noreply.github.com> Date: Sat, 27 Jun 2026 09:43:33 -0700 Subject: [PATCH 5/5] Drop queue query-backed region column --- src/db/models.rs | 3 +-- src/routes/queue.rs | 5 ----- src/services/queue_service.rs | 27 --------------------------- templates/queue.html | 8 +------- 4 files changed, 2 insertions(+), 41 deletions(-) diff --git a/src/db/models.rs b/src/db/models.rs index a2136f2..5c28739 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -519,7 +519,7 @@ pub struct DiscListRow { pub language_flags: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct FlagInfo { pub code: String, pub name: String, @@ -1038,7 +1038,6 @@ pub struct SubmissionListRow { pub submission_type: SubmissionType, pub display_kind: SubmissionDisplayKind, pub title: String, - pub region_flags: Vec, pub system_code: String, pub system_display: String, pub submitter: String, diff --git a/src/routes/queue.rs b/src/routes/queue.rs index a825965..aed96c6 100644 --- a/src/routes/queue.rs +++ b/src/routes/queue.rs @@ -2255,7 +2255,6 @@ mod tests { submission_type, display_kind, title: "Test Game".to_string(), - region_flags: Vec::new(), system_code: "DVD".to_string(), system_display: "DVD".to_string(), submitter: "submitter".to_string(), @@ -2864,10 +2863,6 @@ impl sqlx::FromRow<'_, sqlx::postgres::PgRow> for SubmissionListRow { submission_has_dat_add, ), title: row.try_get("title")?, - region_flags: serde_json::from_value( - row.try_get::("region_flags")?, - ) - .unwrap_or_default(), system_code, system_display, submitter: row.try_get("submitter")?, diff --git a/src/services/queue_service.rs b/src/services/queue_service.rs index 95d28fe..a87d7e3 100644 --- a/src/services/queue_service.rs +++ b/src/services/queue_service.rs @@ -1390,7 +1390,6 @@ pub async fn list_submissions( "SELECT ds.id, ds.submission_type, {dat_add_expr} AS submission_has_dat_add, {title_expr} AS title, - region_flags.region_flags, COALESCE(d.system_code, ds.changes->'system_code'->'add'->>'new', ds.changes->'system_code'->'modify'->>'new', '') AS system_code, COALESCE(s.short_name, '') AS system_short_name, u.username AS submitter, @@ -1406,32 +1405,6 @@ pub async fn list_submissions( LEFT JOIN discs d ON d.id = ds.target_disc_id LEFT JOIN systems s ON s.code = COALESCE(d.system_code, ds.changes->'system_code'->'add'->>'new', ds.changes->'system_code'->'modify'->>'new') - LEFT JOIN LATERAL ( - SELECT COALESCE( - jsonb_agg(jsonb_build_object('code', r.flag_code, 'name', r.name) ORDER BY r.sort_order), - '[]'::jsonb - ) AS region_flags - FROM regions r - JOIN ( - SELECT DISTINCT region_code - FROM ( - SELECT jsonb_array_elements_text(ds.changes->'regions') AS region_code - WHERE jsonb_typeof(ds.changes->'regions') = 'array' - UNION - SELECT dr.region_code - FROM disc_regions dr - WHERE dr.disc_id = ds.target_disc_id - AND COALESCE(jsonb_typeof(ds.changes->'regions'), '') <> 'array' - UNION - SELECT jsonb_array_elements_text(ds.changes->'regions'->'add') AS region_code - WHERE jsonb_typeof(ds.changes->'regions'->'add') = 'array' - ) region_candidates - WHERE region_code NOT IN ( - SELECT jsonb_array_elements_text(ds.changes->'regions'->'remove') - WHERE jsonb_typeof(ds.changes->'regions'->'remove') = 'array' - ) - ) effective_regions ON effective_regions.region_code = r.code - ) region_flags ON TRUE WHERE {} ORDER BY {sort_col} {sort_dir}{nulls_order} LIMIT {page_size} OFFSET {offset}", diff --git a/templates/queue.html b/templates/queue.html index d3dae18..6e47e31 100644 --- a/templates/queue.html +++ b/templates/queue.html @@ -88,7 +88,6 @@

{{ page_title }}

Date{% if sort_column == "date" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} - Region Type{% if sort_column == "type" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} Submission Title{% if sort_column == "title" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} Disc ID{% if sort_column == "disc_id" %} {% if sort_order == "asc" %}▼{% else %}▲{% endif %}{% endif %} @@ -102,11 +101,6 @@

{{ page_title }}

{% for entry in entries %} {{ entry.created_at.format("%Y-%m-%d %H:%M") }} - - {% for rf in entry.region_flags %} - {{ rf.name }} - {% endfor %} - {% if self.can_open_entry(entry) %}{{ entry.title }}{% else %}{{ entry.title }}{% endif %} {% match entry.target_disc_id %}{% when Some with (disc_id) %}{{ disc_id }}{% when None %}{% endmatch %} @@ -117,7 +111,7 @@

{{ page_title }}

{% endfor %} {% if entries.is_empty() %} - No submissions found. + No submissions found. {% endif %}