From dbdaeb61514ff01fb9bd89058b0eae6ef7c70848 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 27 Sep 2026 13:12:57 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(user):=20Ordena=20alfab=C3=A9ticam?= =?UTF-8?q?ente=20grupos=20de=20permisos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade `Lc::collation_key()` en PageTop para obtener una clave de ordenación no sensible a mayúsculas y acentos, con `ñ` como letra propia entre `n` y `o` en español. --- .../src/handlers/admin/permissions.rs | 12 +++-- .../pagetop-user/src/handlers/admin/roles.rs | 21 ++++---- .../src/locale/en-US/permissions.ftl | 2 +- .../src/locale/es-ES/permissions.ftl | 2 +- extensions/pagetop-user/src/permission.rs | 25 +++++++-- src/locale/lc.rs | 52 +++++++++++++++++++ tests/locale.rs | 48 +++++++++++++++++ 7 files changed, 144 insertions(+), 18 deletions(-) diff --git a/extensions/pagetop-user/src/handlers/admin/permissions.rs b/extensions/pagetop-user/src/handlers/admin/permissions.rs index 6f451114..b92e8804 100644 --- a/extensions/pagetop-user/src/handlers/admin/permissions.rs +++ b/extensions/pagetop-user/src/handlers/admin/permissions.rs @@ -14,11 +14,13 @@ use crate::permission::{self, UserPermission}; pub(crate) async fn list_get(request: HttpRequest) -> Result { require_permission(&request, &UserPermission::AdminPermissions)?; + let mut page = Page::admin(request); + let registry = permission::registry(); let title = Lc::t("title-admin-permissions", &LOCALES_USER); let mut content = frame(title.clone()); - for (group, group_label) in registry.groups() { + for (group, group_label) in registry.groups_sorted(page.context()) { let items: Vec<(CowStr, Lc)> = registry .by_group(group) .map(|permission| (permission.key(), permission.label())) @@ -41,6 +43,10 @@ pub(crate) async fn list_get(request: HttpRequest) -> Result************************************************************************ +// **< edit_get / edit_post >*********************************************************************** /// GET /admin/user/roles/{id}/edit - Formulario de edición de rol. pub(crate) async fn edit_get( @@ -286,9 +286,9 @@ pub(crate) async fn view_get( Ok(keys) => keys, Err(_) => return Err(ErrorPage::InternalError(Some(request))), }; - let groups = build_permission_groups(&selected); - let mut page = Page::admin(request); + + let groups = build_permission_groups(&selected, page.context()); let back_href = waypoint.or(page.context().route(ADMIN_ROLES_PATH)); let details_block = role_view_details(&role, page.context()).await; @@ -469,13 +469,13 @@ pub(crate) async fn delete_confirm_get( Ok(HtmxResponse::new(button.render(&mut cx).await).into_response()) } -// **< permissions_get / permissions_post >********************************************************** +// **< permissions_get / permissions_post >********************************************************* -fn build_permission_groups(selected: &[String]) -> PermissionGroups { +fn build_permission_groups(selected: &[String], cx: &Context) -> PermissionGroups { let registry = permission::registry(); registry - .groups() - .iter() + .groups_sorted(cx) + .into_iter() .map(|(group, group_label)| { let perms = registry .by_group(group) @@ -507,9 +507,9 @@ pub(crate) async fn permissions_get( Ok(keys) => keys, Err(_) => return Err(ErrorPage::InternalError(Some(request))), }; - let groups = build_permission_groups(&selected); - let mut page = Page::admin(request); + + let groups = build_permission_groups(&selected, page.context()); let back_href = waypoint.or(page.context().route(ADMIN_ROLES_PATH)); let title = Lc::t("title-admin-role-permissions", &LOCALES_USER); @@ -560,8 +560,9 @@ pub(crate) async fn permissions_post( Ok(Redirect::see_other(target).into_response()) } Err(err) => { - let groups = build_permission_groups(&form.permission_keys); let mut page = Page::admin(request); + + let groups = build_permission_groups(&form.permission_keys, page.context()); let back_href = waypoint.or(page.context().route(ADMIN_ROLES_PATH)); let form_component = RolePermissionsForm::new() .with_role_id(id) diff --git a/extensions/pagetop-user/src/locale/en-US/permissions.ftl b/extensions/pagetop-user/src/locale/en-US/permissions.ftl index 754ce4fe..1b36028d 100644 --- a/extensions/pagetop-user/src/locale/en-US/permissions.ftl +++ b/extensions/pagetop-user/src/locale/en-US/permissions.ftl @@ -2,7 +2,7 @@ # **< Group: Users >** -group-users = Users +group-users = User management perm-login = Sign in perm-register = Register a new account diff --git a/extensions/pagetop-user/src/locale/es-ES/permissions.ftl b/extensions/pagetop-user/src/locale/es-ES/permissions.ftl index ca065c1f..70388224 100644 --- a/extensions/pagetop-user/src/locale/es-ES/permissions.ftl +++ b/extensions/pagetop-user/src/locale/es-ES/permissions.ftl @@ -2,7 +2,7 @@ # **< Grupo: Usuarios >** -group-users = Usuarios +group-users = Gestión de usuarios perm-login = Iniciar sesión perm-register = Registrar una cuenta nueva diff --git a/extensions/pagetop-user/src/permission.rs b/extensions/pagetop-user/src/permission.rs index 9e6e959f..842fe35b 100644 --- a/extensions/pagetop-user/src/permission.rs +++ b/extensions/pagetop-user/src/permission.rs @@ -68,9 +68,10 @@ impl ActionDispatcher for DeclarePermissions {} /// Catálogo mutable de permisos, construido durante la fase de inicialización. /// -/// Un `Vec` basta: el catálogo se construye una sola vez con un puñado de entradas y se recorre -/// entero en la UI de administración, así que conserva el orden de registro sin estructuras -/// adicionales y sin el coste de mantenerlas sincronizadas. +/// Se construye una sola vez con un conjunto de entradas que conserva el orden de registro sin +/// estructuras adicionales. La UI de administración no usa ese orden de registro. Llama a +/// [`groups_sorted()`](Self::groups_sorted), que lo reordena alfabéticamente para cada petición +/// según el idioma activo. #[derive(Default)] pub struct PermissionRegistry { permissions: Vec, @@ -105,10 +106,28 @@ impl PermissionRegistry { self.permissions.iter().copied() } + /// Grupos en orden de primer registro, sin traducir ni ordenar alfabéticamente. + /// + /// Listado de bajo nivel sobre el que se apoya [`groups_sorted()`](Self::groups_sorted); úsalo + /// en su lugar sólo cuando el orden de registro sea justo lo que se necesita (por ejemplo, para + /// inspeccionar el catálogo en pruebas). pub fn groups(&self) -> &[(&'static str, Lc)] { &self.groups } + /// Grupos ordenados alfabéticamente por su título traducido al idioma del contexto, no sensible + /// a mayúsculas y, en general, a los acentos (ver [`Lc::collation_key()`] para el criterio + /// exacto y sus límites). + pub fn groups_sorted(&self, cx: &Context) -> Vec<&(&'static str, Lc)> { + let mut groups: Vec<_> = self + .groups + .iter() + .map(|g| (g.1.collation_key(cx), g)) + .collect(); + groups.sort_by(|a, b| a.0.cmp(&b.0)); + groups.into_iter().map(|(_, g)| g).collect() + } + pub fn by_group<'a>(&'a self, group: &'a str) -> impl Iterator + 'a { self.permissions .iter() diff --git a/src/locale/lc.rs b/src/locale/lc.rs index 0299d2da..c54ed4fb 100644 --- a/src/locale/lc.rs +++ b/src/locale/lc.rs @@ -247,4 +247,56 @@ impl Lc { _ => PreEscaped(self.lookup(language).unwrap_or_default()), } } + + /// Calcula una clave de ordenación alfabética del texto traducido, no sensible a mayúsculas y, + /// en general, a los acentos. + /// + /// Permite ordenar listados de textos traducidos (títulos, etiquetas, etc.) de forma natural + /// para quien lee. Reduce a minúsculas y a su letra base las vocales acentuadas y `ç`, criterio + /// común a la mayoría de idiomas que las usan. + /// + /// Tiene en cuenta como caso especial la letra `ñ` en español. Se conserva como letra propia + /// entre `n` y `o`. Por ahora no distingue entre idiomas; esta regla, específica del español, + /// se aplica también a textos en cualquier otro idioma soportado. No supone un problema en la + /// práctica porque `ñ` no aparece en el resto de idiomas salvo en préstamos puntuales, pero no + /// es el criterio nativo de otras lenguas (en francés, italiano, alemán o portugués, por + /// ejemplo, `ñ` no es una letra propia del alfabeto). + /// + /// El resultado es una clave a comparar con [`str::cmp()`] o [`String::cmp()`], no un texto + /// para mostrar. + /// + /// # Ejemplo + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let mut groups = vec![ + /// Lc::n("Ñu"), + /// Lc::n("Nube"), + /// Lc::n("Oso"), + /// Lc::n("Café"), + /// Lc::n("Anual"), + /// Lc::n("Año"), + /// ]; + /// groups.sort_by_key(|g| g.collation_key(&Locale::default())); + /// + /// let order: Vec<_> = groups.iter().map(|g| g.get().unwrap()).collect(); + /// assert_eq!(order, vec!["Anual", "Año", "Café", "Nube", "Ñu", "Oso"]); + /// ``` + pub fn collation_key(&self, language: &impl LangId) -> String { + let text = self.lookup(language).unwrap_or_default(); + let mut key = String::with_capacity(text.len()); + for c in text.chars().flat_map(char::to_lowercase) { + match c { + 'á' | 'à' | 'ä' | 'â' => key.push('a'), + 'é' | 'è' | 'ë' | 'ê' => key.push('e'), + 'í' | 'ì' | 'ï' | 'î' => key.push('i'), + 'ó' | 'ò' | 'ö' | 'ô' => key.push('o'), + 'ú' | 'ù' | 'ü' | 'û' => key.push('u'), + 'ç' => key.push('c'), + 'ñ' => key.push_str("n\u{10ffff}"), + _ => key.push(c), + } + } + key + } } diff --git a/tests/locale.rs b/tests/locale.rs index 804c70e3..11f08def 100644 --- a/tests/locale.rs +++ b/tests/locale.rs @@ -85,3 +85,51 @@ async fn translated_text_is_not_escaped_when_rendered_as_markup() { let markup = lc.using(&Locale::resolve("en-US")); assert_eq!(markup.into_string(), "Hello world!"); } + +#[pagetop::test] +async fn collation_key_is_case_and_accent_insensitive() { + setup().await; + + let lower = Lc::n("café").collation_key(&Locale::default()); + let upper = Lc::n("CAFÉ").collation_key(&Locale::default()); + assert_eq!(lower, upper); +} + +// `ñ` sorts as its own letter (Spanish/RAE order), between "n..." and "o...", not merged with `n` +// like the other accented letters. +#[pagetop::test] +async fn collation_key_orders_n_tilde_like_spanish() { + setup().await; + + let mut words = vec![Lc::n("Ñu"), Lc::n("Nube"), Lc::n("Oso"), Lc::n("Café")]; + words.sort_by_key(|w| w.collation_key(&Locale::default())); + + let sorted: Vec<_> = words.iter().map(|w| w.get().unwrap()).collect(); + assert_eq!(sorted, vec!["Café", "Nube", "Ñu", "Oso"]); +} + +// Minimal pair sharing the same prefix ("an-"/"añ-"): the comparison is decided by the second +// letter alone (`n` < `ñ`), regardless of how the words continue afterwards ("anual" has more +// letters after the `n` than "año" has after the `ñ`, and still sorts first). +#[pagetop::test] +async fn collation_key_orders_n_tilde_before_further_letters() { + setup().await; + + let mut words = [Lc::n("Año"), Lc::n("Anzuelo"), Lc::n("Anual")]; + words.sort_by_key(|w| w.collation_key(&Locale::default())); + + let sorted: Vec<_> = words.iter().map(|w| w.get().unwrap()).collect(); + assert_eq!(sorted, vec!["Anual", "Anzuelo", "Año"]); +} + +// `collation_key()` sorts the *resolved* translation, not the raw key, so the same `Lc` yields a +// different key depending on the language passed in. +#[pagetop::test] +async fn collation_key_sorts_the_resolved_translation() { + setup().await; + + let lc = Lc::l("test_hello_world"); + let key_en = lc.collation_key(&Locale::resolve("en-US")); + let key_es = lc.collation_key(&Locale::resolve("es-ES")); + assert_ne!(key_en, key_es); +}