WME PLN Module - Categories Handler

Módulo de categorías para WME Place Normalizer. No funciona por sí solo.

Tento skript by nemal byť nainštalovaný priamo. Je to knižnica pre ďalšie skripty, ktorú by mali používať cez meta príkaz // @require https://update.greasyforks.org/scripts/548860/1657864/WME%20PLN%20Module%20-%20Categories%20Handler.js

// ==UserScript==
// @name         WME PLN Module - Categories Handler
// @version      9.0.0
// @description  Módulo de categorías para WME Place Normalizer. No funciona por sí solo.
// @author       mincho77
// @license      MIT
// @grant        none
// ==/UserScript==
//Función Para Cargar Categorías Desde Google Sheets
async function loadDynamicCategoriesFromSheet(cfg)
{
    const SPREADSHEET_ID = cfg?.spreadsheetId || "1kJDEOn8pKLdqEyhIZ9DdcrHTb_GsoeXgIN4GisrpW2Y";
    const API_KEY       = cfg?.apiKey        || "AIzaSyAQbvIQwSPNWfj6CcVEz5BmwfNkao533i8";
    const RANGE         = cfg?.range         || "Categories!A2:E";
    const TTL_MS        = Number(cfg?.cacheTTLHours || 24) * 60 * 60 * 1000;
    window.dynamicCategoryRules = []; // Definimos la variable global para guardar las reglas
    const url = `https://sheets.googleapis.com/v4/spreadsheets/${SPREADSHEET_ID}/values/${RANGE}?key=${API_KEY}`;

    return new Promise((resolve) =>
    {
        if (!SPREADSHEET_ID || !API_KEY)
        {
            plnLog('warn','[WME PLN] No se ha configurado SPREADSHEET_ID o API_KEY. Se omitirá la carga de categorías dinámicas.');
            resolve();
            return;
        }
        // Check for cached data first
        const cachedData = localStorage.getItem("wme_pln_categories_cache");
        if (cachedData)
        {
            try
            {
                const { data, timestamp } = JSON.parse(cachedData);
                // Use cache if less than TTL_MS old
                if (data && timestamp && (Date.now() - timestamp < TTL_MS))
                {
                   plnLog('ui', '[WME PLN] Usando categorías en caché. Reconstruyendo RegExp...');
                    // Se itera sobre los datos de la caché para reconstruir las expresiones regulares
                    window.dynamicCategoryRules = data.map(rule =>
                    {
                        if (rule.keyword)
                        { // Asegurarse de que la regla tenga keywords
                            const canonical = String(rule.keyword || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim();
                            const keywords = canonical.split(';').map(k => k.trim()).filter(k => k.length > 0);
                            const regexParts = keywords.map(k => `\\b${PLNCore.utils.escapeRegExp(k)}\\b`);
                            const combinedRegex = new RegExp(`(${regexParts.join('|')})`, 'i');
                            // Devolver la regla con la propiedad compiledRegex correctamente creada
                            return { ...rule, compiledRegex: combinedRegex };
                        }
                        return rule; // Devuelve la regla sin cambios si no tiene keyword
                    });
                    window.dynamicCategoryRules.sort((a, b) => b.keyword.length - a.keyword.length);
                    resolve();
                    return;
                }
            }
            catch (e)
            {
                plnLog('warn','[WME PLN] Error al leer caché de categorías:', e);
            }
        }
        PLNCore.net.request(
        {
            method: "GET",
            url: url,
            timeout: 10000, // Add timeout
                onload: function (response)
                {
                    if (response.status >= 200 && response.status < 300)
                    {
                        try
                        {
                            const data = JSON.parse(response.responseText);
                            if (data.values) {
                                // El procesamiento de los datos de la API ya era correcto
                                window.dynamicCategoryRules = data.values.map(row =>
                                {
                                    const keyword = (row[0] || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim();
                                    const keywords = keyword.split(';').map(k => k.trim()).filter(k => k.length > 0);
                                    const regexParts = keywords.map(k => `\\b${PLNCore.utils.escapeRegExp(k)}\\b`);
                                    const combinedRegex = new RegExp(`(${regexParts.join('|')})`, 'i');
                                    return {
                                        keyword: keyword,
                                        categoryKey: row[1] || '',
                                        icon: row[2] || '⚪',
                                        desc_es: row[3] || 'Sin descripción',
                                        desc_en: row[4] || 'No description',
                                        compiledRegex: combinedRegex
                                    };
                                });
                                window.dynamicCategoryRules.sort((a, b) => b.keyword.length - a.keyword.length);

                        // La lógica para guardar en caché también es correcta
                                try
                                {
                                    localStorage.setItem("wme_pln_categories_cache", JSON.stringify(
                                    {
                                        data: window.dynamicCategoryRules,
                                        timestamp: Date.now()
                                    }));
                                }
                                catch (e)
                                {
                                    plnLog('warn','[WME PLN] Error al guardar caché de categorías:', e);
                                }
                                plnLog('ui', '[WME PLN] Categorías cargadas desde API');
                        }
                    } 
                    catch (e) 
                    {
                        plnLog('error','[WME PLN] Error al procesar datos de categorías:', e);
                    }
                } 
                else 
                {
                    plnLog('warn',`[WME PLN] Error HTTP ${response.status} al cargar categorías`);
                }
                resolve();
            },
            onerror: function (error)
            {
                plnLog('error','[WME PLN] Error de red al cargar categorías:', error);
                resolve();
            },
            ontimeout: function ()
            {
                plnLog('error','[WME PLN] Timeout al cargar categorías');
                resolve();
            }
        });
    });
}//loadDynamicCategoriesFromSheet
// Función para encontrar la categoría de un lugar basado en su nombre
function findCategoryForPlace(placeName)
{
    if (!placeName || typeof placeName !== 'string' || !window.dynamicCategoryRules || window.dynamicCategoryRules.length === 0) // Si el nombre del lugar es inválido o no hay reglas de categoría cargadas, devuelve un array vacío de sugerencias.
        return [];
    const lowerCasePlaceName = placeName.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");// Convertir el nombre del lugar a minúsculas y normalizar para comparaciones insensibles a mayúsculas y diacríticos
    const allMatchingRules = []; // Este array almacenará todas las reglas de categoría que coincidan.
    const placeWords = lowerCasePlaceName.split(/\s+/).filter(w => w.length > 0); // Descomponer el nombre del lugar en palabras
    const SIMILARITY_THRESHOLD_FOR_KEYWORDS = 0.95; // Puedes ajustar este umbral (ej. 0.90 para 90% de similitud)
    // PASO 0: Normalizar el nombre del lugar eliminando diacríticos y caracteres especiales
    for (const rule of window.dynamicCategoryRules)
    {
        if (!rule.compiledRegex) continue; // Si la regla no tiene una expresión regular compilada (lo cual no debería pasar si se cargó correctamente), salta a la siguiente regla.
        // **PASO 1: Búsqueda por Regex Exacta
        if (rule.compiledRegex.test(lowerCasePlaceName))
        {
            if (!allMatchingRules.some(mr => mr.categoryKey === rule.categoryKey)) {
                allMatchingRules.push(rule);
            }
            // Si Ya Añadimos La Regla Por Regex Exacta, Pasar A La Siguiente Regla Para Ahorrar Cálculos De Similitud
            continue;
        }
        // **PASO 2: Búsqueda por Similitud para CADA palabra del lugar vs CADA palabra clave de la regla**
        const ruleKeywords = rule.keyword.split(';').map(k => k.trim().toLowerCase()).filter(k => k.length > 0);
        let foundSimilarityForThisRule = false; // Bandera para saber si ya encontramos una buena similitud para esta regla, para no seguir buscando más palabras clave de la regla.
        for (const pWord of placeWords) // Cada palabra del nombre del lugar
        { // Cada palabra del nombre del lugar
            if (foundSimilarityForThisRule) break; // Si ya encontramos una buena similitud para esta regla, pasamos a la siguiente.
            for (const rKeyword of ruleKeywords)
            { // Cada palabra clave de la regla
                // Asegurarse de que rKeyword no sea una expresión regular, sino la palabra literal para Levenshtein
                const similarity = PLNCore.utils.calculateSimilarity(pWord, rKeyword); // Calcular la similitud entre la palabra del lugar y la palabra clave de la regla
                if (similarity >= SIMILARITY_THRESHOLD_FOR_KEYWORDS && !allMatchingRules.some(mr => mr.categoryKey === rule.categoryKey)) // Si la similitud es alta y aún no hemos añadido esta categoría
                {
                    allMatchingRules.push(rule);
                    foundSimilarityForThisRule = true; // Marcamos que ya la encontramos para esta regla
                    break; // Salimos del bucle de rKeyword y pWord
                }
            }
        }
    }
    plnLog('ui', `[WME PLN][DEBUG] findCategoryForPlace para "${placeName}" devolvió: `, allMatchingRules);
    return allMatchingRules;
}//findCategoryForPlace
//Permite obtener el icono y descripción de una categoría
function getCategoryDetails(categoryKey)
{
    const lang = getWazeLanguage();
    // 1. Intento con la hoja de Google (window.dynamicCategoryRules)
    if (window.dynamicCategoryRules && window.dynamicCategoryRules.length > 0)
    {
        const rule = window.dynamicCategoryRules.find(r => r.categoryKey.toUpperCase() === categoryKey.toUpperCase());
        if (rule)
        {
            const description = (lang === 'es' && rule.desc_es) ? rule.desc_es : rule.desc_en;
            return { icon: rule.icon, description: description };
        }
    }
    // 2. Fallback a la lista interna del script si no se encontró en la hoja
    const hardcodedInfo = getCategoryIcon(categoryKey); // Llama a la función original
    if (hardcodedInfo && hardcodedInfo.icon !== '⚪' && hardcodedInfo.icon !== '❓')
    {
            // La función original devuelve un título "Español / English", lo separamos.
        const descriptions = hardcodedInfo.title.split(' / ');
        const description = (lang === 'es' && descriptions[0]) ? descriptions[0] : descriptions[1] || descriptions[0];
        return { icon: hardcodedInfo.icon, description: description };
    }
    // 3. Si no se encuentra en ninguna parte, devolver un valor por defecto.
    const defaultDescription = lang === 'es' ? `Categoría no encontrada (${categoryKey})` : `Category not found (${categoryKey})`;
    return { icon: '⚪', description: defaultDescription };
}//getCategoryDetails
    // Función para obtener el ícono de categoría
function getCategoryIcon(categoryName)
{


    // Mapa de categorías a íconos con soporte bilingüe
    const categoryIcons = {
        // Comida y Restaurantes / Food & Restaurants
        "FOOD_AND_DRINK": { icon: "🦞🍷", es: "Comida y Bebidas", en: "Food and Drinks" },
        "RESTAURANT": { icon: "🍽️", es: "Restaurante", en: "Restaurant" },
        "FAST_FOOD": { icon: "🍔", es: "Comida rápida", en: "Fast Food" },
        "CAFE": { icon: "☕", es: "Cafetería", en: "Cafe" },
        "BAR": { icon: "🍺", es: "Bar", en: "Bar" },
        "BAKERY": { icon: "🥖", es: "Panadería", en: "Bakery" },
        "ICE_CREAM": { icon: "🍦", es: "Heladería", en: "Ice Cream Shop" },
        "DEPARTMENT_STORE": { icon: "🏬", es: "Tienda por departamentos", en: "Department Store" },
        "PARK": { icon: "🌳", es: "Parque", en: "Park" },
        // Compras y Servicios / Shopping & Services
        "FASHION_AND_CLOTHING": { icon: "👗", es: "Moda y Ropa", en: "Fashion and Clothing" },
        "SHOPPING_AND_SERVICES": { icon: "👜👝", es: "Mercado o Tienda", en: "Shopping and Services" },
        "SHOPPING_CENTER": { icon: "🛍️", es: "Centro comercial", en: "Shopping Center" },
        "SUPERMARKET_GROCERY": { icon: "🛒", es: "Supermercado", en: "Supermarket" },
        "MARKET": { icon: "🛒", es: "Mercado", en: "Market" },
        "CONVENIENCE_STORE": { icon: "🏪", es: "Tienda", en: "Convenience Store" },
        "PHARMACY": { icon: "💊", es: "Farmacia", en: "Pharmacy" },
        "BANK": { icon: "🏦", es: "Banco", en: "Bank" },
        "ATM": { icon: "💳", es: "Cajero automático", en: "ATM" },
        "HARDWARE_STORE": { icon: "🔧", es: "Ferretería", en: "Hardware Store" },
        "COURTHOUSE": { icon: "⚖️", es: "Corte", en: "Courthouse" },
        "FURNITURE_HOME_STORE": { icon: "🛋️", es: "Tienda de muebles", en: "Furniture Store" },
        "TOURIST_ATTRACTION_HISTORIC_SITE": { icon: "🗿", es: "Atracción turística o Sitio histórico", en: "Tourist Attraction or Historic Site" },
        "PET_STORE_VETERINARIAN_SERVICES": { icon: "🦮🐈", es: "Tienda de mascotas o Veterinaria", en: "Pet Store or Veterinary Services" },
        "CEMETERY": { icon: "🪦", es: "Cementerio", en: "Cemetery" },
        "KINDERGARDEN": { icon: "🍼", es: "Jardín Infantil", en: "Kindergarten" },
        "JUNCTION_INTERCHANGE": { icon: "🔀", es: "Cruce o Intercambio", en: "Junction or Interchange" },
        "OUTDOORS": { icon: "🏞️", es: "Aire libre", en: "Outdoors" },
        "ORGANIZATION_OR_ASSOCIATION": { icon: "👔", es: "Organización o Asociación", en: "Organization or Association" },
        "TRAVEL_AGENCY": { icon: "🧳", es: "Agencia de viajes", en: "Travel Agency" },
        "BANK_FINANCIAL": { icon: "💰", es: "Banco o Financiera", en: "Bank or Financial Institution" },
        "SPORTING_GOODS": { icon: "🛼🏀🏐", es: "Artículos deportivos", en: "Sporting Goods" },
        "TOY_STORE": { icon: "🧸", es: "Tienda de juguetes", en: "Toy Store" },
        "CURRENCY_EXCHANGE": { icon: "💶💱", es: "Casa de cambio", en: "Currency Exchange" },
        "PHOTOGRAPHY": { icon: "📸", es: "Fotografía", en: "Photography" },
        "DESSERT": { icon: "🍰", es: "Postre", en: "Dessert" },
        "FOOD_COURT": { icon: "🥗", es: "Comedor o Patio de comidas", en: "Food Court" },
        "CANAL": { icon: "〰", es: "Canal", en: "Canal" },
        "JEWELRY": { icon: "💍", es: "Joyería", en: "Jewelry" },
        // Transporte / Transportation
        "TRAIN_STATION": { icon: "🚂", es: "Estación de tren", en: "Train Station" },
        "GAS_STATION": { icon: "⛽", es: "Estación de servicio", en: "Gas Station" },
        "PARKING_LOT": { icon: "🅿️", es: "Estacionamiento", en: "Parking Lot" },
        "BUS_STATION": { icon: "🚍", es: "Terminal de bus", en: "Bus Station" },
        "AIRPORT": { icon: "✈️", es: "Aeropuerto", en: "Airport" },
        "CAR_WASH": { icon: "🚗💦", es: "Lavado de autos", en: "Car Wash" },
        "CAR_RENTAL": { icon: "🚘🛺🛻🚙", es: "Alquiler de Vehículos", en: "Car Rental" },
        "TAXI_STATION": { icon: "🚕", es: "Estación de taxis", en: "Taxi Station" },
        "FOREST_GROVE": { icon: "🌳", es: "Bosque", en: "Forest Grove" },
        "GARAGE_AUTOMOTIVE_SHOP": { icon: "🔧🚗", es: "Taller mecánico", en: "Automotive Garage" },
        "GIFTS": { icon: "🎁", es: "Tienda de regalos", en: "Gift Shop" },
        "TOLL_BOOTH": { icon: "🚧", es: "Peaje", en: "Toll Booth" },
        "CHARGING_STATION": { icon: "🔋", es: "Estación de carga", en: "Charging Station" },
        "CAR_SERVICES": { icon: "🚗🔧", es: "Servicios de automóviles", en: "Car Services" },
        "STADIUM_ARENA": { icon: "🏟️", es: "Estadio o Arena", en: "Stadium or Arena" },
        "CAR_DEALERSHIP": { icon: "🚘🏢", es: "Concesionario de autos", en: "Car Dealership" },
        "FERRY_PIER": { icon: "⛴️", es: "Muelle de ferry", en: "Ferry Pier" },
        "INFORMATION_POINT": { icon: "ℹ️", es: "Punto de información", en: "Information Point" },
        "REST_AREAS": { icon: "🏜", es: "Áreas de descanso", en: "Rest Areas" },
        "MUSIC_VENUE": { icon: "🎶", es: "Lugar de música", en: "Music Venue" },
        "CASINO": { icon: "🎰", es: "Casino", en: "Casino" },
        "CITY_HALL": { icon: "🎩", es: "Ayuntamiento", en: "City Hall" },
        "PERFORMING_ARTS_VENUE": { icon: "🎭", es: "Lugar de artes escénicas", en: "Performing Arts Venue" },
        "TUNNEL": { icon: "🔳", es: "Túnel", en: "Tunnel" },
        "SEAPORT_MARINA_HARBOR": { icon: "⚓", es: "Puerto o Marina", en: "Seaport or Marina" },
        // Alojamiento / Lodging
        "HOTEL": { icon: "🏨", es: "Hotel", en: "Hotel" },
        "HOSTEL": { icon: "🛏️", es: "Hostal", en: "Hostel" },
        "LODGING": { icon: "⛺", es: "Alojamiento", en: "Lodging" },
        "MOTEL": { icon: "🛕", es: "Motel", en: "Motel" },
        "SWIMMING_POOL": { icon: "🏊", es: "Piscina", en: "Swimming Pool" },
        "RIVER_STREAM": { icon: "🌊", es: "Río o Arroyo", en: "River or Stream" },
        "CAMPING_TRAILER_PARK": { icon: "🏕️", es: "Camping o Parque de Trailers", en: "Camping or Trailer Park" },
        "SEA_LAKE_POOL": { icon: "🏖️", es: "Mar, Lago o Piscina", en: "Sea, Lake or Pool" },
        "FARM": { icon: "🚜", es: "Granja", en: "Farm" },
        "NATURAL_FEATURES": { icon: "🌲", es: "Características naturales", en: "Natural Features" },
        // Salud / Healthcare
        "HOSPITAL": { icon: "🏥", es: "Hospital", en: "Hospital" },
        "HOSPITAL_URGENT_CARE": { icon: "🏥🚑", es: "Urgencias", en: "Urgent Care" },
        "DOCTOR_CLINIC": { icon: "🏥⚕️", es: "Clínica", en: "Clinic" },
        "DOCTOR": { icon: "👨‍⚕️", es: "Consultorio médico", en: "Doctor's Office" },
        "VETERINARY": { icon: "🐾", es: "Veterinaria", en: "Veterinary" },
        "PERSONAL_CARE": { icon: "💅💇🦷", es: "Cuidado personal", en: "Personal Care" },
        "FACTORY_INDUSTRIAL": { icon: "🏭", es: "Fábrica o Industrial", en: "Factory or Industrial" },
        "MILITARY": { icon: "🪖", es: "Militar", en: "Military" },
        "LAUNDRY_DRY_CLEAN": { icon: "🧺", es: "Lavandería o Tintorería", en: "Laundry or Dry Clean" },
        "PLAYGROUND": { icon: "🛝", es: "Parque infantil", en: "Playground" },
        "TRASH_AND_RECYCLING_FACILITIES": { icon: "🗑️♻️", es: "Instalaciones de basura y reciclaje", en: "Trash and Recycling Facilities" },
        // Educación / Education
        "UNIVERSITY": { icon: "🎓", es: "Universidad", en: "University" },
        "COLLEGE_UNIVERSITY": { icon: "🏫", es: "Colegio", en: "College" },
        "SCHOOL": { icon: "🎒", es: "Escuela", en: "School" },
        "LIBRARY": { icon: "📖", es: "Biblioteca", en: "Library" },
        "FLOWERS": { icon: "💐", es: "Floristería", en: "Flower Shop" },
        "CONVENTIONS_EVENT_CENTER": { icon: "🎤🥂", es: "Centro de convenciones o eventos", en: "Convention or Event Center" },
        "CLUB": { icon: "♣", es: "Club", en: "Club" },
        "ART_GALLERY": { icon: "🖼️", es: "Galería de arte", en: "Art Gallery" },
        "NATURAL_FEATURES": { icon: "🌄", es: "Características naturales", en: "Natural Features" },
        // Entretenimiento / Entertainment
        "CINEMA": { icon: "🎬", es: "Cine", en: "Cinema" },
        "THEATER": { icon: "🎭", es: "Teatro", en: "Theater" },
        "MUSEUM": { icon: "🖼", es: "Museo", en: "Museum" },
        "CULTURE_AND_ENTERTAINEMENT": { icon: "🎨", es: "Cultura y Entretenimiento", en: "Culture and Entertainment" },
        "STADIUM": { icon: "🏟️", es: "Estadio", en: "Stadium" },
        "GYM": { icon: "💪", es: "Gimnasio", en: "Gym" },
        "GYM_FITNESS": { icon: "🏋️", es: "Gimnasio o Fitness", en: "Gym or Fitness" },
        "GAME_CLUB": { icon: "⚽🏓", es: "Club de juegos", en: "Game Club" },
        "BOOKSTORE": { icon: "📖📚", es: "Librería", en: "Bookstore" },
        "ELECTRONICS": { icon: "📱💻", es: "Electrónica", en: "Electronics" },
        "SPORTS_COURT": { icon: "⚽🏀", es: "Cancha deportiva", en: "Sports Court" },
        "GOLF_COURSE": { icon: "⛳", es: "Campo de golf", en: "Golf Course" },
        "SKI_AREA": { icon: "⛷️", es: "Área de esquí", en: "Ski Area" },
        "RACING_TRACK": { icon: "🛷⛸🏎️", es: "Pista de carreras", en: "Racing Track" },
        // Gobierno y Servicios Públicos / Government & Public Services
        "GOVERNMENT": { icon: "🏛️", es: "Oficina gubernamental", en: "Government Office" },
        "POLICE_STATION": { icon: "👮", es: "Estación de policía", en: "Police Station" },
        "FIRE_STATION": { icon: "🚒", es: "Estación de bomberos", en: "Fire Station" },
        "FIRE_DEPARTMENT": { icon: "🚒", es: "Departamento de bomberos", en: "Fire Department" },
        "POST_OFFICE": { icon: "📫", es: "Correo", en: "Post Office" },
        "TRANSPORTATION": { icon: "🚌", es: "Transporte", en: "Transportation" },
        "THEME_PARK": { icon: "🎢", es: "Parque de atracciones, Parque Temático", en: "Theme Park" },
        "PRISON_CORRECTIONAL_FACILITY": { icon: "👁️‍🗨️", es: "Prisión o Centro Correccional", en: "Prison or Correctional Facility" },
        // Religión / Religion
        "RELIGIOUS_CENTER": { icon: "⛪", es: "Iglesia", en: "Church" },
        // Otros / Others
        "RESIDENTIAL": { icon: "🏘️", es: "Residencial", en: "Residential" },
        "RESIDENCE_HOME": { icon: "🏠", es: "Residencia o Hogar", en: "Residence or Home" },
        "OFFICES": { icon: "🏢", es: "Oficina", en: "Office" },
        "FACTORY": { icon: "🏭", es: "Fábrica", en: "Factory" },
        "CONSTRUCTION_SITE": { icon: "🏗️", es: "Construcción", en: "Construction" },
        "MONUMENT": { icon: "🗽", es: "Monumento", en: "Monument" },
        "BRIDGE": { icon: "🌉", es: "Puente", en: "Bridge" },
        "PROFESSIONAL_AND_PUBLIC": { icon: "🗄💼", es: "Profesional y Público", en: "Professional and Public" },
        "OTHER": { icon: "🚪", es: "Otro", en: "Other" },
        "ARTS_AND_CRAFTS": { icon: "🎨", es: "Artes y Manualidades", en: "Arts and Crafts" },
        "COTTAGE_CABIN": { icon: "🏡", es: "Cabaña", en: "Cottage Cabin" },
        "TELECOM": { icon: "📡", es: "Telecomunicaciones", en: "Telecommunications" }
    };
    // Si no hay categoría, devolver ícono por defecto
    if (!categoryName)
    {
        return { icon: "❓", title: "Sin categoría / No category" };
    }
    // Normalizar el nombre de la categoría
    const normalizedInput = categoryName.toLowerCase()
        .normalize("NFD")
        .replace(/[\u0300-\u036f]/g, "")
        .trim();
        plnLog('ui', "[WME_PLN][DEBUG] Buscando ícono para categoría:", categoryName);
        plnLog('ui', "[WME_PLN][DEBUG] Nombre normalizado:", normalizedInput);
    // 1. Buscar coincidencia exacta por clave interna (ej: "PARK")
    for (const [key, data] of Object.entries(categoryIcons))
    {
        if (key.toLowerCase() === normalizedInput)
        {
            return { icon: data.icon, title: `${data.es} / ${data.en}` };
        }
    }
    // Buscar coincidencia en el mapa de categorías
    for (const [key, data] of Object.entries(categoryIcons))
    {
        // Normalizar los nombres en español e inglés para la comparación
        const normalizedES = data.es.toLowerCase()
            .normalize("NFD")
            .replace(/[\u0300-\u036f]/g, "")
            .trim();
        const normalizedEN = data.en.toLowerCase()
            .normalize("NFD")
            .replace(/[\u0300-\u036f]/g, "")
            .trim();
        if (normalizedInput === normalizedES || normalizedInput === normalizedEN)
        {
            return { icon: data.icon, title: `${data.es} / ${data.en}` };
        }
    }
    // Si no se encuentra coincidencia, devolver ícono por defecto
    plnLog('ui', "[WME_PLN][DEBUG] No se encontró coincidencia, usando ícono por defecto");
    return {
        icon: "⚪",
        title: `${categoryName} (Sin coincidencia / No match)`
    };
}// getCategoryIcon

// Crea un dropdown para seleccionar categorías recomendadas
function createRecommendedCategoryDropdown(placeId, currentCategoryKey, dynamicCategorySuggestions)
{
    window.tempSelectedCategories = window.tempSelectedCategories || new Map();
    const wrapperDiv = document.createElement("div");
    wrapperDiv.style.position = "relative";
    wrapperDiv.style.width = "100%";
    wrapperDiv.style.minWidth = "150px";
    wrapperDiv.style.display = "flex";
    wrapperDiv.style.flexDirection = "column";
    // Parte de sugerencias dinámicas existentes
    const suggestionsWrapper = document.createElement("div"); // Contenedor para sugerencias
    suggestionsWrapper.style.display = "flex";
    suggestionsWrapper.style.flexDirection = "column";
    suggestionsWrapper.style.alignItems = "flex-start";
    suggestionsWrapper.style.gap = "4px";
    // Filtrar y ordenar las sugerencias dinámicas para la presentación
    const filteredSuggestions = dynamicCategorySuggestions.filter(suggestion => suggestion.categoryKey.toUpperCase() !== currentCategoryKey.toUpperCase());
    if (filteredSuggestions.length > 0)
    { // Solo si hay sugerencias diferentes a la actual
        filteredSuggestions.forEach(suggestion =>
        {
            const suggestionEntry = document.createElement("div");
            suggestionEntry.style.display = "flex";
            suggestionEntry.style.alignItems = "center";
            suggestionEntry.style.gap = "4px";
            suggestionEntry.style.padding = "2px 4px";
            suggestionEntry.style.border = "1px solid #dcdcdc";
            suggestionEntry.style.borderRadius = "3px";
            suggestionEntry.style.backgroundColor = "#eaf7ff"; // Un color distinto para sugerencias
            suggestionEntry.style.cursor = "pointer";
            suggestionEntry.title = `Sugerencia: ${getCategoryDetails(suggestion.categoryKey).description}`;
            //Añadir icono y descripción de la categoría
            const suggestedIconSpan = document.createElement("span");// Icono de la sugerencia
            suggestedIconSpan.textContent = suggestion.icon;
            suggestedIconSpan.style.fontSize = "16px";
            suggestionEntry.appendChild(suggestedIconSpan);
            // Añadir descripción de la categoría
            const suggestedDescSpan = document.createElement("span");
            suggestedDescSpan.textContent = getCategoryDetails(suggestion.categoryKey).description;
            suggestionEntry.appendChild(suggestedDescSpan);
            suggestionEntry.addEventListener("click", async function handler()
            { // Cambiado a función con nombre 'handler'
                const placeToUpdate = W.model.venues.getObjectById(placeId);
                if (!placeToUpdate)
                {
                    plnLog('error', 'Lugar no encontrado para actualizar categoría.');
                    return;
                }
                try
                {
                    const UpdateObject = (window.require && window.require("Waze/Action/UpdateObject")) || null;
                    if (!UpdateObject) {
                        plnLog('error', 'No se pudo cargar Waze/Action/UpdateObject (SDK no listo).');
                        plnToast('No se pudo aplicar la categoría (SDK no listo).', 3000);
                        return;
                    }
                    const action = new UpdateObject(placeToUpdate, { categories: [suggestion.categoryKey] });
                    W.model.actionManager.add(action);
                                        // Obtener la celda de la categoría original y aplicar un estilo de opacidad
                    const row = document.querySelector(`tr[data-place-id="${placeId}"]`); // Obtener la fila
                    row.dataset.categoryChanged = 'true'; // Marcar fila como modificada
                    // Habilitar el botón de aplicar sugerencia
                    const applyButton = row.querySelector('button[title="Aplicar sugerencia"]');
                    if (applyButton)
                    {
                        applyButton.disabled = false;
                        applyButton.style.opacity = "1";
                    }
                    //Actualizar visualmente la celda de Categoría Actual en la tabla
                    updateCategoryDisplayInTable(placeId, suggestion.categoryKey);

                    // Asegurarse de que la fila existe antes de intentar acceder a sus celdas
                    if (row)
                    {
                        const originalCategoryCell = row.querySelector('td:nth-child(10)'); // La décima columna es "Categoría"
                        if (originalCategoryCell)
                        {
                            originalCategoryCell.style.opacity = '0.5'; // Atenuar la celda completa
                            originalCategoryCell.title += ' (Modificada)'; // Opcional, añadir un tooltip
                        }
                    }
                    // : Mostrar chulito verde en la sugerencia misma
                    const successIcon = document.createElement("span");
                    successIcon.textContent = " ✅";
                    successIcon.style.marginLeft = "5px";
                    suggestionEntry.appendChild(successIcon); // Añadir el chulito a la entrada de la sugerencia
                    suggestionEntry.style.cursor = "default"; // Deshabilitar clic posterior

                    suggestionEntry.removeEventListener("click", handler); // Deshabilita el listener una vez que se ha hecho clic
                    suggestionEntry.style.opacity = "0.7"; // Opcional: Atenúa la sugerencia para indicar que ya se usó

                    optionsListDiv.style.display = "none"; // Ocultar lista
                    searchInput.blur(); // Quitar el foco
                    // : Eliminar la selección temporal para la categoría, ya se guardó
                    tempSelectedCategories.delete(placeId); // Si esta categoría se guardó directamente
                }
                catch (e)
                {
                    plnLog('error', 'Error al actualizar la categoría desde dropdown:', e);
                    plnToast("Error al actualizar la categoría: " + e.message, 3000); // Mantener alerta para errores
                }
            });
        suggestionsWrapper.appendChild(suggestionEntry);
        });
        wrapperDiv.appendChild(suggestionsWrapper); // Añadir contenedor de sugerencias
    }// createRecommendedCategoryDropdown
    //Fin de parte de sugerencias dinámicas
    // Input para buscar
    const searchInput = document.createElement("input");
    searchInput.type = "text";
    searchInput.placeholder = "Buscar o Seleccionar Categoría";// Placeholder más descriptivo
    searchInput.style.width = "calc(100% - 10px)";
    searchInput.style.padding = "5px";
    searchInput.style.marginTop = "5px"; //  Espacio después de sugerencias
    searchInput.style.marginBottom = "5px";
    searchInput.style.border = "1px solid #ccc";
    searchInput.style.borderRadius = "3px";
    searchInput.setAttribute('spellcheck', 'false');// Evitar corrección ortográfica
    searchInput.readOnly = false;// Permitir escribir pero no editar directamente
    searchInput.style.cursor = 'auto';// Permitir escribir pero no editar directamente
    searchInput.style.opacity = '1.0'; // Opacidad normal para el input
    wrapperDiv.appendChild(searchInput); // Añadir el input al wrapper
    // Div que actuará como la lista desplegable de opciones
    const optionsListDiv = document.createElement("div");
    optionsListDiv.style.position = "absolute";
    // Ajuste de top para que aparezca debajo del input, incluso con sugerencias
    optionsListDiv.style.top = "calc(100% + 5px)"; // Se ajusta dinámicamente o se puede hacer con position: relative dentro de un contenedor fijo.
    optionsListDiv.style.left = "0";
    optionsListDiv.style.width = "calc(100% - 2px)";
    optionsListDiv.style.maxHeight = "200px";
    optionsListDiv.style.overflowY = "auto";
    optionsListDiv.style.border = "1px solid #ddd";
    optionsListDiv.style.backgroundColor = "#fff";
    optionsListDiv.style.zIndex = "1001";
    optionsListDiv.style.display = "none";
    optionsListDiv.style.borderRadius = "3px";
    optionsListDiv.style.boxShadow = "0 2px 5px rgba(0,0,0,0.2)";
    wrapperDiv.appendChild(optionsListDiv);
    // --- Populate options list ---
    function populateOptions(filterText = "")
    {
        optionsListDiv.innerHTML = ""; // Clear existing options
        const lowerFilterText = filterText.toLowerCase(); // Normalize filter text for case-insensitive search
        // Sort rules alphabetically by their Spanish description for display
        const sortedRules = [...window.dynamicCategoryRules].sort((a, b) =>
        {
            const descA = (getWazeLanguage() === 'es' && a.desc_es) ? a.desc_es : a.desc_en;
            const descB = (getWazeLanguage() === 'es' && b.desc_es) ? b.desc_es : b.desc_en;
            return descA.localeCompare(descB);
        });
        sortedRules.forEach(rule =>
        {// Iterate through each rule
            const displayDesc = (getWazeLanguage() === 'es' && rule.desc_es) ? rule.desc_es : rule.desc_en;
            if (filterText === "" || displayDesc.toLowerCase().includes(lowerFilterText) || rule.categoryKey.toLowerCase().includes(lowerFilterText))
            {// Check if displayDesc or categoryKey contains the filter text
                const optionDiv = document.createElement("div");
                optionDiv.style.padding = "5px";
                optionDiv.style.cursor = "pointer";
                optionDiv.style.borderBottom = "1px solid #eee";
                optionDiv.style.display = "flex";
                optionDiv.style.alignItems = "center";
                optionDiv.style.gap = "5px";
                optionDiv.title = `Seleccionar: ${displayDesc} (${rule.categoryKey})`;
                // Resaltar si es la categoría actual o la temporalmente seleccionada
                const tempSelectedKey = tempSelectedCategories.get(placeId); // Obtener selección temporal
                if (rule.categoryKey.toUpperCase() === currentCategoryKey.toUpperCase())
                {// Resaltar la categoría actual
                    optionDiv.style.backgroundColor = "#e0f7fa"; // Azul claro para la actual
                    optionDiv.style.fontWeight = "bold";
                }
                else if (tempSelectedKey && rule.categoryKey.toUpperCase() === tempSelectedKey.toUpperCase())  // Resaltar selección temporal
                    optionDiv.style.backgroundColor = "#fffacd"; // Amarillo claro para la seleccionada temporalmente
                else if (dynamicCategorySuggestions.some(s => s.categoryKey.toUpperCase() === rule.categoryKey.toUpperCase()))
                    optionDiv.style.backgroundColor = "#e6ffe6"; // Verde claro para sugerida por el sistema
                const iconSpan = document.createElement("span");// Icono de la categoría
                iconSpan.textContent = rule.icon;
                iconSpan.style.fontSize = "16px";
                optionDiv.appendChild(iconSpan);
                const textSpan = document.createElement("span");// Descripción de la categoría
                textSpan.textContent = displayDesc;
                optionDiv.appendChild(textSpan);// Añadir descripción de la categoría
                optionDiv.addEventListener("mouseenter", () => optionDiv.style.backgroundColor = "#f0f0f0");
                optionDiv.addEventListener("mouseleave", () =>
                {
                    if (tempSelectedKey && rule.categoryKey.toUpperCase() === tempSelectedKey.toUpperCase())
                    {
                        optionDiv.style.backgroundColor = "#fffacd";
                    }
                    else if (rule.categoryKey.toUpperCase() === currentCategoryKey.toUpperCase())
                    {
                        optionDiv.style.backgroundColor = "#e0f7fa";
                    }
                    else if (dynamicCategorySuggestions.some(s => s.categoryKey.toUpperCase() === rule.categoryKey.toUpperCase()))
                    {
                        optionDiv.style.backgroundColor = "#e6ffe6";
                    }
                    else
                    {
                        optionDiv.style.backgroundColor = "#fff";
                    }
                });
                // Añadir evento click para seleccionar la categoría
                optionDiv.addEventListener("click", async () =>
                {
                    const placeToUpdate = W.model.venues.getObjectById(placeId);
                    if (!placeToUpdate)
                    {
                        //console.error("[WME_PLN] Lugar no encontrado para actualizar categoría.");
                        return;
                    }
                    try
                    {
                        const UpdateObject = (window.require && window.require("Waze/Action/UpdateObject")) || null;
                        if (!UpdateObject) {
                            plnLog('error', 'No se pudo cargar Waze/Action/UpdateObject (SDK no listo).');
                            plnToast('No se pudo aplicar la categoría (SDK no listo).', 3000);
                            return;
                        }
                        const action = new UpdateObject(placeToUpdate, { categories: [rule.categoryKey] });
                        W.model.actionManager.add(action);
                        // ✅ CORRECCIÓN: Se declara 'row' aquí, ANTES de su primer uso.
                        const row = document.querySelector(`tr[data-place-id="${placeId}"]`);
                        // Ahora es seguro usar la variable 'row'.
                        if (row)
                        {
                            row.dataset.categoryChanged = 'true'; // Marcar fila como modificada
                            const applyButton = row.querySelector('button[title="Aplicar sugerencia"]');
                            // Habilitar el botón de aplicar sugerencia
                            if (applyButton)
                            {
                                applyButton.disabled = false;
                                applyButton.style.opacity = "1";
                            }
                        }
                        // Actualizar visualmente la celda de Categoría Actual en la tabla
                        updateCategoryDisplayInTable(placeId, rule.categoryKey);
                        // Atenuar la celda de la categoría original
                        if (row)
                        {
                            const categoryCell = row.querySelector('td:nth-child(10)');
                            if (categoryCell)
                            {
                                const currentCategoryDiv = categoryCell.querySelector('div');
                                if (currentCategoryDiv)
                                {
                                    currentCategoryDiv.style.opacity = '0.5';
                                    currentCategoryDiv.title += ' (Modificada)';
                                }
                            }
                        }

                        // Actualizar el valor del input con icono y descripción de la selección
                        searchInput.value = `${rule.icon} ${displayDesc}`;
                        searchInput.style.setProperty('opacity', '1.0', 'important'); // Usar setProperty para asegurar visibilidad

                        // Ocultar la lista de opciones
                        optionsListDiv.style.display = "none";
                        searchInput.blur();

                    }
                    catch (e)
                    {
                       plnLog('error', "[WME_PLN] Error al actualizar la categoría desde dropdown:", e);
                       plnToast("Error al actualizar la categoría: " + e.message, 3000);
                    }
                });
                optionsListDiv.appendChild(optionDiv);
            }
        });
        if (optionsListDiv.childElementCount === 0)
        {// Si no hay opciones que coincidan con el filtro, mostrar mensaje
            const noResults = document.createElement("div");
            noResults.style.padding = "5px";
            noResults.style.color = "#777";
            noResults.textContent = "No hay resultados.";
            optionsListDiv.appendChild(noResults);
        }
    }// populateOptions
    // Limpiamos los listeners anteriores y los reescribimos de forma más robusta.
    let debounceTimer;
    searchInput.addEventListener("input", () =>
    {
        clearTimeout(debounceTimer);
        // Muestra la lista y filtra mientras el usuario escribe.
        debounceTimer = setTimeout(() => {
            populateOptions(searchInput.value);
            optionsListDiv.style.display = "block";
        }, 200);
    });
    searchInput.addEventListener("focus", () =>
    {
        // Al hacer foco, muestra la lista completa.
        populateOptions(searchInput.value);
        optionsListDiv.style.display = "block";
    });
    // Usamos 'mousedown' en lugar de 'click' para cerrar el menú.
    // Esto evita conflictos con el evento 'click' de las opciones.
    document.addEventListener("mousedown", (e) =>
    {
        if (!wrapperDiv.contains(e.target))
        {
            optionsListDiv.style.display = "none";
        }
    });
    populateOptions(""); // Cargar las opciones inicialmente (sin filtro)
    return wrapperDiv;
}// createRecommendedCategoryDropdown
长期地址
遇到问题?请前往 GitHub 提 Issues。