//! Tipos en memoria del árbol de menús y función de construcción. use std::collections::HashMap; use pagetop::locale::{Locale, RequestLocale}; use pagetop::prelude::*; use crate::cache::FlatMenu; use crate::entity::menu_item_translation; use crate::{action, cache, repo}; // **< MenuKey >************************************************************************************ /// Selector para localizar un menú por `id` o por `machine_name`. pub enum MenuKey { Id(i32), Name(String), } impl From for MenuKey { fn from(id: i32) -> Self { MenuKey::Id(id) } } impl From<&str> for MenuKey { fn from(name: &str) -> Self { MenuKey::Name(name.to_owned()) } } impl From for MenuKey { fn from(name: String) -> Self { MenuKey::Name(name) } } // **< URL de un ítem de menú >********************************************************************* /// Construye la URL de un ítem de menú a partir del texto crudo guardado en BD. /// /// Devuelve `None` para `` o una cadena vacía (título de sección, sin enlace). El /// `RoutePath` devuelto está sin resolver todavía: [`try_resolve_menu_url()`] es quien decide, en /// el momento del renderizado, si debe pasar por [`Context::route()`] o dejarse tal cual. pub fn menu_item_url(url: &str) -> Option { (!url.is_empty() && url != "").then(|| RoutePath::new(url.to_owned())) } /// Resuelve la URL de un ítem de menú para renderizado. /// /// Las internas pasan por [`Context::route()`] para preservar `lang` cuando corresponda; las /// externas ([`RoutePath::is_external()`]) se devuelven tal cual, sin tocar el idioma, porque no /// pertenecen al espacio de rutas de la aplicación. `None` (sin enlace) se propaga tal cual. pub fn try_resolve_menu_url(url: Option<&RoutePath>, cx: &Context) -> Option { url.map(|path| { if path.is_external() { path.clone() } else { cx.route(path.path().to_owned()) } }) } // **< TreeOptions >******************************************************************************** /// Opciones de construcción del árbol de menú. #[derive(Clone, Debug, Default)] pub struct TreeOptions { /// Profundidad máxima de nodos a incluir (`None` = sin límite). pub max_depth: Option, /// Si `true`, incluye también los ítems con `enabled = false`. pub include_disabled: bool, } // **< MenuNode >*********************************************************************************** /// Nodo del árbol de menú. Sus campos son `pub` para que las acciones puedan modificarlos. #[derive(Clone, Debug)] pub struct MenuNode { pub item_id: i32, pub title: String, /// `None` si el ítem no tiene enlace (`` o URL vacía): título de sección. pub url: Option, pub weight: i32, pub depth: u8, pub enabled: bool, pub expanded: bool, pub provider: String, pub external_key: Option, /// Atributos HTML adicionales inyectados por `DecorateMenuItem` en tiempo de render. pub attrs: HashMap, pub children: Vec, /// `true` si este nodo o algún descendiente coincide con la ruta actual. pub in_active_trail: bool, /// `true` si este nodo coincide exactamente con la ruta actual. pub is_active: bool, } // **< MenuTree >*********************************************************************************** /// Árbol completo de un menú, listo para renderizar. #[derive(Clone, Debug)] pub struct MenuTree { pub menu_id: i32, pub machine_name: String, pub title: String, pub roots: Vec, } impl MenuTree { /// Aplica `f` recursivamente a todos los nodos del árbol (post-orden). pub fn walk_mut(&mut self, f: &mut F) { walk_nodes_mut(&mut self.roots, f); } } fn walk_nodes_mut(nodes: &mut [MenuNode], f: &mut F) { for node in nodes.iter_mut() { walk_nodes_mut(&mut node.children, f); f(node); } } // **< build_tree >********************************************************************************* /// Construye el árbol del menú indicado aplicando caché, acciones y active trail. /// /// Devuelve `None` si el menú no existe en la base de datos. pub async fn build_tree(key: MenuKey, cx: &Context, opts: &TreeOptions) -> Option { let menu = repo::find_menu_model(&key).await?; let flat = cache::get_or_load(menu.id, &menu.machine_name).await; let lang = RequestLocale::from_request(cx.request()) .langid() .to_string(); let menu_title = resolve_menu_title(menu.id, &lang).await; let roots = build_nodes(&flat, None, 1, opts, &lang); let mut tree = MenuTree { menu_id: menu.id, machine_name: menu.machine_name.clone(), title: menu_title, roots, }; // Acciones de alteración del árbol (filtros, reordenamientos, etc.). action::AlterMenuTree::dispatch(&menu.machine_name, &mut tree, cx); // Cálculo del active trail por coincidencia de URL. let current_path = cx.request().map(|r| r.path()).unwrap_or("/"); compute_active_trail(&mut tree.roots, current_path); // Acciones de resolución de active trail para rutas paramétricas o especiales. action::ResolveActiveTrail::dispatch(&menu.machine_name, &mut tree, cx); // Decoración de nodos (atributos HTML, iconos, badges...). let name = menu.machine_name.clone(); tree.walk_mut(&mut |node| { action::DecorateMenuItem::dispatch(&name, node, cx); }); Some(tree) } // **< Funciones internas >************************************************************************* pub(crate) fn build_nodes( flat: &FlatMenu, parent_id: Option, depth: u8, opts: &TreeOptions, lang: &str, ) -> Vec { if opts.max_depth.map(|d| depth > d).unwrap_or(false) { return vec![]; } let mut nodes: Vec = flat .items .iter() .filter(|m| m.parent_id == parent_id && (opts.include_disabled || m.enabled)) .map(|m| { let translations = flat .translations .get(&m.id) .map(Vec::as_slice) .unwrap_or(&[]); MenuNode { item_id: m.id, title: resolve_item_title(translations, lang), url: menu_item_url(&m.url), weight: m.weight, depth, enabled: m.enabled, expanded: m.expanded, provider: m.provider.clone(), external_key: m.external_key.clone(), attrs: HashMap::new(), children: build_nodes(flat, Some(m.id), depth + 1, opts, lang), in_active_trail: false, is_active: false, } }) .collect(); nodes.sort_by_key(|n| n.weight); nodes } pub(crate) fn compute_active_trail(nodes: &mut [MenuNode], current_path: &str) -> bool { let mut any_active = false; for node in nodes.iter_mut() { let self_active = node .url .as_ref() .is_some_and(|p| !p.is_external() && p.path() == current_path); let child_active = compute_active_trail(&mut node.children, current_path); node.is_active = self_active; node.in_active_trail = self_active || child_active; if node.in_active_trail { any_active = true; } } any_active } // Resuelve el título del menú desde la BD aplicando la cadena de fallback de idioma. async fn resolve_menu_title(menu_id: i32, lang: &str) -> String { use crate::entity::menu_translation; use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn}; let rows = menu_translation::Entity::find() .filter(menu_translation::Column::MenuId.eq(menu_id)) .all(dbconn()) .await .unwrap_or_default(); resolve_title_from( rows.iter().map(|r| (r.lang.as_str(), r.title.as_str())), lang, ) } // Resuelve el título de un ítem aplicando la cadena de fallback de idioma. fn resolve_item_title(translations: &[menu_item_translation::Model], lang: &str) -> String { resolve_title_from( translations .iter() .map(|t| (t.lang.as_str(), t.title.as_str())), lang, ) } // Cadena de fallback: exacto -> base del lang -> idioma por defecto -> base del defecto -> // cualquiera. fn resolve_title_from<'a>( translations: impl Iterator + Clone, lang: &str, ) -> String { let base_lang = lang.split('-').next().unwrap_or(lang); let default_lang = Locale::default_langid().to_string(); let base_default = default_lang.split('-').next().unwrap_or("").to_owned(); let candidates = [ lang, base_lang, default_lang.as_str(), base_default.as_str(), ]; for candidate in candidates { if candidate.is_empty() { continue; } if let Some((_, title)) = translations.clone().find(|(l, _)| *l == candidate) { return title.to_owned(); } } // Cualquier traducción disponible como último recurso. translations .clone() .next() .map(|(_, t)| t.to_owned()) .unwrap_or_default() }