// ==UserScript==
// @name WME Places Name Normalizer
// @namespace https://gf.qytechs.cn/en/users/mincho77
// @version 5.1.0
// @description Normaliza nombres de lugares en Waze Map Editor (WME)
// @author mincho77
// @match https://www.waze.com/*editor*
// @match https://beta.waze.com/*editor*
// @grant GM_xmlhttpRequest
// @connect api.languagetool.org
// @grant unsafeWindow
// @license MIT
// @run-at document-end
// ==/UserScript==
/*global W*/
(() => {
"use strict";
// Variables globales básicas
const SCRIPT_NAME = GM_info.script.name;
const VERSION = GM_info.script.version.toString();
// Inicializar la lista de palabras especiales
let specialWords = JSON.parse(localStorage.getItem("specialWords")) || [];
let maxPlaces = 100;
let normalizeArticles = false;
let placesToNormalize = [];
let wordLists = {
excludeWords : JSON.parse(localStorage.getItem("excludeWords")) || [],
dictionaryWords :
JSON.parse(localStorage.getItem("dictionaryWords")) || []
};
// ==============================================
// Internacionalización (i18n)
// ==============================================
const uiStrings = {
SP : {
activeLangLabel : "Idioma activo:",
normalizeArticlesLabel : "Normalizar artículos (el, la, los, ...)",
useApiLabel : "Usar API de ortografía",
maxPlacesLabel : "Máximo de Places a buscar:",
specialWordsLabel : "Palabras Especiales",
addWordPlaceholder : "Agregar palabra...",
addButton : "Agregar",
searchWordPlaceholder : "Buscar palabra...",
exportWordsButton : "Exportar Palabras",
importListButton : "Importar Lista",
replaceListLabel : "Reemplazar lista actual",
dropZoneText :
"📂 Arrastra aquí tu archivo .txt o .xml para importar palabras especiales",
dictionaryLabel : "Diccionario Ortográfico",
searchDictionaryPlaceholder : "Buscar palabra...",
exportDictionaryButton : "📤 Exportar Diccionario",
importDictionaryButton : "📥 Importar Diccionario",
clearDictionaryButton : "🧹 Limpiar Diccionario",
dictionaryDropZoneText :
"📂 Arrastra aquí tu archivo de palabras del diccionario (.xml o .txt)",
scanButton : "Scan...",
clearSpecialWordsButton : "Eliminar Palabras Especiales",
// Floating Panel Headers & Text
panelTitle : "Normalizador de Nombres",
placesToReview : "lugares para revisar",
applyCol : "Aplicar",
deleteCol : "Eliminar",
typeCol : "Tipo Place",
permaCol : "Perma",
categoryCol : "Categoría", // Nueva traducción
currentNameCol : "Nombre Actual",
normalizedNameCol : "Nombre Normalizado",
problemCol : "Problema Detectado",
actionsCol : "Acciones",
applySelectedButton : "Aplicar Cambios Seleccionados",
cancelButton : "Cancelar",
errorDetailTitle : "error(es) encontrado(s)",
// Panel flotante - Tabla
normalizationLabel : "Normalización",
normalizeButtonLabel : "NrmliZer", // O "Normalizar" si prefieres
excludeWordButtonLabel : "ExcludeWrd", // O "Excluir Palabra"
errorInLabel : "Error en:",
suggestionLabel : "Sugerencia:",
useSuggestionButtonLabel : "Usar sugerencia",
// Tooltips
placeTypeAreaTooltip : "Tipo de Lugar: Área",
// Spinner & Progress
spinnerProcessingMessage : "Procesando lugares...", // Nuevo
spinnerCheckingMessage : "Revisando ortografía...",
spinnerProgressMessage : "% completado",
placesReadyToNormalize :
"lugares marcados para Normalización", // Nuevo contador
placeTypePointTooltip : "Tipo de Lugar: Punto"
},
EN : {
activeLangLabel : "Active Language:",
normalizeArticlesLabel :
"Do not normalize articles (the, a, an, ...)", // Will be hidden
useApiLabel : "Verify Spelling With API",
maxPlacesLabel : "Maximum Places to search:",
specialWordsLabel : "Special Words",
addWordPlaceholder : "Add word...",
addButton : "Add",
searchWordPlaceholder : "Search word...",
exportWordsButton : "Export Words",
importListButton : "Import List",
replaceListLabel : "Replace current list",
dropZoneText :
"📂 Drag your .txt or .xml file here to import special words",
dictionaryLabel : "Spelling Dictionary",
searchDictionaryPlaceholder : "Search word...",
exportDictionaryButton : "📤 Export Dictionary",
importDictionaryButton : "📥 Import Dictionary",
clearDictionaryButton : "🧹 Clear Dictionary",
dictionaryDropZoneText :
"📂 Drag your dictionary words file (.xml or .txt) here",
scanButton : "Scan...",
clearSpecialWordsButton : "Delete Special Words",
// Floating Panel Headers & Text
panelTitle : "Name Normalizer",
placesToReview : "places to review",
applyCol : "Apply",
deleteCol : "Delete",
typeCol : "Place Type",
permaCol : "Perma",
categoryCol : "Category", // Nueva traducción
currentNameCol : "Current Name",
normalizedNameCol : "Normalized Name",
problemCol : "Problem Detected",
actionsCol : "Actions",
applySelectedButton : "Apply Selected Changes",
cancelButton : "Cancel",
errorDetailTitle : "error(s) found",
// Floating Panel - Table
normalizationLabel : "Normalization",
normalizeButtonLabel : "NrmliZer", // Or "Normalize"
excludeWordButtonLabel : "ExcludeWrd", // Or "Exclude Word"
errorInLabel : "Error in:",
suggestionLabel : "Suggestion:",
useSuggestionButtonLabel : "Use suggestion",
// Tooltips
placeTypeAreaTooltip : "Place Type: Area",
// Spinner & Progress
spinnerProcessingMessage : "Processing places...", // New
spinnerCheckingMessage : "Checking spelling...",
spinnerProgressMessage : "% complete",
placesReadyToNormalize :
"places checked to normalize", // Nuevo contador
placeTypePointTooltip : "Place Type: Point"
}
};
// Acá se obtienen los textos según el idioma activo
function S(key)
{
return uiStrings[activeDictionaryLang]?.[key] ||
uiStrings['SP']?.[key] ||
`[${key}]`; // Fallback a SP y luego a la key
}
// Helper para obtener código de idioma para la API
function getApiLangCode(internalLang)
{
return internalLang === 'EN' ? 'en' : 'es';
}
// ==============================================
// Inicialización de idioma y diccionarios
// ==============================================
// Idioma activo: cargado desde memoria o por defecto "SP"
let activeDictionaryLang =
localStorage.getItem("activeDictionaryLang") || "SP";
// Diccionarios por defecto (solo si no hay nada guardado)
const defaultDictionaries = {
SP : { a : [ "árbol" ], b : [ "barco" ] },
EN : { a : [ "apple" ], b : [ "boat" ] }
};
// Diccionario principal, comenzamos con los valores por defecto
const spellDictionaries = {
SP : {...defaultDictionaries.SP },
EN : {...defaultDictionaries.EN }
};
// Si hay datos guardados en localStorage, los sobreescribimos
const savedSP = localStorage.getItem("spellDictionaries_SP");
const savedEN = localStorage.getItem("spellDictionaries_EN");
if (savedSP)
{
try
{
spellDictionaries.SP = JSON.parse(savedSP);
}
catch (e)
{
console.warn(
"❌ Diccionario SP corrupto en memoria, se usará el de ejemplo.");
}
}
if (savedEN)
{
try
{
spellDictionaries.EN = JSON.parse(savedEN);
}
catch (e)
{
console.warn(
"❌ Diccionario EN corrupto en memoria, se usará el de ejemplo.");
}
}
// Crear la lista visible a partir del idioma actual
let dictionaryWords =
Object.values(spellDictionaries[activeDictionaryLang]).flat().sort();
unsafeWindow.debugLang = activeDictionaryLang;
unsafeWindow.debugDict = spellDictionaries;
// Asegura que normalizeArticles se inicialice correctamente según el
// idioma Si es inglés, la normalización de artículos SIEMPRE está
// desactivada.
if (activeDictionaryLang === 'EN')
{
normalizeArticles = false; // Forzar desactivación para inglés
}
else
{
// Para español, cargar desde localStorage o usar valor por defecto
// (true = NO normalizar)
normalizeArticles =
localStorage.getItem('normalizeArticles_SP') !== null
? JSON.parse(localStorage.getItem('normalizeArticles_SP'))
: false;
}
let excludeWords = wordLists.excludeWords || [];
//********************************************************************************************************************************
// Declaración global de placesToNormalize
// --------------------------------------------------------------------------------------------------------------------------------
// Prevención global del comportamiento por defecto en drag & drop
// (Evita que se abra el archivo en otra ventana)
// Se aplican los eventos de arrastre y suelta a todo el documento.
// Se previene el comportamiento por defecto para todos los eventos
// de arrastre y suelta, excepto en el drop-zone.
// Se establece el efecto de arrastre como "none" para evitar
// cualquier efecto visual no deseado.
// --------------------------------------------------------------------------------------------------------------------------------
["dragenter", "dragover", "dragleave", "drop"].forEach((evt) => {
document.addEventListener(evt, (e) => {
// Si el evento ocurre dentro del área de drop-zone, no lo
// bloquea
if (e.target && e.target.closest && e.target.closest("#drop-zone"))
{
return; // Permitir que el drop-zone maneje el evento
}
if (e.target && e.target.closest &&
e.target.closest("#dictionary-drop-zone"))
{
return; // Permitir que el dictionary-drop-zone maneje el evento
}
e.preventDefault(); // Prevenir el comportamiento predeterminado
e.stopPropagation(); // Detener la propagación del evento
}, { capture : true, passive : false });
});
//********************************************************************************************************************************
// Nombre: obtenerListaEsdrujulas
// Fecha modificación: 2025-07-29
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Array<string> - Una lista de palabras esdrújulas comunes.
// Descripción: Devuelve una lista predefinida de palabras esdrújulas en español,
// que siempre deben llevar tilde. Útil para validaciones o mejoras
// en la detección de acentos.
//********************************************************************************************************************************
function obtenerListaEsdrujulas() {
return [
"música",
"pájaro",
"teléfono",
"brújula",
"matemáticas",
"miércoles",
"cámara",
"rápido",
"América", // También nombres propios
"clásico",
"página",
"último",
"oxígeno",
"plátano",
"semáforo",
"clínica",
"vehículos",
"láminas",
"sábado",
"número",
"crédito",
"público",
"ejército",
"océano",
"gramática",
"fábrica",
"máquina",
"plástico",
"cerámica",
"económico",
"política",
"académico",
"simpático",
"fantástico",
"práctico",
"círculo",
"triángulo",
"rectángulo",
"hipótesis",
"análisis",
"síntesis",
"catástrofe",
"metáfora",
"paréntesis",
"helicóptero",
"termómetro",
"kilómetro",
"centímetro",
"milímetro",
"décimo",
"vigésimo",
"trigésimo",
"cuadragésimo",
"quincuagésimo",
"sexagésimo",
"septuagésimo",
"octogésimo",
"nonagésimo",
"centésimo",
"milésimo"
// Puedes añadir más palabras aquí si lo necesitas
];
}
//********************************************************************************************************************************
// Nombre: generarPluralesEsdrujulas
// Fecha modificación: 2025-07-29
// Autor: Chat Asistente (basado en solicitud)
// Entradas: listaSingulares (Array<string>) - Lista de palabras esdrújulas
// en singular. Salidas: Array<string> - Lista combinada de singulares y sus
// plurales correspondientes. Descripción: Genera formas plurales para una
// lista de palabras esdrújulas.
// Maneja casos comunes como añadir 's' a vocales y palabras
// invariables terminadas en 's'. Devuelve una lista única y
// ordenada alfabéticamente.
//********************************************************************************************************************************
function generarPluralesEsdrujulas(listaSingulares)
{
if (!Array.isArray(listaSingulares))
{
console.error(
"generarPluralesEsdrujulas: La entrada debe ser un array.");
return [];
}
const resultadoSet =
new Set(); // Usar un Set para evitar duplicados automáticamente
listaSingulares.forEach(palabra => {
if (typeof palabra !== 'string' || palabra.trim() === '')
return; // Ignorar entradas inválidas
resultadoSet.add(palabra); // Añadir la palabra original
const ultimaLetra = palabra.slice(-1).toLowerCase();
// Regla general para plurales de esdrújulas:
if ('sx'.includes(ultimaLetra))
{
// Palabras terminadas en 's' o 'x' (no agudas) suelen ser
// invariables
resultadoSet.add(
palabra); // Añadir la misma palabra (Set maneja duplicados)
}
else if ('aeiouáéíóú'.includes(ultimaLetra))
{
// Palabras terminadas en vocal: añadir 's'
resultadoSet.add(palabra + 's');
}
// Nota: Es raro que esdrújulas terminen en otras consonantes, pero
// se podrían añadir más reglas aquí si fuera necesario.
});
return Array.from(resultadoSet)
.sort((a, b) => a.localeCompare(b)); // Convertir a array y ordenar
}
// Acá se normaliza una palabra: se pasa a minúsculas y se quitan las tildes
function normalizarPalabra(palabra)
{
return palabra.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // Quitar tildes
.toLowerCase();
}
// =======================
// Integración con API LanguageTool y gestión de sugerencias ortográficas
// =======================
// Estructura interna: cada error ortográfico sugerido por la API se
// almacena así: { word: 'Frayle', suggestion: 'Fraile', origin: 'API',
// message: 'Sugerencia de la API', index: ... }
// Mapa de sugerencias API por placeId (o por nombre si es necesario)
let spellingApiSuggestions = {}; // { [placeId]: [ {word, suggestion,
// origin, message, index} ] }
//********************************************************************************************************************************
// Nombre: checkSpellingWithAPI (Acá se llama a la API de LanguageTool)
// Descripción: Llama a la API de LanguageTool para revisar la ortografía de
// un texto.
// Acá se guardan las sugerencias encontradas para un 'placeId'
// específico, pero NO se aplican automáticamente al nombre.
// Acá se ejecuta un 'callback' con las sugerencias obtenidas.
//********************************************************************************************************************************
function checkSpellingWithAPI(text, lang, placeId, callback)
{
// Limpieza y validación inicial
if (!placeId || !text || typeof callback !== 'function')
{
console.warn(`checkSpellingWithAPI: Faltan parámetros (placeId: ${
!!placeId}, text: "${text}", callback: ${
typeof callback}). Llamando callback(null).`);
if (typeof callback === 'function')
callback(null); // <-- LLAMAR AL CALLBACK AQUÍ
return; // <-- Salir temprano
}
if (!spellingApiSuggestions[placeId])
{
spellingApiSuggestions[placeId] = [];
}
// Llamada a la API
console.log(
`[checkSpellingWithAPI] Initiating GM_xmlhttpRequest for placeId ${
placeId}`); // <-- Nuevo Log
GM_xmlhttpRequest({
method : "POST",
url : "https://api.languagetool.org/v2/check",
headers : { "Content-Type" : "application/x-www-form-urlencoded" },
data : `text=${encodeURIComponent(text)}&language=${
getApiLangCode(lang)}`,
onload : function(response) {
console.log(
`[checkSpellingWithAPI] onload triggered for placeId ${
placeId}, status: ${response.status}`); // <-- Nuevo Log
try
{
if (response.status === 200)
{
const result = JSON.parse(response.responseText);
const suggestions = result.matches.map(
(match) => ({
word : match.context.text.substring(
match.context.offset,
match.context.offset + match.context.length),
suggestion :
match.replacements.length > 0
? match.replacements[0].value
: null, // Mantener null si no hay sugerencia
message : match.message,
rule : match.rule.id,
}));
// Almacenar sugerencias en el objeto global
spellingApiSuggestions[placeId] = suggestions;
// Llamar al callback con las sugerencias
callback(suggestions);
}
else
{
console.error("Error en la API de LanguageTool:",
response.status,
response.statusText);
// safeCallback(null) ya se llama en el catch o al final
// del try
callback(null); // o callback([])
}
}
catch (e)
{ // <--- Añadir catch
console.error("Error procesando respuesta de la API:", e);
callback(null); // Llamar con null si hay error en el try
}
},
onerror : function(error) { // <--- Añadir parámetro error
console.log(
`[checkSpellingWithAPI] onerror triggered for placeId ${
placeId}`); // <-- Nuevo Log
console.error("Error al contactar la API de LanguageTool:",
error);
// Llamar al callback con null o vacío en caso de error de API
callback(null); // o callback([])
},
ontimeout : function() { // Añadir handler de timeout
console.log(
`[checkSpellingWithAPI] ontimeout triggered for placeId ${
placeId}`); // <-- Nuevo Log
console.error("Timeout al contactar la API de LanguageTool.");
callback(null); // Llamar con null en timeout
},
timeout : 30000 // Aumentar timeout a 30 segundos
});
}
// Acá se revisa si la palabra está en las especiales
function esPalabraEspecial(palabra)
{
const palabraNormalizada = normalizarPalabra(palabra);
for (const especial of specialWords)
{
const especialNormalizada = normalizarPalabra(
especial); // Se normaliza la palabra especial para comparar
if (palabraNormalizada === especialNormalizada)
{
return especial; // Retorna la versión oficial con tildes y
// formato
}
}
return null;
}
//********************************************************************************************************************************
// Nombre: debugDictionaries (Acá se depuran los diccionarios)
// Fecha de modificación: 2025-04-15 12:17
// Autor: mincho77
// Entradas: Nada
// Salidas: Nada. Se muestra en la consola el idioma activo y el diccionario
// actual. Descripción: Esta función muestra en la consola el idioma activo
// y el diccionario que se está usando. Sirve para depurar y ver cómo están
// configurados los diccionarios. Permite verificar que el idioma y el
// diccionario se hayan cargado correctamente. Se puede usar directamente
// desde la consola del navegador para revisar el estado.
//*********************************************************************************************************************************
unsafeWindow.debugDictionaries = function ()
{
console.log("Idioma activo:", activeDictionaryLang);
console.log("Diccionario actual:",
spellDictionaries[activeDictionaryLang]);
};
// --- Inicio: Adaptación del código Python para silabificación ---
const VOWEL_SET = new Set('AEIOUaeiouÀÁÄÈÉËÌÍÏÒÓÖÙÚÜàáäèéëìíïòóöùúü');
const OPEN_PLAIN = new Set('aeo');
const OPEN_ACCENTED = new Set('áàéèóò'); // Usando caracteres explícitos
const OPEN_FULL = new Set([...OPEN_PLAIN, ...OPEN_ACCENTED ]);
const CLOSED_PLAIN = new Set('iu');
const CLOSED_ACCENTED = new Set('íìúù'); // Usando caracteres explícitos
const BEFORE_L_GROUP = new Set('bvckfgpt');
const BEFORE_R_GROUP = new Set('bvcdkfgpt');
const FOREIGN_GROUP = new Set('slrnc');
const CONSONANT_PAIRS =
new Set([ 'pt', 'ct', 'cn', 'ps', 'mn', 'gn', 'ft', 'pn', 'cz', 'ts' ]);
function isConsonant(character)
{
return character && !VOWEL_SET.has(character);
}
//********************************************************************************************************************************
// Nombre: onsetPy (Acá se procesa el ataque de la sílaba)
// Descripción: Parte de la adaptación del código Python para
// silabificación. Acá se procesa la parte inicial consonántica (ataque) de
// una sílaba en una palabra dada, empezando desde la posición 'pos'.
//********************************************************************************************************************************
function onsetPy(word, pos)
{
let last_consonant = 'a'; // Equivalente a u'a'
const length = word.length;
while (pos < length && isConsonant(word[pos]) &&
word[pos].toLowerCase() !== 'y')
{
last_consonant = word[pos];
pos += 1;
}
if (length <= pos)
{
return pos;
}
const c1 = word[pos].toLowerCase();
if (pos < length - 1)
{
if (c1 === 'u')
{
if (last_consonant === 'q')
{
pos += 1;
}
else if (last_consonant === 'g')
{
const c2 = word[pos + 1].toLowerCase();
if ('eéií'.includes(c2))
{ // Simple string check for these vowels
pos += 1;
}
}
}
else if (c1 === 'ü' && last_consonant === 'g')
{
pos += 1;
}
}
return pos;
}
function nucleusPy(word, pos)
{
let previous = 0;
const length = word.length;
let stress_found = false;
if (pos >= length)
{
return { pos : pos, stress_found : stress_found };
}
if (word[pos].toLowerCase() === 'y')
{
pos += 1;
}
if (pos < length)
{
const cr = word[pos].toLowerCase();
if (OPEN_ACCENTED.has(cr))
{
stress_found = true;
previous = 0;
pos += 1;
}
else if (OPEN_PLAIN.has(cr))
{
previous = 0;
pos += 1;
}
else if (CLOSED_ACCENTED.has(cr) || cr === 'ü')
{
stress_found = true;
return { pos : pos + 1, stress_found : stress_found };
}
else if (CLOSED_PLAIN.has(cr))
{
previous = 2;
pos += 1;
}
}
let aitch = false;
if (pos < length)
{
const cr = word[pos].toLowerCase();
if (cr === 'h')
{
pos += 1;
aitch = true;
}
}
if (pos < length)
{
const cr = word[pos].toLowerCase();
if (OPEN_FULL.has(cr))
{
if (OPEN_ACCENTED.has(cr))
{
stress_found = true;
}
if (previous === 0)
{
if (aitch)
{
pos -= 1;
}
return { pos : pos, stress_found : stress_found };
}
else
{
pos += 1;
}
}
else if (CLOSED_ACCENTED.has(cr))
{
stress_found = true;
if (previous !== 0)
{
pos += 1;
}
else if (aitch)
{
pos -= 1;
}
return { pos : pos, stress_found : stress_found };
}
else if (CLOSED_PLAIN.has(cr) || cr === 'ü')
{
if (pos < length - 1)
{
const cr_next = word[pos + 1].toLowerCase();
if (!isConsonant(cr_next))
{
if (pos > 0 && word[pos - 1].toLowerCase() === 'h')
{
pos -= 1;
}
return { pos : pos, stress_found : stress_found };
}
}
// Check needed: word[pos-1] might be out of bounds if pos=0
if (pos > 0 &&
word[pos].toLowerCase() !== word[pos - 1].toLowerCase())
{
pos += 1;
}
else if (pos === 0)
{ // If it's the first char, still might need to advance
pos += 1;
}
return { pos : pos, stress_found : stress_found };
}
}
if (pos < length)
{
if (CLOSED_PLAIN.has(word[pos].toLowerCase()))
{
return { pos : pos + 1, stress_found : stress_found };
}
}
return { pos : pos, stress_found : stress_found };
}
function codaPy(word, pos)
{
const length = word.length;
if (pos >= length || !isConsonant(word[pos]))
{
return pos;
}
else if (pos === length - 1)
{
return pos + 1;
}
const c1 = word[pos].toLowerCase();
const c2 = word[pos + 1].toLowerCase();
if (!isConsonant(c2))
{
// Specific check for 'y' acting as vowel sound after consonant
if (c2 === 'y' && pos + 1 === length - 1)
return pos; // e.g., rey -> keep c1 with nucleus
return pos; // Only one consonant in coda
}
if (pos < length - 2)
{
const c3 = word[pos + 2].toLowerCase();
if (!isConsonant(c3))
{ // Pattern C-C-V
if (c1 === 'l' && c2 === 'l')
return pos;
if (c1 === 'c' && c2 === 'h')
return pos;
if (c1 === 'r' && c2 === 'r')
return pos;
if (c1 !== 's' && c1 !== 'r' && c2 === 'h')
return pos;
if (c2 === 'y')
return FOREIGN_GROUP.has(c1) ? pos : pos + 1;
if (BEFORE_L_GROUP.has(c1) && c2 === 'l')
return pos;
if (BEFORE_R_GROUP.has(c1) && c2 === 'r')
return pos;
return pos + 1; // Split C1 | C2-V
}
else
{ // Pattern C-C-C...
if (pos + 3 === length)
{ // Ends in CCC
if (c2 === 'y')
return FOREIGN_GROUP.has(c1) ? pos : pos + 1;
if (c3 === 'y')
return pos + 1; // Ends V-CCy -> VC | Cy
return pos + 3; // Ends V-CCC -> VCCC |
}
// Check next char after CCC
const c4 = word[pos + 3].toLowerCase();
if (!isConsonant(c4))
{ // Pattern C-C-C-V
if (c2 === 'y')
return FOREIGN_GROUP.has(c1) ? pos : pos + 1;
if (CONSONANT_PAIRS.has(word.substring(pos + 1, pos + 3)))
return pos + 1; // V-C | CCV (pair starts syllable)
if ((c3 === 'l' || c3 === 'r') ||
(c2 === 'c' && c3 === 'h') || (c3 === 'y'))
return pos +
1; // V-C | CCV (blend/ch/y starts syllable)
return pos + 2; // V-CC | CV
}
else
{
// Pattern C-C-C-C... very rare, assume split after C2 for
// simplicity
return pos + 2;
}
}
}
else
{ // Pattern C-C at end of word
if (c2 === 'y')
return pos; // Treat final 'y' like a vowel sound attached to C1
return pos + 2; // Ends in CC
}
}
function obtenerSilabasAproximadas(word)
{
if (!word || typeof word !== 'string')
return [];
word = word.normalize("NFC"); // Keep NFC normalization
let pos = 0;
const positions = [];
const length = word.length;
// let stress_found = false; // Ignored for now
// let stressed = 0; // Ignored for now
while (pos < length)
{
positions.push(pos);
pos = onsetPy(word, pos);
let nucleusResult = nucleusPy(word, pos);
pos = nucleusResult.pos;
// stress_found = nucleusResult.stress_found; // Ignored
pos = codaPy(word, pos);
// Stress logic from Python ignored for now
}
positions.push(length);
const syllabes = [];
for (let i = 0; i < positions.length - 1; i++)
{
// Avoid creating empty syllables if positions are duplicated (can
// happen with complex logic)
if (positions[i + 1] > positions[i])
{
syllabes.push(word.substring(positions[i], positions[i + 1]));
}
}
// Filter out potential empty strings just in case
return syllabes.filter(s => s.length > 0);
}
// --- Fin: Adaptación del código Python ---
//***********************************************************************************************************
// Nombre: detectarTilde
// Descripción: Analiza tildes, clasifica y valida acentuación básica
// (simplificado). Versión final revisada para asegurar definición de
// variables y lógica de validación.
//***********************************************************************************************************
function detectarTilde(palabra)
{
// 1. Normalizar la palabra original primero
palabra = palabra.normalize("NFC");
// 2. Limpiar puntuación final común ANTES de cualquier validación
const palabraLimpia =
palabra.replace(/[.,;:)]+$/, ''); // Añadido ')' a la regex
// 3. Validación inicial (AHORA usa palabraLimpia después de declararla)
if (!palabraLimpia || typeof palabraLimpia !== "string" ||
palabraLimpia.trim().length === 0)
{
// Retornar basado en la entrada original si es necesario
return {
tieneTilde : false,
tipo : null,
silabaTildada : null,
esValida : true // Considerar palabra vacía o inválida como
// "válida" para no marcarla como error
};
}
// --- Resto de la lógica usando palabraLimpia ---
// Definiciones de vocales con y sin tilde
const vocalesConTilde = "áéíóúÁÉÍÓÚ";
const vocales = "aeiouáéíóúÁÉÍÓÚ";
// Información sobre la última letra para reglas de acentuación
const ultimaLetra = palabraLimpia
.slice(-1) // <-- Usar palabraLimpia
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
const terminaEnVocalNS =
[ 'a', 'e', 'i', 'o', 'u', 'n', 's' ].includes(ultimaLetra);
// Obtiene una aproximación de las sílabas
const silabas =
obtenerSilabasAproximadas(palabraLimpia); // <-- Usar palabraLimpia
const totalSilabas = silabas.length;
// Detecta si hay tilde gráfica y en qué sílaba
let silabaTildadaIndex = -1; // Índice base 0
for (let i = 0; i < silabas.length; i++)
{
for (let char of silabas[i])
{
// Busca si el carácter es una vocal tildada (usando la variable
// definida)
if (vocalesConTilde.includes(char))
{
silabaTildadaIndex = i;
break; // Tilde encontrada
}
}
if (silabaTildadaIndex !== -1)
break; // Tilde encontrada en alguna sílaba
}
const tieneTilde = silabaTildadaIndex !== -1;
let tipo = null; // Tipo de palabra (aguda, grave, etc.)
let esValida = false; // Indica si cumple reglas de acentuación
let silabaTildadaOrdinal = null; // Número ordinal (base 1)
if (tieneTilde)
{
// Si tiene tilde, el tipo y la validación se basan en su posición.
silabaTildadaOrdinal = silabaTildadaIndex + 1;
const posicionDesdeFinal =
totalSilabas -
silabaTildadaOrdinal; // 0=última, 1=penúltima, etc.
// *** NUEVO: Chequeo prioritario para hiato acentual con vocal abierta ***
const silabaConTilde = silabas[silabaTildadaIndex];
let caracterTildado = '';
for (let char of silabaConTilde) {
if (vocalesConTilde.includes(char)) {
caracterTildado = char;
break;
}
}
// Verificación de hiato con vocal débil acentuada (í, ú)
const hiatoConVocalDebil = () => {
if (!tieneTilde || silabaTildadaIndex === -1)
return false;
const silaba = silabas[silabaTildadaIndex];
const vocalDebilTildada =
/[íúÍÚ]/i.test(silaba); // Incluir mayúsculas
if (!vocalDebilTildada)
return false;
const anterior = silabas[silabaTildadaIndex - 1] || "";
const siguiente = silabas[silabaTildadaIndex + 1] || "";
const vocalFuerte = /[aeoáéó]/i;
return vocalFuerte.test(anterior.slice(-1)) ||
vocalFuerte.test(siguiente);
};
// *** MODIFICADO: Si la tilde está en vocal abierta (á,é,ó), es válida (hiato) ***
if (OPEN_ACCENTED.has(caracterTildado.toLowerCase())) // <-- Chequea si es á, é, ó
{
tipo = "hiato_abierta_acentuada"; // Nuevo tipo para claridad
esValida = true; // Tilde en vocal abierta para hiato es válida
}
else if (hiatoConVocalDebil()) // <-- Ahora es else if
{
tipo = "hiato_debil_acentuada"; // Podría ser grave o aguda
// dependiendo de la estructura
esValida = true; // Hiato con tilde en débil siempre es válido
}
else if (totalSilabas === 1)
{
tipo = "monosílaba";
esValida =
true; // Monosílabos con tilde (diacrítica) asumidos válidos.
}
else if (posicionDesdeFinal === 0)
{
tipo = "aguda";
esValida = terminaEnVocalNS; // Aguda con tilde es válida si
// termina en vocal/n/s
}
else if (posicionDesdeFinal === 1)
{
tipo = "grave";
esValida = !terminaEnVocalNS; // Grave con tilde es válida si NO
// termina en vocal/n/s
}
else if (posicionDesdeFinal === 2)
{
tipo = "esdrújula";
esValida = true; // Esdrújulas siempre llevan tilde y son
// válidas si las tienen.
}
else
{ // posicionDesdeFinal >= 3
tipo = "sobresdrújula";
esValida = true; // Sobresdrújulas siempre llevan tilde y son
// válidas si las tienen.
}
}
else // No tiene tilde
{
silabaTildadaOrdinal = null; // No hay sílaba tildada
if (totalSilabas <= 1)
{ // Considerar palabras de 0 o 1 sílaba como monosílabas
tipo = "monosílaba";
esValida = true; // Monosílabos sin tilde son válidos.
}
else
{
// Clasificar tipo por acento natural según terminación
// *** Lógica corregida para determinar tipo sin tilde ***
if (terminaEnVocalNS) { // Si termina en vocal, n, o s -> Naturalmente grave
tipo = "grave"; // Acento natural en penúltima
}
else
{
tipo = "aguda"; // Acento natural en última
}
// *** NUEVO: Heurística para palabras terminadas en -ción/-sión ***
// Si termina en 'cion' o 'sion' y no tiene tilde, es casi seguro que es aguda.
const lowerPalabraLimpia = palabraLimpia.toLowerCase();
if (lowerPalabraLimpia.endsWith('cion') || lowerPalabraLimpia.endsWith('sion')) {
tipo = "aguda"; // Corregir tipo a aguda
}
// Validar si DEBERÍA llevar tilde pero no la tiene
if ((tipo === "aguda" && terminaEnVocalNS) ||
(tipo === "grave" && !terminaEnVocalNS))
// Ya no se asume esdrújula aquí, las esdrújulas SIEMPRE deben tener tilde gráfica
{
esValida = false; // Debería llevar tilde pero no la tiene
}
else
{
esValida = true; // Es válida sin tilde
}
}
}
// Validación extra: hiato obligatorio (palabras conocidas)
if (!tieneTilde && esHiatoObligatorio(palabraLimpia))
{
tipo = "hiato_debil_acentuada_forzada";
esValida = false;
}
if (!esValida)
{
// Usar 'palabra' (original con posible puntuación) para el log de
// error
console.log(`❌ Palabra: ${palabra}, Tipo: ${tipo}, Tilde en: ${
silabaTildadaOrdinal ||
'N/A'}, Total Sílabas: ${totalSilabas}, Es válida: ${esValida}`);
}
return {
tieneTilde,
tipo,
silabaTildada : silabaTildadaOrdinal,
esValida
};
}
window.detectarTilde = detectarTilde;
function validarReglasAcentuacion(tipo, ultimaLetra, tieneTilde)
{
if (tipo === "aguda")
{
// Agudas llevan tilde si terminan en vocal, "n" o "s"
return (tieneTilde && /[nsaeiou]$/.test(ultimaLetra)) ||
(!tieneTilde && !/[nsaeiou]$/.test(ultimaLetra));
}
else if (tipo === "grave")
{
// Graves llevan tilde si NO terminan en vocal, "n" o "s"
return (!tieneTilde && /[nsaeiou]$/.test(ultimaLetra)) ||
(tieneTilde && !/[nsaeiou]$/.test(ultimaLetra));
}
else if (tipo === "esdrújula")
{
// Esdrújulas siempre llevan tilde
return tieneTilde;
}
return true; // Otros casos (monosílabos, sobresdrújulas, etc.)
}
// =======================
// Normalización y gestión de sugerencias ortográficas
// =======================
// Esta función NO debe aplicar sugerencias API automáticamente
function normalizePlaceName(originalName, placeId, callback)
{
// Aquí va la lógica de normalización local: tildes, diccionario, etc.
// ...
// Si se desea usar la API, sólo agregar sugerencias, NO reemplazar el
// valor
const useApi = document.getElementById("useSpellingAPI")?.checked;
if (useApi)
{
checkSpellingWithAPI(originalName,
activeDictionaryLang,
placeId,
function(apiSuggestions) {
// Almacenar sugerencias API en el formato
// requerido
spellingApiSuggestions[placeId] =
apiSuggestions.map(
s => ({
original : s.word,
suggestion : s.suggestion,
message : s.message,
origin : "API"
}));
// No se hace ningún reemplazo aquí, sólo
// se pasa la sugerencia al callback/panel
if (typeof callback === "function")
callback({
normalized : originalName,
apiSuggestions : apiSuggestions
});
});
}
else
{
// Normalización tradicional aquí (si aplica)
if (typeof callback === "function")
callback({ normalized : originalName, apiSuggestions : [] });
}
}
// =======================
// Panel flotante: renderizado de errores ortográficos y sugerencias API
// =======================
// Renderiza el panel flotante de errores ortográficos, incluyendo
// sugerencias de origen API
function openFloatingPanel(placesToNormalize)
{
// Cierra panel previo si existe
const existingPanel =
document.getElementById("normalizer-floating-panel");
if (existingPanel)
{
existingPanel.remove();
}
const panel = document.createElement("div");
panel.id = "normalizer-floating-panel";
panel.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 90%; max-width: 1200px; max-height: 80vh; background: white;
padding: 0; border-radius: 8px; box-shadow: 0 0 25px rgba(0,0,0,0.4);
z-index: 10000; overflow-y: auto; font-family: Arial, sans-serif;
`;
let html = `
<style>
#normalizer-table { width: 100%; border-collapse: collapse; margin: 15px 0; }
#normalizer-table th { background: #2c3e50; color: white; padding: 10px; text-align: left; }
#normalizer-table td { padding: 8px 10px; border-bottom: 1px solid #eee; vertical-align: top; }
.normalize-btn, .apply-btn, .add-exclude-btn { padding: 8px 16px; margin: 2px; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; transition: all 0.3s; }
.normalize-btn { background: #3498db; color: white; }
.apply-btn { background: #2ecc71; color: white; }
.add-exclude-btn { background: #e67e22; color: white; }
.close-btn { position: absolute; top: 15px; right: 15px; background: #e74c3c; color: white; border: none; width: 30px; height: 30px; border-radius: 50%; font-weight: bold; cursor: pointer; z-index: 11; }
input[type="checkbox"] { transform: scale(1.3); margin: 0 5px; }
input[type="text"] { width: 100%; padding: 5px; box-sizing: border-box; }
</style>
<div style="padding: 20px;">
<button class="close-btn" id="close-panel-btn">×</button>
<h2 style="color: #2c3e50; margin-top: 0;">Normalizador de Nombres</h2>
<table id="normalizer-table">
<thead>
<tr>
<th>Aplicar</th>
<th>Eliminar</th>
<th>Nombre Actual</th>
<th>Nombre Normalizado</th>
<th>Problemas Detectados</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
`;
placesToNormalize.forEach((place, index) => {
// Asegura incluir spellingWarnings y hasSpellingWarning desde
// spellingApiSuggestions
place.spellingWarnings =
spellingApiSuggestions[place.placeId] || [];
place.hasSpellingWarning = place.spellingWarnings.length > 0;
const {
originalName,
newName,
hasSpellingWarning,
spellingWarnings = []
} = place;
html += `
<tr>
<td><input type="checkbox" class="normalize-checkbox" data-index="${
index}"></td>
<td><input type="checkbox" class="delete-checkbox" data-index="${
index}"></td>
<td>${originalName}</td>
<td><input type="text" class="new-name-input" value="${
newName}" data-index="${index}"></td>
<td>${
hasSpellingWarning ? `${spellingWarnings.length} errores`
: "Ninguno"}</td>
<td>
<button class="normalize-btn" data-index="${
index}">Normalizar</button>
<button class="add-exclude-btn" data-word="${
originalName}" data-index="${index}">Excluir</button>
</td>
</tr>
`;
if (spellingWarnings.length > 0)
{
html += `
<tr>
<td colspan="6">
<details>
<summary>${
spellingWarnings.length} errores encontrados</summary>
<ul>
${
spellingWarnings
.map(warning => `
<li>
Error en "${warning.original}": ${
warning.message}<br>
Sugerencia: <strong>${
warning.suggestion}</strong>
<button class="apply-suggestion-btn" data-index="${
index}" data-suggestion="${
warning.suggestion}">Aplicar</button>
</li>
`)
.join('')}
</ul>
</details>
</td>
</tr>
`;
}
});
html += `
</tbody>
</table>
<div style="text-align: right;">
<button id="apply-all-btn" style="background: #27ae60; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-weight: bold;">Aplicar Cambios</button>
<button id="cancel-btn" style="background: #e74c3c; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-weight: bold;">Cancelar</button>
</div>
</div>
`;
panel.innerHTML = html;
document.body.appendChild(panel);
// Cerrar panel
document.getElementById("close-panel-btn")
.addEventListener("click", () => panel.remove());
// Aplicar todos los cambios
document.getElementById("apply-all-btn")
.addEventListener("click", () => {
const selectedChanges = placesToNormalize.filter((_, index) => {
const checkbox = panel.querySelector(
`.normalize-checkbox[data-index="${index}"]`);
return checkbox && checkbox.checked;
});
if (selectedChanges.length === 0)
{
alert("No se seleccionaron cambios para aplicar.");
return;
}
console.log("Aplicando cambios:", selectedChanges);
panel.remove();
});
// Cancelar
document.getElementById("cancel-btn")
.addEventListener("click", () => panel.remove());
// Aplicar sugerencias
panel.querySelectorAll(".apply-suggestion-btn").forEach(btn => {
btn.addEventListener("click", function() {
const index = this.dataset.index;
const suggestion = this.dataset.suggestion;
const input =
panel.querySelector(`.new-name-input[data-index="${index}"]`);
if (input)
{
input.value = suggestion;
const checkbox = panel.querySelector(
`.normalize-checkbox[data-index="${index}"]`);
if (checkbox)
checkbox.checked = true;
}
});
});
// Normalizar individualmente
panel.querySelectorAll(".normalize-btn").forEach(btn => {
btn.addEventListener("click", function() {
const index = this.dataset.index;
const input =
panel.querySelector(`.new-name-input[data-index="${index}"]`);
if (input)
{
input.value = input.value.trim()
.toUpperCase(); // Ejemplo de normalización
const checkbox = panel.querySelector(
`.normalize-checkbox[data-index="${index}"]`);
if (checkbox)
checkbox.checked = true;
}
});
});
// Excluir palabras
panel.querySelectorAll(".add-exclude-btn").forEach(btn => {
btn.addEventListener("click", function() {
const word = this.dataset.word;
console.log(`Palabra excluida: ${word}`);
});
});
}
//********************************************************************************************************************************
// Nombre: showNoPlacesFoundMessage (Acá se muestra un mensaje si no se
// encuentran lugares) Fecha de modificación: 2025-04-10 Autor: mincho77
// Entradas: Nada
// Salidas: Nada. Se crea un modal que informa que no se encontraron lugares
// con los criterios actuales. Descripción: Muestra un mensaje modal cuando
// la búsqueda de lugares no devuelve resultados.
// Incluye un botón para cerrar el modal. Sirve para informar
// al usuario.
//********************************************************************************************************************************
function showNoPlacesFoundMessage()
{ // Se crea el elemento div para el modal
const modal = document.createElement("div");
modal.className = "no-places-modal-overlay";
modal.innerHTML = `
<div class="no-places-modal">
<div class="no-places-header">
<h3>⚠️ No se encontraron lugares</h3>
</div>
<div class="no-places-body">
<p>No se encontraron lugares que cumplan con los criterios actuales.</p>
<p>Intenta ajustar los filtros o ampliar el área de búsqueda.</p>
</div>
<div class="no-places-footer">
<button id="close-no-places-btn" class="no-places-btn">Aceptar</button>
</div>
</div>
`;
// Se agrega el modal al cuerpo del documento
document.body.appendChild(modal);
// Se maneja el evento de clic en el botón para cerrar el modal
document.getElementById("close-no-places-btn")
.addEventListener("click", () => { modal.remove(); });
}
// Estilos CSS para el mensaje
const noPlacesStyles = `
<style>
.no-places-modal-overlay
{
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
animation: fadeIn 0.3s ease-in-out;
}
.no-places-modal {
background: #fff;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 400px;
overflow: hidden;
animation: slideIn 0.3s ease-in-out;
text-align: center;
padding: 20px;
}
.no-places-header {
background: #f39c12;
color: white;
padding: 15px;
font-size: 18px;
font-weight: bold;
border-radius: 10px 10px 0 0;
}
.no-places-body {
padding: 20px;
font-size: 14px;
color: #333;
}
.no-places-footer {
padding: 15px;
background: #f4f4f4;
text-align: center;
}
.no-places-btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
background: #3498db;
color: white;
transition: background 0.3s, transform 0.2s;
}
.no-places-btn:hover {
background: #2980b9;
transform: scale(1.05);
}
/* Animaciones */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
#dictionary-drop-zone
{
border: 2px dashed #ccc;
padding: 10px;
margin: 10px;
text-align: center;
font-style: italic;
color: #555;
background-color: #f8f9fa;
}
@keyframes slideIn {
from {
transform: translateY(-20px);
}
to {
transform: translateY(0);
}
}
</style>
`;
// Insertar los estilos en el documento
document.head.insertAdjacentHTML("beforeend", noPlacesStyles);
//********************************************************************************************************************************
// Nombre: showModal (Acá se muestra un modal personalizado)
// Fecha de modificación: 2025-04-10
// Autor: mincho77
// Entradas:
// - title (string): Título del modal.
// - message (string): Mensaje a mostrar.
// - confirmText (string): Texto del botón de confirmar.
// - cancelText (string): Texto del botón de cancelar.
// - onConfirm (function): Qué hacer al confirmar.
// - onCancel (function): Qué hacer al cancelar.
// - type (string): Tipo de modal (info, error, warning, question, success).
// - autoClose (number): Tiempo en ms para que se cierre solo.
// - prependText (string): Texto extra antes del mensaje.
// Salidas: Nada. Se crea un modal personalizado con título, mensaje y
// botones. Descripción: Esta función crea un modal personalizado que se
// muestra en la pantalla con un título, un mensaje y botones de
// confirmación y cancelación. El modal se puede personalizar con diferentes
// tipos (info, error, warning, question, success) y se puede cerrar
// automáticamente después de un tiempo especificado. Permite al usuario
// interactuar con el modal y ejecutar funciones específicas al hacer clic
// en los botones. Se utiliza para mostrar mensajes de advertencia,
// información o error al usuario. El modal se cierra automáticamente
// después de un tiempo especificado si se indica.
//********************************************************************************************************************************
function showModal({
title,
message,
confirmText,
cancelText,
onConfirm,
onCancel,
type = "info",
autoClose = null,
prependText = "",
})
{
// Se determina el ícono según el tipo de modal
let icon;
switch (type)
{
case "error":
icon = "⛔";
break;
case "warning":
icon = "⚠️";
break;
case "info":
icon = "ℹ️";
break;
case "question":
icon = "❓";
break;
case "success":
icon = "✅";
break;
default:
icon = "ℹ️";
break;
}
const fullMessage = message.replace("{prependText}", prependText);
// Se crea el elemento div para el modal
const modal = document.createElement("div");
modal.className = "custom-modal-overlay";
modal.innerHTML = `
<div class="custom-modal">
<div class="custom-modal-header">
<h3>${icon} ${title}</h3>
<button class="close-modal-btn" title="Cerrar">×</button>
</div>
<div class="custom-modal-body">
<p>${fullMessage}</p>
</div>
<div class="custom-modal-footer">
${
cancelText
? `<button id="modal-cancel-btn" class="modal-btn cancel-btn">${
cancelText}</button>`
: ""}
${
confirmText
? `<button id="modal-confirm-btn" class="modal-btn confirm-btn">${
confirmText}</button>`
: ""}
</div>
</div>
`;
// Se agrega el modal al cuerpo del documento
document.body.appendChild(modal);
// Se manejan los eventos de clic en los botones
if (confirmText)
{
document.getElementById("modal-confirm-btn")
.addEventListener("click", () => {
if (onConfirm)
onConfirm(); // Ejecutar la función de confirmación
modal.remove(); // Cerrar el modal
});
}
if (cancelText)
{
document.getElementById("modal-cancel-btn")
.addEventListener("click", () => {
if (onCancel)
onCancel(); // Ejecutar la función de cancelación
modal.remove(); // Cerrar el modal
});
}
// Se cierra el modal al hacer clic en el botón de cerrar (X)
modal.querySelector(".close-modal-btn")
.addEventListener("click", () => { modal.remove(); });
// Se cierra automáticamente si se especificó un tiempo en autoClose
if (autoClose)
{
setTimeout(() => { modal.remove(); }, autoClose);
}
}
// Estilos CSS para el modal
const modalStyles = `
<style>
.custom-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
animation: fadeIn 0.3s ease-in-out;
}
.custom-modal {
background: #fff;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 400px;
overflow: hidden;
animation: slideIn 0.3s ease-in-out;
}
.custom-modal-header {
background: #3498db;
color: white;
padding: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.custom-modal-header h3 {
margin: 0;
font-size: 18px;
}
.close-modal-btn {
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
transition: color 0.3s;
}
.close-modal-btn:hover {
color: #e74c3c;
}
.custom-modal-body {
padding: 20px;
font-size: 14px;
color: #333;
text-align: center;
}
.custom-modal-footer {
display: flex;
justify-content: space-between;
padding: 15px;
background: #f4f4f4;
}
.modal-btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: background 0.3s, transform 0.2s;
}
.confirm-btn {
background: #27ae60;
color: white;
}
.confirm-btn:hover {
background: #2ecc71;
transform: scale(1.05);
}
.cancel-btn {
background: #e74c3c;
color: white;
}
.cancel-btn:hover {
background: #c0392b;
transform: scale(1.05);
}
/* Animaciones */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
transform: translateY(-20px);
}
to {
transform: translateY(0);
}
}
</style>
`;
// Insertar los estilos en el documento
document.head.insertAdjacentHTML("beforeend", modalStyles);
//********************************************************************************************************************************
// Nombre: openEditPopup (Acá se abre un popup para editar una palabra)
// Fecha de modificación: 2025-04-22 06:00
// Autor: mincho77
// Entradas:
// - index (number): Índice de la palabra a editar.
// - listType (string): Tipo de lista ("excludeWords" o "dictionaryWords").
// Salidas: Nada. Se abre un popup para editar una palabra en la lista
// especificada. Descripción: Esta función abre un popup que permite al
// usuario modificar una palabra de la lista (ya sea de las excluidas o del
// diccionario). Se valida que la palabra no esté vacía y que no sea
// duplicada. o su diccionario ortográfico personalizado.
//********************************************************************************************************************************
function openEditPopup(index, listType = "excludeWords")
{
const wordList =
listType === "dictionaryWords" ? dictionaryWords : excludeWords;
const wordToEdit = wordList[index];
if (!wordToEdit)
{
// Se muestra un error si no se encuentra la palabra en el índice
// dado
console.error(`No se encontró la palabra en el índice ${index}`);
return;
}
showModal({
title : "Editar palabra",
message : `<input type="text" id="editWordInput" value="${
wordToEdit}" style="width: 95%; padding: 5px; border-radius: 4px; border: 1px solid #ccc;">`,
confirmText : "Guardar",
cancelText : "Cancelar",
type : "question",
onConfirm : () => {
const newWord =
// Se obtiene el nuevo valor del input
document.getElementById("editWordInput").value.trim();
if (!newWord)
{
showModal({
title : "Error",
message : "La palabra no puede estar vacía.",
confirmText : "Aceptar",
type : "error"
});
return;
}
// Se verifica si la nueva palabra ya existe en la lista (y no
// es la misma palabra original)
if (wordList.includes(newWord) && wordList[index] !== newWord)
{
showModal({
title : "Duplicada",
message : "Esa palabra ya está en la lista.",
confirmText : "Aceptar",
type : "warning"
});
return;
}
// Se actualiza la palabra en la lista correspondiente
wordList[index] = newWord;
if (listType === "dictionaryWords")
{
// *** Lógica corregida para guardar en el diccionario estructurado ***
const originalWord = wordToEdit; // Palabra antes de editar
const currentDictLang = spellDictionaries[activeDictionaryLang];
// 1. Eliminar la palabra original de la estructura
const originalLetter = originalWord.charAt(0).toLowerCase();
if (currentDictLang && currentDictLang[originalLetter]) {
const originalIndexInLetter = currentDictLang[originalLetter].indexOf(originalWord);
if (originalIndexInLetter > -1) {
currentDictLang[originalLetter].splice(originalIndexInLetter, 1);
// Si la letra queda vacía, eliminarla
if (currentDictLang[originalLetter].length === 0) {
delete currentDictLang[originalLetter];
}
}
}
// 2. Agregar la nueva palabra a la estructura
const newLetter = newWord.charAt(0).toLowerCase();
if (!currentDictLang[newLetter]) {
currentDictLang[newLetter] = [];
}
if (!currentDictLang[newLetter].includes(newWord)) { // Evitar duplicados al agregar
currentDictLang[newLetter].push(newWord);
currentDictLang[newLetter].sort(); // Mantener ordenado
}
// 3. Guardar la estructura actualizada en localStorage con la clave correcta
localStorage.setItem(`spellDictionaries_${activeDictionaryLang}`, JSON.stringify(currentDictLang));
// 4. Actualizar la lista plana global
dictionaryWords = Object.values(currentDictLang).flat().sort();
renderDictionaryWordsPanel(); // Re-renderizar el panel
}
else
{
wordLists.excludeWords = excludeWords;
localStorage.setItem("excludeWords",
JSON.stringify(excludeWords));
renderExcludedWordsPanel();
}
// Se muestra un mensaje de éxito
showModal({
title : "Actualizada",
message : "La palabra fue modificada correctamente.",
confirmText : "Aceptar",
type : "success",
autoClose : 2000
});
}
});
}
//***********************************************************************************************************************************************************
// Nombre: waitForElement (Acá se espera a que un elemento aparezca en el
// DOM) Fecha de modificación: 2025-04-10 Autor: mincho77 Entradas:
// - selector (string): El selector CSS del elemento que se espera.
// - callback (function): Función que se ejecuta cuando se encuentra el
// elemento.
// - interval (number, opcional): Tiempo en ms entre cada intento (defecto:
// 300ms).
// - maxAttempts (number, opcional): Número máximo de intentos (defecto:
// 20). Salidas: Nada. Se ejecuta el callback con el elemento encontrado o
// se muestra una advertencia si no se encuentra. Descripción: Esta función
// espera a que un elemento (definido por un selector CSS) aparezca en el
// DOM. Se revisa cada cierto tiempo (interval) hasta un número máximo de
// veces (maxAttempts). Si se encuentra el elemento, se llama a la función
// 'callback' pasándole el elemento.
// como argumento. Si no se encuentra después de los
// intentos máximos, se detiene y se muestra una advertencia en la consola.
// Esto es útil para asegurarse de que elementos dinámicos estén disponibles
// antes de asignarles event listeners o manipularlos.
//***********************************************************************************************************************************************************
function waitForElement(
selector, callback, interval = 300, maxAttempts = 20)
{
let attempts = 0;
const checkExist =
setInterval(() => { // Se crea un intervalo para revisar
const element = document.querySelector(selector);
attempts++;
if (element)
{ // Si se encuentra el elemento
clearInterval(checkExist);
callback(element);
}
else if (attempts >= maxAttempts)
{ // Si se supera el número máximo de intentos
clearInterval(checkExist); // Se detiene la búsqueda
console.warn(`No se encontró el elemento ${
selector} después de ${maxAttempts} intentos.`);
}
}, interval);
}
//********************************************************************************************************************************
// Nombre: handleLanguageChange (Acá se maneja el cambio de idioma)
// Fecha de modificación: 2025-05-02
// Hora: 06:45
// Autor: mincho77
// Entradas: Event (opcional) - El evento 'change' del selector.
// Descripción: Maneja la lógica cuando el usuario cambia el idioma en el
// selector.
//********************************************************************************************************************************
function handleLanguageChange()
{
const selector = document.getElementById("dictionaryLanguageSelect");
if (!selector)
return; // Salir si el selector no existe
// Se guarda el estado de normalizeArticles específico para español
if (activeDictionaryLang === 'SP')
{
localStorage.setItem('normalizeArticles_SP',
JSON.stringify(normalizeArticles));
}
// Forzar normalizeArticles a false si el idioma es inglés
if (activeDictionaryLang === 'EN')
{
normalizeArticles = false;
}
const previousLang = activeDictionaryLang;
const newLang = selector.value;
if (previousLang &&
spellDictionaries[previousLang]) // Si había un idioma anterior y
// diccionario
{
// Guardar el diccionario del idioma anterior
localStorage.setItem(
`spellDictionaries_${previousLang}`,
JSON.stringify(spellDictionaries[previousLang]));
// Guardar estado de normalizeArticles si era español
if (previousLang === 'SP')
{
localStorage.setItem('normalizeArticles_SP',
JSON.stringify(normalizeArticles));
}
}
activeDictionaryLang = newLang; // Se actualiza el idioma activo
localStorage.setItem("activeDictionaryLang", activeDictionaryLang);
// Se carga el diccionario del nuevo idioma desde localStorage
const storedDictionary = JSON.parse(
localStorage.getItem(`spellDictionaries_${activeDictionaryLang}`));
// Si hay diccionario guardado, se usa
if (storedDictionary)
{
spellDictionaries[activeDictionaryLang] = storedDictionary;
}
else if (!spellDictionaries[activeDictionaryLang] ||
Object.keys(spellDictionaries[activeDictionaryLang]).length ===
0)
{ // Si no hay diccionario guardado o está vacío, se usan los valores
// por defecto
if (activeDictionaryLang === "SP")
{
spellDictionaries.SP = { a : [ "árbol" ], b : [ "barco" ] };
}
else if (activeDictionaryLang === "EN")
{
spellDictionaries.EN = { a : [ "apple" ], b : [ "boat" ] };
}
// Se guarda el diccionario por defecto en localStorage
localStorage.setItem(
`spellDictionaries_${activeDictionaryLang}`,
JSON.stringify(spellDictionaries[activeDictionaryLang]));
}
// Se actualiza la lista plana de palabras del diccionario
dictionaryWords = // Se actualiza la lista plana de palabras del
// diccionario
Object.values(spellDictionaries[activeDictionaryLang]).flat().sort();
// Actualizar estado de normalizeArticles según el nuevo idioma
if (activeDictionaryLang === 'EN')
{
normalizeArticles = false; // Desactivar para inglés
}
else
{
// Cargar estado guardado para español o usar default
normalizeArticles =
localStorage.getItem('normalizeArticles_SP') !== null
? JSON.parse(localStorage.getItem('normalizeArticles_SP'))
: true;
}
// --- INICIO: Se refresca la interfaz sin volver a registrar la pestaña
// ---
const pane =
document.getElementById("normalizer-tab-pane"); // Usar el ID asignado
if (pane)
{
pane.innerHTML =
getSidebarHTML(); // Regenerar contenido con nuevo idioma
// listeners
waitForElement("#normalizeArticles", () => {
console.log(`[${
SCRIPT_NAME}] ✅ Refreshing sidebar events after lang change`);
attachEvents(); // Re-adjunta listeners generales
initSearchSpecialWords(); // Re-inicializa búsqueda palabras
// especiales
renderDictionaryWordsPanel(); // Re-renderiza panel diccionario
attachDictionarySearch(); // Re-adjunta búsqueda diccionario
// Se asegura que el dropdown muestre el idioma correcto Y SE
// VUELVE A ADJUNTAR EL LISTENER
waitForElement("#dictionaryLanguageSelect", (sel) => {
sel.value = activeDictionaryLang;
attachLanguageChangeListener();
});
attachDetailsToggleListeners();
});
}
else
{
console.error(
"No se encontró el panel de la pestaña para refrescar.");
}
// --- FIN: Se refresca la interfaz ---
// console.log("Idioma activo:", activeDictionaryLang);
console.log("Datos del diccionario:",
spellDictionaries[activeDictionaryLang]);
}
//***********************************************************************************************************************************************************
// Nombre: attachLanguageChangeListener (Acá se adjunta el listener para el
// cambio de idioma) Fecha de modificación: 2025-07-27 Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna.
// Descripción: Busca el selector de idioma y le adjunta el listener
// 'handleLanguageChange'.
//***********************************************************************************************************************************************************
function attachLanguageChangeListener()
{
const selector = document.getElementById("dictionaryLanguageSelect");
if (selector)
{
// Remover listener anterior por si acaso (evita duplicados si algo
// sale mal) // Se quita el listener anterior por si acaso
selector.removeEventListener('change', handleLanguageChange);
selector.addEventListener(
'change', handleLanguageChange); // Se adjunta el listener
console.log(`[${SCRIPT_NAME}] Language change listener attached.`);
}
else
{
console.error(
"attachLanguageChangeListener: No se encontró #dictionaryLanguageSelect.");
}
}
//***********************************************************************************************************************************************************
// Nombre: attachDetailsToggleListeners (Acá se conectan los listeners para
// desplegar detalles)
// Fecha modificación: 2025-07-27
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna.
// Descripción: Adjunta los listeners 'toggle' a los elementos <details>
// para animar las flechas de despliegue.
//***********************************************************************************************************************************************************
function attachDetailsToggleListeners()
{
waitForElement("#details-special-words", (detailsElem) => {
const arrow = document.getElementById("arrow");
if (detailsElem && arrow)
{
// Remover listener anterior por si acaso
detailsElem.removeEventListener("toggle", toggleArrowRotation);
// Adjuntar nuevo listener
detailsElem.addEventListener("toggle", toggleArrowRotation);
}
});
waitForElement("#details-dictionary-words", (detailsElem) => {
const arrow = document.getElementById("arrow-dic");
if (detailsElem && arrow)
{
// Remover listener anterior por si acaso
detailsElem.removeEventListener("toggle", toggleArrowRotation);
// Adjuntar nuevo listener
detailsElem.addEventListener("toggle", toggleArrowRotation);
}
});
console.log(`[${SCRIPT_NAME}] Details toggle listeners attached.`);
}
//***********************************************************************************************************************************************************
// Nombre: toggleArrowRotation (Acá se rota la flecha de despliegue)
// Fecha modificación: 2025-05-01 06:00
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna. Rota la flecha de despliegue según el estado del
// elemento <details>. Descripción: Esta función rota la flecha de
// despliegue según el estado del elemento <details> al que está asociada.
// Si el elemento está abierto, la flecha se rota 90 grados; si está
// cerrado, se rota a su posición original. Se utiliza para proporcionar una
// indicación visual del estado del elemento <details> al usuario. La
// función se adjunta como un event listener al evento 'toggle' del elemento
// <details>.
//***********************************************************************************************************************************************************
function toggleArrowRotation()
{ // 'this' se refiere al elemento <details>
const arrowId =
this.id === 'details-special-words' ? 'arrow' : 'arrow-dic';
const arrow = document.getElementById(arrowId);
if (arrow)
{
arrow.style.transform =
this.open ? "rotate(90deg)" : "rotate(0deg)";
}
}
//***********************************************************************************************************************************************************
// Nombre: renderSpellDictionaryPanel
// Fecha modificación: 2025-04-15 12:17
// Autor: mincho77
// Entradas: Ninguna
// Salidas: string: HTML para el panel del diccionario ortográfico.
// Descripción: Esta función genera el HTML para el panel del
// diccionario ortográfico. Incluye un selector para elegir el idioma
// del diccionario, un campo de texto para agregar nuevas palabras, un
// botón para agregar palabras, un campo de búsqueda para filtrar
// palabras en la lista, y botones para importar y exportar el
// diccionario. El panel se puede mostrar u ocultar al hacer clic en el
// encabezado. Se utiliza para permitir al usuario gestionar un
// diccionario ortográfico personalizado para el normalizador de nombres
// de lugares. Se incluye un icono representativo para cada idioma
// (España e Inglaterra) junto a la opción correspondiente en el
// selector. El campo de búsqueda permite filtrar las palabras en la
// lista del diccionario, facilitando la búsqueda de palabras
// específicas. Los botones de importar y exportar permiten al usuario
// gestionar su diccionario ortográfico, facilitando la importación de
// palabras desde un archivo XML y la exportación de palabras a un
// archivo XML. Se utiliza para mejorar la experiencia del usuario al
// permitirle personalizar su diccionario ortográfico según sus
// necesidades.
//***********************************************************************************************************************************************************
function renderSpellDictionaryPanel()
{
return `
<details id="details-dictionary-words" style="margin-top: 15px;">
<summary style="cursor: pointer; font-weight: bold; list-style: none;">
<span id="arrow-dic" style="display: inline-block; transition: transform 0.2s;">▶</span> ${
S('dictionaryLabel')}
</summary>
<!-- Buscar palabra -->
<div style="margin-top: 10px;">
<input type="text" id="searchDictionaryWord" placeholder="${
S('searchDictionaryPlaceholder')}" style="width: 100%; padding: 5px; border: 1px solid #ccc; border-radius: 4px;">
</div>
<div id="dictionary-words-list" style="margin-top: 10px; max-height: 200px; overflow-y: auto;">
</div>
<!-- Botones de archivo -->
<div style="margin-top: 10px;">
<button id="exportDictionaryBtn">${
S('exportDictionaryButton')}</button>
<button id="importDictionaryBtn">${
S('importDictionaryButton')}</button>
<button id="clear-dictionary-btn" style="margin-left: 10px;">${
S('clearDictionaryButton')}</button>
<input type="file" id="hiddenImportDictionaryInput" accept=".xml" style="display: none;">
</div>
<!-- Drag & Drop Diccionario-->
<div id="dictionary-drop-zone" style="border: 2px dashed #ccc; padding: 10px; margin: 10px;">
${S('dictionaryDropZoneText')}
</div>
</details>
`;
}
//***********************************************************************************************************************************************************
// Nombre: initializeExcludeWords
// Fecha modificación: 2025-03-30
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - localStorage debe estar disponible.
// Descripción: Inicializa la lista de palabras excluidas a partir del
// localStorage, combinando con las palabras ya cargadas en la variable
// global excludeWords y actualizando el almacenamiento local.
//***********************************************************************************************************************************************************
function initializeExcludeWords()
{
const saved = JSON.parse(localStorage.getItem("excludeWords")) || [];
wordLists.excludeWords =
[...new Set([...saved, ...wordLists.excludeWords ]) ].sort();
excludeWords = wordLists.excludeWords; // Sincronizar
localStorage.setItem("excludeWords",
JSON.stringify(wordLists.excludeWords));
}
//***********************************************************************************************************************************************************
// Nombre: initSearchSpecialWords
// Fecha modificación: 2025-04-10
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Descripción: Esta función inicializa la búsqueda de palabras
// especiales en el panel lateral del normalizador. Agrega un evento de
// entrada al campo de búsqueda que filtra los elementos de la lista de
// palabras especiales según el texto ingresado. Si el campo de búsqueda
// no está disponible, espera 200 ms y vuelve a intentar. Esto es útil
// para permitir al usuario buscar y filtrar palabras especiales en la
// lista de manera eficiente.
//***********************************************************************************************************************************************************
function initSearchSpecialWords()
{
const searchInput = document.getElementById("searchWord");
const normalizerSidebar = document.getElementById("normalizer-sidebar");
if (searchInput && normalizerSidebar)
{
searchInput.addEventListener("input", function() {
const query = searchInput.value.toLowerCase().trim();
const items = normalizerSidebar.querySelectorAll("li");
items.forEach(item => {
const text =
item.querySelector("span")?.textContent.toLowerCase() ||
"";
item.style.display = text.includes(query) ? "flex" : "none";
});
});
}
else
{
setTimeout(initSearchSpecialWords, 200);
}
}
//***********************************************************************************************************************************************************
// Nombre: getSidebarHTML
// Fecha modificación: 2025-04-09
// Autor: mincho77
// Entradas: Ninguna
// Salidas: string: HTML para el panel lateral del normalizador.
// Descripción: Esta función genera el HTML para el panel lateral del
// normalizador de nombres de lugares. Incluye opciones para normalizar
// artículos, un campo para ingresar el máximo de lugares a buscar, una
// sección para palabras especiales con un botón para agregar palabras,
// un campo de búsqueda, y botones para importar y exportar la lista de
// palabras especiales. También incluye un botón para limpiar la lista
// de palabras especiales. El panel se puede mostrar u ocultar al hacer
// clic en el encabezado. Se utiliza para permitir al usuario gestionar
// su lista de palabras especiales y personalizar el comportamiento del
// normalizador de nombres de lugares. El HTML incluye estilos en línea
// para mejorar la apariencia y la usabilidad del panel.
//***********************************************************************************************************************************************************
function getSidebarHTML()
{
return `
<div id="normalizer-tab">
<h4>Places Name Normalizer <span style="font-size:11px;">${
VERSION}</span></h4>
<!-- Selector de idioma (Movido aquí) -->
<div style="margin-top: 10px;">
<label for="dictionaryLanguageSelect"><b>${
S('activeLangLabel')}</b></label>
<select id="dictionaryLanguageSelect" style="width: 100%; margin-top: 5px; padding: 4px;">
<option value="SP">Español 🇪🇸</option>
<option value="EN">English 🇬🇧</option>
</select>
</div>
<!-- No Normalizar artículos -->
<div style="margin-top: 15px; display: ${
activeDictionaryLang === 'EN' ? 'none' : 'block'};">
<input type="checkbox" id="normalizeArticles" ${
normalizeArticles ? "checked" : ""}>
<label for="normalizeArticles">${
S('normalizeArticlesLabel')}</label>
</div>
<div style="margin-top: 15px;">
<input type="checkbox" id="useSpellingAPI">
<label for="useSpellingAPI">${S('useApiLabel')}</label>
</div>
<!-- Máximo de Places a buscar -->
<div style="margin-top: 15px;">
<label>${S('maxPlacesLabel')} </label>
<input type="number" id="maxPlacesInput" value="${
maxPlaces}" min="1" max="800" style="width: 60px;">
</div>
<!-- Sección de Palabras Especiales -->
<details id="details-special-words" style="margin-top: 15px;">
<summary style="cursor: pointer; font-weight: bold; list-style: none;">
<span id="arrow" style="display: inline-block; transition: transform 0.2s;">▶</span> ${
S('specialWordsLabel')}
</summary>
<div style="margin-top: 10px; display: flex; gap: 5px;">
<input type="text" id="excludeWord" placeholder="${
S('addWordPlaceholder')}" style="flex: 1; padding: 5px; border: 1px solid #ccc; border-radius: 4px;">
<button id="addExcludeWord" style="background: #3498db; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">${
S('addButton')}</button>
</div>
<div style="margin-top: 10px; display: flex; gap: 5px;">
<input type="text" id="searchWord" placeholder="${
S('searchWordPlaceholder')}" style="flex: 1; padding: 5px; border: 1px solid #ccc; border-radius: 4px;">
</div>
<div id="normalizer-sidebar" style="margin-top: 10px; max-height: 200px; overflow-y: auto;"></div>
<button id="exportExcludeWords" style="margin-top: 10px;">${
S('exportWordsButton')}</button>
<button id="importExcludeWordsUnifiedBtn" style="margin-top: 5px;">${
S('importListButton')}</button>
<input type="file" id="hiddenImportInput" accept=".xml,.txt" style="display: none;">
<div style="margin-top: 5px;">
<input type="checkbox" id="replaceExcludeListCheckbox">
<label for="replaceExcludeListCheckbox">${
S('replaceListLabel')}</label>
</div>
<div id="drop-zone" style="border: 2px dashed #ccc; border-radius: 6px; padding: 15px; margin: 15px 0; text-align: center; font-style: italic; color: #555; background-color: #f8f9fa;">
${S('dictionaryDropZoneText')}
</div>
${S('dropZoneText')}
</div>
</details>
<hr>
<!-- Sección de Diccionario Ortográfico -->
${renderSpellDictionaryPanel()}
<hr>
<!-- Botón Scan -->
<button id="scanPlaces">${S('scanButton')}</button>
</div>
<hr>
<!-- Botón de limpieza -->
<button id="customButton" style="background:rgb(219, 96, 52); color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-weight: bold; margin-top: 10px;">
${S('clearSpecialWordsButton')}
</button>
`;
}
//***********************************************************************************************************************************************************
// Nombre: clearExcludeWordsList
// Fecha modificación: 2025-04-10
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - La variable global excludeWords debe estar definida.
// - La función renderExcludedWordsPanel debe estar definida.
// Descripción: Esta función limpia la lista de palabras excluidas
// almacenadas en localStorage y actualiza la variable global
// excludeWords.
//***********************************************************************************************************************************************************
function clearExcludeWordsList()
{
excludeWords = []; // Limpia la lista de palabras excluidas
wordLists.excludeWords = excludeWords; // Sincronizar
localStorage.removeItem(
"excludeWords"); // Elimina las palabras del almacenamiento local
// Limpia manualmente el contenedor antes de renderizar
const container = document.getElementById("normalizer-sidebar");
if (container)
{
container.innerHTML = ""; // Limpia el contenido del contenedor
}
renderExcludedWordsPanel(); // Refresca la lista en la interfaz
showModal({
title : "Lista Limpiada",
message : "La lista de palabras excluidas ha sido limpiada.",
type : "success",
autoClose : 1500
});
}
//***********************************************************************************************************************************************************
// Nombre: clearActiveDictionary
// Fecha modificación: 2025-04-22
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - La variable global activeDictionaryLang debe estar definida.
// - La variable global spellDictionaries debe estar definida.
// - La función renderDictionaryWordsPanel debe estar definida.
// Descripción: Esta función limpia el diccionario ortográfico activo,
// eliminando todas las palabras y actualizando el almacenamiento local.
// También muestra un modal de confirmación al usuario.
//***********************************************************************************************************************************************************
function clearActiveDictionary()
{
if (!spellDictionaries[activeDictionaryLang])
{
console.warn("⚠️ No se encontró el diccionario del idioma activo.");
return;
}
// Limpiar las letras
spellDictionaries[activeDictionaryLang] = {};
// Actualizar localStorage
localStorage.setItem(
`spellDictionaries_${activeDictionaryLang}`,
JSON.stringify(spellDictionaries[activeDictionaryLang]));
// Limpiar visualmente
dictionaryWords = [];
renderDictionaryWordsPanel();
showModal({
title : "Diccionario borrado",
message : `Se eliminó todo el contenido del diccionario en idioma ${
activeDictionaryLang}.`,
confirmText : "Aceptar",
type : "info",
});
}
//***********************************************************************************************************************************************************
// Nombre: attachEvents
// Fecha modificación: 2025-03-30
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - Deben existir en el DOM los elementos con los siguientes IDs:
// "normalizeArticles", "maxPlacesInput", "addExcludeWord",
// "scanPlaces", "hiddenImportInput", "importExcludeWordsUnifiedBtn" y
// "exportExcludeWords".
// - Debe existir la función handleImportList y la función scanPlaces.
// - Debe estar definida la variable global excludeWords y la
// funciónrenderExcludedWordsPanel. Descripción: Esta función adjunta
// los event listeners necesarios para gestionar la interacción del
// usuario con el panel del normalizador de nombres. Se encargan de:
// - Actualizar la opción de normalizar artículos al cambiar el estado
// del checkbox.
// - Modificar el número máximo de lugares a procesar a través de un
// input.
// - Exportar la lista de palabras excluidas a un archivo XML.
// - Añadir nuevas palabras a la lista de palabras excluidas, evitando
// duplicados, y actualizar el panel.
// - Activar el botón unificado para la importación de palabras
// excluidas mediante un input oculto.
// - Ejecutar la función de escaneo de lugares al hacer clic en el botón
// correspondiente.
//***********************************************************************************************************************************************************
function attachEvents()
{
console.log(`[${SCRIPT_NAME}] Adjuntando eventos...`);
const normalizeArticlesCheckbox =
document.getElementById("normalizeArticles");
const maxPlacesInput = document.getElementById("maxPlacesInput");
const addExcludeWordButton = document.getElementById("addExcludeWord");
const scanPlacesButton = document.getElementById("scanPlaces");
const hiddenInput = document.getElementById("hiddenImportInput");
const importButtonUnified =
document.getElementById("importExcludeWordsUnifiedBtn");
// Validación de elementos necesarios
if (!normalizeArticlesCheckbox || !maxPlacesInput ||
!addExcludeWordButton || !scanPlacesButton)
{
console.error(
`[${SCRIPT_NAME}] Error: No se encontraron elementos en el DOM.`);
return;
}
// Evento: cambiar estado de "no normalizar artículos"
normalizeArticlesCheckbox.addEventListener("change", (e) => {
normalizeArticles = e.target.checked;
// Guardar preferencia solo si es español
if (activeDictionaryLang === 'SP')
{
localStorage.setItem('normalizeArticles_SP',
JSON.stringify(normalizeArticles));
}
});
// Evento: cambiar número máximo de places
maxPlacesInput.addEventListener(
"input", (e) => { maxPlaces = parseInt(e.target.value, 10); });
// Evento para el botón personalizado
const customButton = document.getElementById("customButton");
if (customButton)
{
customButton.addEventListener("click", () => {
showModal({
title : "Confirmación",
message :
"¿Estás seguro de que deseas limpiar la lista de palabras excluidas?",
confirmText : "Sí, limpiar",
cancelText : "Cancelar",
type : "question",
onConfirm : () => {
clearExcludeWordsList();
}, // Llama a la función para limpiar la lista},
onCancel : () => {
console.log(
"El usuario canceló la limpieza de la lista.");
}
});
});
}
// Evento: exportar palabras excluidas a XML
document.getElementById("exportExcludeWords")
.addEventListener("click", () => {
const savedWords =
JSON.parse(localStorage.getItem("excludeWords")) || [];
if (savedWords.length === 0)
{
showModal({
title : "Error",
message : "No hay palabras excluidas para exportar.",
confirmText : "Aceptar",
onConfirm :
() => { console.log("El usuario cerró el modal."); }
});
return;
}
const sortedWords =
[...savedWords ].sort((a, b) => a.localeCompare(b));
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<ExcludedWords>
${sortedWords.map((word) => ` <word>${word}</word>`).join("\n ")}
</ExcludedWords>`;
const blob =
new Blob([ xmlContent ], { type : "application/xml" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "excluded_words.xml";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
// Evento: añadir palabra excluida sin duplicados
addExcludeWordButton.addEventListener("click", () => {
const wordInput = document.getElementById("excludeWord") ||
document.getElementById("excludedWord");
const word = wordInput?.value.trim();
if (!word)
return;
const lowerWord = word.toLowerCase();
const alreadyExists =
excludeWords.some((w) => w.toLowerCase() === lowerWord);
if (!alreadyExists)
{
wordLists.excludeWords.push(word);
localStorage.setItem("excludeWords",
JSON.stringify(wordLists.excludeWords));
renderExcludedWordsPanel(); // Refresca la lista después de
// agregar la palabra
}
wordInput.value = ""; // Limpia el campo de entrada
});
// Evento: nuevo botón unificado de importación
importButtonUnified.addEventListener("click",
() => { hiddenInput.click(); });
hiddenInput.addEventListener("change", () => { handleImportList(); });
// limpiardiccionario
waitForElement("#clear-dictionary-btn", (btn) => {
btn.addEventListener("click", () => {
const confirmClear = confirm(
"¿Seguro que deseas borrar TODO el diccionario activo?");
if (confirmClear)
clearActiveDictionary();
});
});
// Evento: escanear lugares
scanPlacesButton.addEventListener("click", scanPlaces);
}
//***********************************************************************************************************************************************************
// Nombre: attachDictionarySearch
// Fecha modificación: 2025-04-10
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - Debe existir en el DOM el campo de búsqueda con id
// "searchDictionaryWord" y el contenedor de palabras del diccionario
// con id "dictionary-words-list". Descripción: Esta función adjunta un
// evento de búsqueda al campo de búsqueda del diccionario ortográfico.
// Filtra la lista de palabras mostradas en el contenedor
// "dictionary-words-list" según la entrada del usuario. Se utiliza para
// mejorar la experiencia del usuario al permitirle buscar rápidamente
// palabras específicas en el diccionario ortográfico.
//***********************************************************************************************************************************************************
function attachDictionarySearch()
{
const dictionarySearchInput =
document.getElementById("searchDictionaryWord");
const dictionaryWordsContainer =
document.getElementById("dictionary-words-list");
if (!dictionarySearchInput || !dictionaryWordsContainer)
{
console.error(
"[PlacesNameNormalizer] No se encontró el campo 'searchDictionaryWord' o 'dictionary-words-list'.");
return;
}
// Solo modifica .style.display para ocultar/mostrar
dictionarySearchInput.addEventListener("input", () => {
const query = dictionarySearchInput.value.toLowerCase().trim();
const items = dictionaryWordsContainer.querySelectorAll("li");
items.forEach(item => {
const text =
item.querySelector("span")?.textContent.toLowerCase() || "";
item.style.display = text.includes(query) ? "flex" : "none";
});
});
}
//***********************************************************************************************************************************************************
// Nombre: createSidebarTab
// Fecha modificación: 2025-04-22
// Hora: 06:50
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - Debe existir la función W.userscripts.registerSidebarTab.
// Descripción: Esta función crea una pestaña en la barra lateral de WME
// para el normalizador de nombres de lugares. Primero, verifica si la
// pestaña ya existe y la elimina si es necesario. Luego, registra una
// nueva pestaña utilizando la función W.userscripts.registerSidebarTab.
// Si la pestaña se registra correctamente, se configura su contenido y
// se añaden los eventos necesarios. Se utiliza para proporcionar una
// interfaz de usuario para el normalizador de nombres de lugares dentro
// de WME, permitiendo al usuario acceder a las funciones del script de
// manera fácil y rápida. La pestaña incluye opciones para normalizar
// artículos, un campo para ingresar el máximo de lugares a buscar, una
// sección para palabras especiales con un botón para agregar palabras,
// un campo de búsqueda, y botones para importar y exportar la lista de
// palabras especiales. También incluye un botón para limpiar la lista
// de palabras especiales.
//***********************************************************************************************************************************************************
function createSidebarTab()
{
try
{
if (!W || !W.userscripts)
{
console.error(
`[${SCRIPT_NAME}] WME not ready for sidebar creation`);
return;
}
let registration;
const tabId = "normalizer-tab-pane"; // Usar un ID consistente
// para el CONTENEDOR
try
{
registration =
W.userscripts.registerSidebarTab("PlacesNormalizer");
}
catch (e)
{
if (e.message.includes("already been registered"))
{
console.warn(`[${
SCRIPT_NAME}] Tab registration conflict, skipping...`);
return;
}
throw e;
}
const { tabLabel, tabPane } = registration;
if (!tabLabel || !tabPane) // tabPane es el div contenedor
{
throw new Error(
"Tab registration failed to return required elements");
}
// Limpiar el contenido anterior del panel ANTES de añadir el
// nuevo NO eliminar el tabPane en sí.
tabPane.innerHTML = '';
// Configure tab
tabLabel.innerHTML = `
<img src="data:image/jpeg;base64,/9j/4QDKRXhpZgAATU0AKgAAAAgABgESAAMAAAABAAEAAAEaAAUAAAABAAAAVgEbAAUAAAABAAAAXgEoAAMAAAABAAIAAAITAAMAAAABAAEAAIdpAAQAAAABAAAAZgAAAAAAAABIAAAAAQAAAEgAAAABAAeQAAAHAAAABDAyMjGRAQAHAAAABAECAwCgAAAHAAAABDAxMDCgAQADAAAAAQABAACgAgAEAAAAAQAAAoCgAwAEAAAAAQAAAqmkBgADAAAAAQAAAAAAAAAAAAD/2wCEAAEBAQEBAQIBAQIDAgICAwQDAwMDBAUEBAQEBAUGBQUFBQUFBgYGBgYGBgYHBwcHBwcICAgICAkJCQkJCQkJCQkBAQEBAgICBAICBAkGBQYJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCf/dAAQABv/AABEIAGUAXwMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP7+KKKKACiivxO/4Kff8FePDv7Gt23wK+B9jbeKvird26zNBcMf7O0SGUfu7nUTGVd3Ycw2kbK8g5ZokIevQyvKq+MrLD4eN2/6+47MBl9XE1VRoq7P2rlnht4zNOwRFGSxOAB7noK5i18e+CL67+wWWsWMs/Ty0uImb/vkNmv86P42fHz9o/8Aaf1eTW/2lPiBrXitpWZvsAuXsdIiD/8ALOLTbQx2/lr0XzVlkx952PNfNsHwh+FdlOlzY+HNOtpoyGSWG3jjkUjoVdAGBHYg1+uYXwbk4fvq9n5RuvzX5H6Rh/C+q4/vKqT8l/wx/qNZFLX+ej+zZ+3h+2f+x/qMNx8G/Heoato0LBpPDnii4m1bTJlG3KI9w7XVqSowrQTKik7jG/Q/2Mf8E7v+Cjvwo/4KAfD25vdDtz4c8a6AI08QeGriUST2bSZCTwSAL9pspip8mdVXoUkWORWRfiuJ+A8Xlkfav3qfddPVdPyPlc/4QxOAXPLWHdf5dD9FqKK5bxp4v0bwH4Zu/FWvPstrOPccfeY9FRR3ZjgKPWvgMXi6WHpSr1pKMYq7b2SX+R81QoTqTVOmrt6JHU0V8kfAr9pTUfix4uufC+q6SlliB7iF4ZDIAqMqlZMgc/MMEcdsdK+t68DhHjHL88wax+WT5qd2tmtV5NJno51keJy6v9WxUbSt5fof/9D+/iiiigD5K/bq/acsP2Ov2TfG/wC0Tcwpd3Ph7TmbT7R2CC61Gdlt7G2z2865kjT8a/z2ftfifWtUv/GPj7UJNZ8Sa9cyajrGpTf6y7vZzullb0GeEQfKiBUXCqoH9d3/AAcVXl2n7C+haPGStrqHjnQo7nHdYGluYwfbzYkr+RYnnmv6H8I8vpwwM8T9qTt8klY/bPDPBQWGnX6t2+Ssdp8K/hT8Yf2gviVZ/Bn9n7w7N4p8U3qGYW0brDBbW6kK1zeXD/Jb26EjLHLH7qK74U/qF4j/AOCDH/BRrw74N/4SvTr7wTr+oLGHfQrS8vIJxgfMkN5cQLDK/ZQ6QqT1Ze36Wf8ABuH8PPBVn+zz8Rvi9DGj+J9c8YT6bfSHaZYbPTLeFbK3B6rFiV7hV6bpmPev6NK8Xi3xIxmGx0sNhUlGGmq3/wCB6Hj8R8dYuji5UcPZKOm39fgf5lFzba3o+t6l4R8XabdaHruiXL2Wp6Xfx+VdWdzHjdFKnY4IKkZV1KshKMpPoXwU/aD8ZfsgfGzw7+1R8PyxvPCU2/ULZP8Al/0aQqNRsmAxu8yFd8Q6CeOJv4cV+t3/AAcK+BPCXhT9tz4eeOvDsaQar4w8JajHrAT/AJbDR7u1Sxmcf3lW7mj3dWVVByEXH4i3j20dpLJesqQqjGRmIChAPmJJ4AA6+1fqmT42GZ5dCtUhpNar8H+Wnkfo2V4uGY4CNSpHSS2/A/0wfCnifRfGnhfTvGPhyYXOnaraw3lrKvSSGdBJGw9ipFfmh+1J8XX8feKx4K8PuZNM0qXZ+75+0XX3SQB1CfcT1OfavHv2Ovil43+FH/BKf4HeCfFENxp3i+/8GabbtDcqUuLa2SEIsrq3zK5i2BAQCCeR8pFfRv7I/wAGf7Y1Bfih4gi/0SyYpp8bdHlXgy/7sfRf9rn+EV/k/wCO+bYnOc5jwDkstW/30ltGC6fdaT7+7Hq0fHcG5PQymjVzzG6qF4013e11+S7avoj6Z/Zy+DqfC3wiLrVox/bOpBZLo9fLA+5CD6J/Fjq2e2K+iqTGOBS1/QXDHDmFyjAUsuwUbU6asv8AN+b3fmfk+bZpWxuIniq7vKX9W9Fsj//R/v4ooooA/K7/AILRfATWv2gf+CdfjzRfCls95rXhpLXxTp1vEPnmm0OdL0wr/tSxRvGOP4q/hs07UbPV9Pg1XTpFmt7mNZYnXlWRxlSMdiOlf6b80MVxC0E6h0cbWVhkEHggj0r/AD7f+Cgv7Hd3+wd+1dqvwhsIDF4K8Sm413wbLgBFsZJAbnThjgNp00gjVQABbvBjJ3Y/cPCPPIpTy+e/xR+7X8l+J+s+GmbxjzYKXXVfr+SOi/4J7/8ABQLx/wD8E7vinq/iHTtHl8V+BvFvlNr+hWzxxXaXMC+XFqFg0pSIziMCKWKRlWWNUw6GMbv6DPEP/BxT+wTZ+F5NR8JWHjLW9a8smLSE8P3VnK0gHCNc3Yis09NxmK+meK/kNZgME9K+ifgT+yJ+07+0zqMVh8EvBOp6tbybc6jNC1lpcasMiR764VIWTjnyfNf0Q19txFwVlWKqfXMX7vd3SXzv+lj6zO+EMvxFT6zXfL31SXz/AKRX/aH+Pnxo/bq/aTu/jT47sS2ua59m0fQ9A09muVsrQORbWNvkIZZZJZC0km1TJI38KKoT9f8A4F/8EdvDfw28a+H/ABj+2J460eWPS9uq6l4KsbeWWWby0EkNlNfecI5A0oHnRrABKmYwShZm/Qf9gn/gmd8PP2MJ4vif49vLfxj8SyjiK7jjI07SFkG1ksUf5pJinyPcvhiMhFiRih774r/BzxpoOq3nivSXn12xupHnm3fPdws5ydw/5aoOxHIHGMDNfyN9IH6SOOyPBrB8F0ozUdJSteytb3Vvp3WqOzJq2GxFT6lRqeypJWTSV5el17qt10b6W63/AAtoXib9pT4uST3uYYZSJLlk+7a2ifKkadgcfIn+1luxr9ddH0jTtA0u30XSIVgtbWNYoo0GAqKMACvgb9hPXri9i8QaTbQxNaRGCUzhcSea25fLY9wFXIH8PPrX6F1+DfRs4foRyZ55NudfEuTnJrXSTVl5XTfm35JL4DxVzKo8csviuWnSSUUttl+mnoFFFFf0Yflp/9L+/iiiigAr4P8A+CiX7Dfg79vb9na9+FGsTJpfiCwkGpeG9ZKb207VIVIikIGGaCRSYbiMEb4XYZBwR94UV04PF1MPVjWou0o7G2GxE6M1UpuzWx/mfXOleM/hR8ULrwL8V9DWy8U+B9Zt49Z0S5bMTy2U0VwYGcKd1reRBdsgXD28oYLztr+8H4G/tO/DX9rD4NWvxm+EF+8mkKFgv9LbC3Gk3SKN1rcQx8KUBBVhlHQq6EoytXyF/wAFf/8Agl8v7X3hJPj58B7WG2+Lnhe18uFeI49e0+MlzplyxwokUlms5m/1UhKE+XI9fy1/sJ/Gn9qzwH+09oOkfsXWdxcfETWp30u68OXqSQ2s8NpIVvIddhYBre3sWLebMyia1f5Y8ySeRN+t8U5dheL8l/ieyq00/Radf7rtofskcyoZphFiW1GdPo9v+Be2j6H9yWn6hJqLebBHstxwGbqx9h0AFaAuYt7qD/qsbvb2/KvSNc+H2qz6RHc6MILa+Ma+bChJhD4+byiQCAD93IHHYV51b+FNUluYvCsMMiPKf30jKRtT+JyenPav4PzDh3HYSqqMoXvs1s+yR5WGzPD1oc8Xa3TsepfCDQLLTdBn1uG3SGbVZjPIyqFLgfIhbAGeBXrVVrO1gsbSOytl2xxKEUegUYFWa/ecmy2OEwtPDR+yvx6/ifm2PxTr1pVX1/pfgFFFFemcZ//T/v4oopOlAH5M/wDBZL/gpvaf8Esf2Urf43aZo9t4k8Ta5rVpomiaTdSywQzyyB57l5ZII5HjjgtIZpSwU5YKnVgK+Q/+CHX/AAXNuP8AgrD4j8d/Dvx/4X0vwj4j8KWljqllBpV9Lew3mn3Mk1vK4aeOFw8E0ShwE27ZY8HOQPxT/wCC4WvXf/BUb/guB8IP+CXnhWc3PhzwhPbafraLJIiibUlj1PXZMrxuttGt44UdRlZLopuQk1i/tR29p/wRy/4OU/CHx60GIaR8Nfiy1m9zHBHHFbJY655Oi6pFnhQlnfwWF8wG0hWON3SgD+rb/grf+3b4u/4Jx/sX6r+1F4I8PWfii/0/VdJ05NPv55LaBxqV5HaFjLCkjrs8zdwh6YxX8vPwi/4Lm/t1XUWtft1/Bv8AYL0u7sPGEKjV/HGgx6pM2pQac5gPnXVrpUk8q27IUZmQqmz5uF4/Zj/g6IYH/gkb4jYc/wDFT+FP/Txb1/P7/wAEvv8Agu78V/2Iv+CYvhX9nr4ffsy+N/HcvhmHV3t/FUVtdDw5MbnULq7LvNa2dy/lweb5cgQH50YZXqKjNpNJjUmtj+on/gj/AP8ABZn4Pf8ABWXwPrn9g6DceDfGvhKO1l1jRJ51vIDbXm9YLyxvEVBcW0jxSJ80cUqMvzxqGQt82/8ABW3/AIOFvg1/wTn8eH9nX4T+HD8TPikiQte2CXX2bT9JNwEa3ivZoo553upkdWitLeF5MNGZDEJYt/5c/wDBo58KPh/s+MP7Wknjjw/qvjLxHDb20/hLR2K3ekWbXV1qBuLyI4VBdzylbZIPMijhiUedI7MqfEP/AAbR+ENH/bp/4K1fEr9sT47QjU9b0Gz1HxbZW96DJJFq2u6rNAk7q5I8ywtka3i4/dbtq42JiRH163/BzH/wVV+Bk1p8QP2v/wBk/wDsLwLfXEaQ3bW2u6K0iSNgLFdahaPb+a3SKO48jzGwBgHNf1nfsJ/tyfAj/god+znpP7Sn7Pt5LLpN+8lrd2d0qpeadf2523Fldxozqs0TY5RmjdSskbNGysfefjH8H/h58ffhV4g+CnxY0yHWPDfiiwn03UbO4UPHLBOhRhhgQCM5U9VYAjkV8r/sC/8ABN79l7/gm18PtU+Hf7MllqUFvr1zFeapc6rqV1qNxeXUMK26zOZ3McbeWiqRCka4AG3AGAD70ooooA//1P7+K8j+Pnxn8F/s6fBLxZ8efiLcraaF4O0m71i+lY4xBZwtKwHudu0DuSAK9crG8QeHfD/izR5/Dvimxt9S0+5AWa2uokmhkAIIDxuCrDIBwR2oA/zJ/wDgl3/wSl+Lv/Bd/wCI/wAYv2wPGvxOvfh0P7eaebVtJiW+lu9X1gvqF3ZxzC5jKxWNtJbQ5U8rsTaqpivYP+Cr3/Btd40/YP8A2SNS/at0/wCNGtfFC28P3VpZ6rZarZeW1ppmpzLaTXUM5uJ/LETyRtLldmwFmxsBH+jR4S8D+C/AOnPo/gXSLLRbSSQytBYW8dtG0hABcpEqqWIAGcZwB6Vp67oGheKNIuPD/iWyg1Cwu08ue2uY1lhkQ/wvG4KsPYjFAH8SP7a/7a1t+3D/AMGsPh742a/qMNxr+l614X8P+JJfNi2jVNG1m3tbiZmRigS4VFukJI/dSq2BXjv/AASH/wCDkL/gnv8A8E//APgm54H/AGUvi1H4j1Xxt4VGrNNb6Va2z2crXmpXV7Akd5NdRQDMcyBixVVbIPSv7lovgj8GIPDs3hCDwjoqaTczLcy2S6fbC3kmQALI0Qj2M6gABiMjAx0rGh/Zu/Z4t5Vnt/Afh2N0OVZdLswQR6ERcUAfxH/8GyvwY+M3x1/4KPfE3/gpFpnhR/CXwy1i38UJbj5vsj3HiLWIL+HTrGTYqXMVmkDGeSImJHKKh5KR+AfG3wT+0b/wbZ/8FXtV/a28M+F5PEPwZ8bXmpJBKhMFldaRq9wL2bTJ7zaYbTUbC6H+ieftjkjChM+bMYf9E6ysrPTrWOysIkghiAVI41CqoHQBRgAewqlrmgaH4m0ubQ/EdnBf2VwuyW3uY1lidfRkcFSPYigD+Mv9pj/g8H/Zo8Q/BDU/DX7GXhHxBN8StVs3tbJ9dWyhstNuph5ayEW13PLfyRkkxQ2qssrhVMkYYGv2Z/4IT/8ADyjWv2R5viH/AMFJfEF9qeq+ILxJ/DVhrFrbW2q2ejrCio+ofZ7a1YTXMu+RY5k82OHy/M2yM6J+lngf9kf9lb4ZeIT4t+HHw18LaBqpbd9s07R7K2nz6+ZFErfrX0KBigBaKKKAP//V/v4ooooAKKKKACiiigAooooAKKKKACiiigD/2Q=="
style="height: 16px; vertical-align: middle; margin-right: 5px;">
NrmliZer
`;
tabPane.id = tabId; // Asignar ID al CONTENEDOR (tabPane)
// Inyectar HTML del panel
tabPane.innerHTML = getSidebarHTML();
// Esperar que el DOM esté listo antes de adjuntar eventos
waitForElement("#normalizeArticles", () => {
console.log(
`[${SCRIPT_NAME}] ✅ Sidebar DOM ready, attaching events`);
attachEvents();
// Activar búsqueda para palabras especiales
initSearchSpecialWords(); // Mover aquí dentro
renderDictionaryWordsPanel(); // Renderizar diccionario
// inicial
attachDictionarySearch(); // Adjuntar búsqueda diccionario
// inicial
// Mover la configuración del selector aquí, después de que
// el HTML se inyecta
waitForElement("#dictionaryLanguageSelect", (selectorElem) => {
console.log(
`[${SCRIPT_NAME}] Attaching language change listener`);
// El elemento ya está disponible como 'selectorElem'
selectorElem.value =
activeDictionaryLang; // Asegura valor inicial
attachLanguageChangeListener(); // Llamar a la función
// que adjunta el
// listener
});
});
// waitForElement("#normalizeArticles", ...)
// Exponer depuración por consola
unsafeWindow.debugDictionaries = function() {
// Renderizar el panel del diccionario después de que el HTML
// esté listo
renderDictionaryWordsPanel();
attachDictionarySearch();
console.log("Idioma activo:", activeDictionaryLang);
console.log("Diccionario actual:",
spellDictionaries[activeDictionaryLang]);
};
}
catch (error)
{
console.error(`[${SCRIPT_NAME}] Error creating sidebar tab:`,
error);
}
}
//********************************************************************************************************************************
// Nombre: checkSpellingWithAPI
// Fecha modificación: 2025-03-30
// Autor: mincho77
// Entradas: text (string) – Texto a evaluar ortográficamente.
// Salidas: Promise – Resuelve con lista de errores ortográficos detectados.
// Prerrequisitos: Requiere permisos GM_xmlhttpRequest y @connect a
// api.languagetool.org Descripción: Consulta la API de LanguageTool para
// verificar ortografía del texto.
//********************************************************************************************************************************
function checkSpellingWithAPI(text)
{
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method : "POST",
url : "https://api.languagetool.org/v2/check",
headers :
{ "Content-Type" : "application/x-www-form-urlencoded" },
// Usar el idioma activo para la API
data : `language=${getApiLangCode(activeDictionaryLang)}&text=${
encodeURIComponent(text)}`,
onload : function(response) {
if (response.status === 200)
{
const result = JSON.parse(response.responseText);
const errores = result.matches.map(
(match) => ({
palabra : match.context.text.substring(
match.context.offset,
match.context.offset + match.context.length),
sugerencia :
match.replacements.length > 0
? match.replacements[0].value
: match.context
.text // Mantener la palabra original si
// no hay sugerencias
}));
resolve(errores);
}
else
{
reject("❌ Error en respuesta de LanguageTool");
}
},
onerror : function(
err) { reject("❌ Error de red al contactar LanguageTool"); }
});
});
}
window.checkSpellingWithAPI = checkSpellingWithAPI;
//********************************************************************************************************************************
// Nombre: mostrarErroresOrtograficosEnPanel
// Fecha modificación: 2025-05-03
// Hora: 16:33
// Autor: mincho77
// Entradas:
// - errores (Array): Lista de errores con palabra, sugerencia, tipo y
// severidad Salidas: Ninguna Descripción: Muestra los errores ortográficos
// en el panel flotante, incluyendo sugerencias desde API o reglas locales
//********************************************************************************************************************************
function mostrarErroresOrtograficosEnPanel(errores)
{
if (!Array.isArray(errores) || errores.length === 0)
return;
errores.forEach((error) => {
const row = document.querySelector(
`.normalizer-row[data-original="${error.palabra}"]`);
if (row)
{
const warningIcon = row.querySelector(".warning-icon");
const suggestionsDropdown =
row.querySelector(".suggestions-dropdown");
if (warningIcon)
{
warningIcon.classList.remove("hidden");
warningIcon.title =
`Error (${error.tipo}) detectado: ${error.palabra}`;
}
if (suggestionsDropdown && error.sugerencia)
{
const option = document.createElement("option");
option.value = error.sugerencia;
option.textContent = `🔁 ${error.sugerencia} (API)`;
suggestionsDropdown.appendChild(option);
}
}
else
{
console.warn(
`❗ No se encontró fila para la palabra: ${error.palabra}`);
}
});
}
//********************************************************************************************************************************
// Nombre: evaluarOrtografiaCompleta
// Fecha modificación: 2025-04-10 22:15 GMT-5
// Autor: mincho77
// Entradas:
// - texto (string): Texto a evaluar
// - config (opcional): {
// usarAPI: true, // Usar LanguageTool
// reglasLocales: true, // Aplicar reglas de tildes
// timeout: 5000 // Tiempo máximo para API
// }
// Salidas:
// Promise<{
// original: string,
// normalizado: string,
// errores: Array<{
// palabra: string,
// sugerencia: string,
// tipo: 'ortografia'|'tilde'|'gramatica',
// severidad: 'alta'|'media'|'baja'
// }>,
// metadata: {
// totalErrores: number,
// apiUsada: boolean,
// tiempoProcesamiento: number
// }
// }>
// Descripción:
// Sistema completo que combina normalización y revisión ortográfica real
//********************************************************************************************************************************
async function evaluarOrtografiaCompleta(texto, config = {})
{
const inicio = Date.now();
const resultadoBase = {
original : texto,
normalizado : texto,
errores : [],
metadata :
{ totalErrores : 0, apiUsada : false, tiempoProcesamiento : 0 }
};
// 1. Normalización básica inicial
const normalizado = await normalizePlaceName(texto, true);
// const normalizado = await normalizePlaceName(texto, placeId); // <--
// Pasar placeId
resultadoBase.normalizado =
normalizado; // <-- Usar el resultado de normalizePlaceName
// 2. Revisión por palabras especiales antes de hacer cualquier otra
// cosa
const originalWords = texto.split(/\s+/);
const especialesLower = wordLists.excludeWords.map(
w =>
w.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase());
for (let i = 0; i < originalWords.length; i++)
{
const palabraOriginal = originalWords[i];
const palabraNormalizada = palabraOriginal.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
const index =
especialesLower.findIndex(w => w === palabraNormalizada);
if (index !== -1)
{
resultadoBase.normalizado = resultadoBase.normalizado.replace(
new RegExp(`\\b${palabraOriginal}\\b`, "g"),
wordLists.excludeWords[index]);
}
}
// 3. Detección de errores locales (tilde y ortografía)
if (config.reglasLocales !== false)
{
const erroresLocales = detectarErroresLocales(
texto,
resultadoBase.normalizado); // <-- Pasar original y normalizado
resultadoBase.errores.push(...erroresLocales);
const palabrasNoValidas =
erroresLocales.filter(e => e.tipo === "tilde" && e.sugerencia &&
e.sugerencia.length > 0);
if (palabrasNoValidas.length > 0 &&
typeof mostrarErroresOrtograficosEnPanel === "function")
{
mostrarErroresOrtograficosEnPanel(palabrasNoValidas);
}
}
// 4. Revisión con API de LanguageTool, pero sin aplicar cambios
// automáticos
// *** Lógica condicional revisada ***
let apiResult = { errores : [], apiStatus : 'skipped' }; // Inicializar
if (config.usarAPI === true && activeDictionaryLang !== 'EN' &&
resultadoBase.normalizado.length > 1)
{ // <-- Usar === true y verificar idioma EN y longitud
console.log(`[evaluarOrtografiaCompleta] Llamando API para idioma ${
activeDictionaryLang}`); // Log para confirmar
try
{
// Llamar a la API con el texto YA NORMALIZADO
apiResult = await revisarConLanguageTool(
resultadoBase.normalizado, config.timeout);
resultadoBase.metadata.apiUsada = true;
}
catch (error)
{
console.error("Error llamando a revisarConLanguageTool:",
error);
apiResult = {
errores : [],
apiStatus : 'error'
}; // Marcar como error si la llamada falla
}
}
else
{
// Log detallado de por qué se omitió la API
if (config.usarAPI !== true)
{
console.log(
`[evaluarOrtografiaCompleta] API omitida (checkbox desmarcado).`);
}
else if (activeDictionaryLang === 'EN')
{
console.log(
`[evaluarOrtografiaCompleta] API omitida (idioma EN).`);
}
else if (resultadoBase.normalizado.length <= 1)
{
console.log(
`[evaluarOrtografiaCompleta] API omitida (texto normalizado muy corto).`);
}
}
// Añadir errores de API (si los hubo) a la lista principal
apiResult.errores.forEach(e => {
// Asegura que los errores de API tengan los campos necesarios
e.origen = 'API';
e.tipo = e.tipo || 'ortografia'; // Default si no viene de la API
e.severidad = e.severidad || 'media'; // Default
});
resultadoBase.errores.push(...apiResult.errores);
// (Opcional) Mostrar errores combinados en el panel si la función
// existe if (typeof mostrarErroresOrtograficosEnPanel === "function") {
// mostrarErroresOrtograficosEnPanel(resultadoBase.errores);
// }
// 5. Filtrado final
resultadoBase.errores = filtrarErrores(resultadoBase.errores);
resultadoBase.metadata.totalErrores = resultadoBase.errores.length;
resultadoBase.metadata.tiempoProcesamiento = Date.now() - inicio;
return resultadoBase;
}
// ==================== FUNCIONES DE SOPORTE ====================
//********************************************************************************************************************************
// Nombre: detectarErroresLocales
// Descripción: Detecta errores de tildes y mayúsculas
//********************************************************************************************************************************
function detectarErroresLocales(original, normalizado)
{
const errores = []; // Array para almacenar los errores encontrados
const palabrasOriginal = original.split(/\s+/);
// let palabrasNormalizadas = [...palabrasOriginal]; // Esta variable no
// se usa para construir el resultado de errores
// Itera sobre cada palabra original
palabrasOriginal.forEach((palabra, i) => {
const contieneNumeros = /\d/.test(palabra);
// Limpiar puntuación final común ANTES de cualquier validación
const palabraLimpia = palabra.replace(/[.,;:!?()"']+$/, ''); // Añadir más puntuación
const contieneSoloLetras =
/^[a-zA-ZáéíóúÁÉÍÓÚüÜñÑ]+$/.test(palabraLimpia); // Usar palabraLimpia aquí también
// Aplicar regla solo si no tiene números, son letras puras y no
// está vacía
if (!contieneNumeros && contieneSoloLetras &&
palabraLimpia.length > 0) // Añadir check de longitud > 0
{
let errorAgregado = false; // Flag para prevenir múltiples errores para la misma palabra
// 1. Verificar Exclusiones y Palabras Especiales PRIMERO
const lowerPalabraLimpiaNormalizada = normalizarPalabra(palabraLimpia);
const isExcluded = excludeWords.some(excluded => normalizarPalabra(excluded) === lowerPalabraLimpiaNormalizada);
if (isExcluded) return; // Saltar palabra excluida
const palabraEspecialCorrecta = esPalabraEspecial(palabraLimpia); // Devuelve la forma correcta o null
if (palabraEspecialCorrecta) // <-- Corregido: usar palabraEspecialCorrecta
{
if (palabraLimpia !== palabraEspecialCorrecta) {
errores.push({
palabraOriginal: palabra,
palabraProblematica: palabraLimpia,
sugerida: palabraEspecialCorrecta,
tipo: "especial_incorrecta",
severidad: "media",
motivo: `Forma incorrecta de palabra especial. Usar: "${palabraEspecialCorrecta}"`,
origen: 'Local (Especial)'
});
errorAgregado = true;
}
return; // Si es especial (correcta o no), no hacer más checks locales
}
// 2. Verificar Diccionario (Coincidencia EXACTA)
const estaEnDiccionarioExacto = dictionaryWords.includes(palabraLimpia);
if (estaEnDiccionarioExacto)
{
console.log(`[DEBUG] "${
palabraLimpia}" está en diccionario. Verificando ambigüedad...`); // LOG AMBIGÜEDAD 1
// *** NUEVO: Lógica de ambigüedad de acento ***
const tieneTildeActual = /[áéíóúÁÉÍÓÚ]/.test(palabraLimpia);
if (!tieneTildeActual)
{
// La palabra SIN tilde está en el diccionario.
// ¿Existe una versión CON tilde en el diccionario?
const palabraConTildePotencial = generarSugerenciaTilde(palabraLimpia); // Intentar generar versión con tilde
if (palabraConTildePotencial !== palabraLimpia)
{ // Si se pudo generar una versión diferente (con tilde)
console.log(`[DEBUG] Posible versión con tilde: "${
palabraConTildePotencial}"`); // LOG AMBIGÜEDAD 2
// Verificar si la palabra con tilde está en el diccionario
// const tildeNormalizadaDic = normalizarPalabra(palabraConTildePotencial); // <-- Ya no se usa normalizado aquí
// const tildeEnDiccionario = dictionaryWords.some(dictWord => normalizarPalabra(dictWord) === tildeNormalizadaDic); // <-- Ya no se usa normalizado aquí
const tildeEnDiccionario = dictionaryWords.includes(palabraConTildePotencial); // <-- Check EXACTO
if (tildeEnDiccionario)
{
// ¡Ambigüedad! Ambas formas (con y sin tilde) están en el diccionario.
console.log(`[detectarErroresLocales] Ambigüedad detectada para "${palabraLimpia}" / "${palabraConTildePotencial}" (ambas en diccionario).`);
console.log(
`[DEBUG] Pushing error tilde_ambigua`); // LOG
// AMBIGÜEDAD
// 3
errores.push({
palabraOriginal: palabra, // <-- Usar palabraOriginal
sugerencia: palabraConTildePotencial, // Sugerir la versión con tilde por defecto
tipo: "tilde_ambigua",
severidad: "baja", // Es una advertencia más que un error claro
motivo: `Ambigüedad: "${palabraLimpia}" y "${palabraConTildePotencial}" están en diccionario. Verificar contexto.`, // Motivo más claro
palabraOriginal: palabra,
palabraProblematica: palabraLimpia,
origen: 'Local (Diccionario)'
});
errorAgregado = true; // Marcar que se agregó un error (ambigüedad)
}
console.log(`[DEBUG] Versión con tilde "${
palabraConTildePotencial}" NO encontrada en diccionario.`); // LOG AMBIGÜEDAD 4
}
console.log(
`[DEBUG] No se pudo generar versión con tilde o es igual a la original.`); // LOG AMBIGÜEDAD 5
}
// Si no hay ambigüedad o la palabra ya tenía tilde, simplemente omitir revisión local.
console.log(`[detectarErroresLocales] Palabra "${palabraLimpia}" encontrada en diccionario (sin ambigüedad). Omitiendo revisión local.`);
console.log(`[DEBUG] Omitiendo revisión local para "${
palabraLimpia}" (en diccionario, sin ambigüedad).`); // LOG
// AMBIGÜEDAD
// 6
if (!errorAgregado) // Si no se reportó ambigüedad, omitir más revisiones
return; // Palabra en diccionario, sin ambigüedad detectada, omitir otras revisiones.
}
// 3. Si NO está en el diccionario EXACTO, aplicar reglas de Tilde y Ortografía
else if (!errorAgregado) { // Asegura que solo se ejecute si no está en diccionario exacto y no hay error previo
}
const tildeData =
detectarTilde(palabraLimpia); // <-- Usar palabraLimpia para detectar tilde
console.log(
`[DEBUG] "${
palabraLimpia}" NO está en diccionario. Resultado detectarTilde:`,
tildeData); // LOG ORTO 1
// 3.1 Caso: `detectarTilde` dice VÁLIDA pero SIN TILDE -> ¿Está la versión CON TILDE en el diccionario?
if (!errorAgregado && tildeData.esValida && !tildeData.tieneTilde) {
const sugerenciaConTilde = generarSugerenciaTilde(palabraLimpia);
if (sugerenciaConTilde !== palabraLimpia) {
// Verificar si la versión CON tilde SÍ está en el diccionario
const tildeEnDiccionario = dictionaryWords.includes(sugerenciaConTilde);
if (tildeEnDiccionario) {
// ¡Caso "drogueria" encontrado!
console.log(`[DEBUG] Pushing error tilde_faltante_dic para "${palabraLimpia}" -> "${sugerenciaConTilde}"`);
errores.push({
palabraOriginal: palabra,
palabraProblematica: palabraLimpia,
sugerida: sugerenciaConTilde,
tipo: "tilde_faltante_dic", // Tipo específico
severidad: "alta",
motivo: `Posible falta de tilde. Forma correcta "${sugerenciaConTilde}" encontrada en diccionario.`,
origen: 'Local (Diccionario/Tilde)'
});
errorAgregado = true; // Marcar que se agregó un error
}
}
}
// 3.2 Caso: `detectarTilde` dice INVÁLIDA (y no se agregó error antes)
// (La condición original `!tildeData.esValida && tildeData.tieneTilde`
// podría ser la que causa problemas con
// palabras como "Portón" si el 'tipo' fue mal clasificado como
// grave pero tenía tilde)
else if (!errorAgregado && !tildeData // <-- Cambiado a else if
.esValida /*&& tildeData.tieneTilde*/) // Podrías probar
// a eliminar la
// // <-- Usar
// palabraLimpia
// segunda parte
// de la condición
{ // <-- Este bloque ahora solo se ejecuta si !tildeData.esValida Y !errorAgregado
console.log(`❌ Palabra inválida (detectada localmente): "${
palabra}" - Tipo detectado: ${
tildeData.tipo}, Es válida según regla local: ${
tildeData.esValida}`);
// Aquí se decide si añadir el error.
// Quizás necesites refinar esta condición:
// Añadir el error si es inválida localmente Y NO está en la
// lista de excluidas
// const lowerExcluidas = // <-- Ya no es necesario, se verifica antes
// excludeWords.map(w => w.toLowerCase());
// console.log(`[DEBUG] Verificando exclusión para "${
// palabraLimpia}"...`); // LOG ORTO 2
// if (!lowerExcluidas.includes(palabraLimpia.toLowerCase())) // <-- Ya no es necesario
{ // Generar sugerencia usando la palabra limpia
const sugerida = generarSugerenciaTilde(palabraLimpia);
// El bloque comentado anterior sobre corregirTildeLocal fue eliminado por sintaxis incorrecta.
// *** NUEVO: Lógica mejorada para buscar corrección
// ortográfica ***
const sugerenciaTildeNormalizada =
normalizarPalabra(sugerida); // <-- Cambiado de sugerenciaTilde a sugerida
const sugerenciaTildeEnDiccionario =
dictionaryWords.some(dictWord =>
normalizarPalabra(dictWord) ===
sugerenciaTildeNormalizada);
let posibleCorreccionOrtografica = null;
console.log(
`[DEBUG] Buscando corrección ortográfica para "${
palabraLimpia}" en diccionario...`); // LOG ORTO 3
// Si la sugerencia de tilde NO está en el diccionario,
// buscar una corrección ortográfica más probable. O
// incluso si está, podríamos buscar una mejor
// corrección ortográfica. Vamos a buscar siempre que
// haya un error de tilde detectado.
const palabraLimpiaNormalizada =
normalizarPalabra(palabraLimpia);
// Buscar palabra en diccionario con base similar (ej.
// Levenshtein 1 o reemplazo s/z/c/b/v) Simplificación:
// Buscar misma longitud y diferencia en s/z/c/b/v
for (const dictWord of dictionaryWords)
{
const dictWordNormalizada =
normalizarPalabra(dictWord);
if (dictWordNormalizada.length ===
palabraLimpiaNormalizada.length)
{
let diffCount = 0;
let diffIndex = -1;
for (let k = 0; k < dictWordNormalizada.length;
k++)
{
if (dictWordNormalizada[k] !==
palabraLimpiaNormalizada[k])
{
diffCount++;
diffIndex = k;
}
}
// Si difiere en 1 caracter y es una sustitución
// común (simplificado)
if (diffCount === 1)
{
const char1 =
palabraLimpiaNormalizada[diffIndex];
const char2 =
dictWordNormalizada[diffIndex];
console.log(`[DEBUG] Comparando "${
palabraLimpiaNormalizada}" vs "${
dictWordNormalizada}" (diff: ${
diffCount})`); // LOG ORTO 4
const commonSubs = [
[ 's', 'z' ],
[ 'z', 's' ],
[ 's', 'c' ],
[ 'c', 's' ],
[ 'b', 'v' ],
[ 'v', 'b' ]
];
if (commonSubs.some(pair =>
pair[0] === char1 &&
pair[1] === char2))
{
console.log(
`[DEBUG] Encontrada corrección ortográfica: "${
dictWord}"`); // LOG ORTO 5
posibleCorreccionOrtografica =
dictWord; // Encontramos corrección
// ortográfica
break;
}
}
}
}
// Decidir qué error reportar
console.log(`[DEBUG] Resultado búsqueda ortográfica: ${
posibleCorreccionOrtografica}`); // LOG ORTO 6
// Si encontramos una corrección ortográfica válida,
// reportar eso
if (posibleCorreccionOrtografica)
{
console.log(
`[DEBUG] Pushing error ortografia_dic`); // LOG
// ORTO 7
// Priorizar error ortográfico encontrado en
// diccionario
errores.push({
palabra : palabra,
sugerencia : posibleCorreccionOrtografica,
tipo : "ortografia_dic",
severidad : "alta",
motivo :
`Posible error ortográfico. Encontrado en diccionario: "${
posibleCorreccionOrtografica}"`,
palabraOriginal : palabra,
palabraProblematica : palabraLimpia,
origen : 'Local (Diccionario)'
}); // <-- Aquí debería ser origen 'Local (Diccionario/Orto)'?
errorAgregado = true; // Marcar que se agregó un error
}
else
{
console.log(
`[DEBUG] Pushing error tilde (detectarTilde inválido)`); // LOG ORTO 8
// Si no, reportar el error de tilde original
errores.push({
palabra : palabra,
sugerencia : sugerida, // <-- Cambiado de sugerenciaTilde a sugerida
tipo : "tilde",
severidad : "media",
motivo : `Tipo detectado: ${
tildeData.tipo}, Válida localmente: ${
tildeData.esValida}`,
palabraOriginal : palabra,
palabraProblematica : palabraLimpia,
origen : 'Local'
}); // <-- Aquí debería ser origen 'Local (Tilde)'?
errorAgregado = true; // Marcar que se agregó un error
}
}
// console.log(`[DEBUG] Palabra "${ // <-- Ya no es necesario
// palabraLimpia}" está excluida o es especial.`); // LOG ORTO 9
}
}
});
return errores;
}
//********************************************************************************************************************************
// Nombre: revisarConLanguageTool
// Fecha modificación: 2025-04-10 22:15 GMT-5
// Autor: mincho77
// Entradas:
// - texto (string): Texto a evaluar
// - timeout (opcional): Tiempo máximo para la API (en milisegundos)
// Salidas:
// Promise<{
// errores: Array<{
// palabra: string,
// sugerencia: string,
// tipo: 'ortografia'|'gramatica',
// severidad: 'alta'|'media'
// }>,
// apiStatus:
// 'success'|'timeout'|'parse_error'|'api_error'|'network_error'
// }>
// Prerrequisitos: Requiere permisos GM_xmlhttpRequest y @connect a
// api.languagetool.org Descripción: Consulta la API para errores
// ortográficos y gramaticales
//********************************************************************************************************************************
// Descripción: Consulta la API para errores avanzados
//********************************************************************************************************************************
function revisarConLanguageTool(texto, timeout = 5000)
{
return new Promise((resolve) => {
const timer = setTimeout(
() => { resolve({ errores : [], apiStatus : "timeout" }); },
timeout);
GM_xmlhttpRequest({
method : "POST",
url : "https://api.languagetool.org/v2/check",
headers :
{ "Content-Type" : "application/x-www-form-urlencoded" },
// Usar el idioma activo para la API
data : `language=${getApiLangCode(activeDictionaryLang)}&text=${
encodeURIComponent(texto)}`,
onload : function(response) {
clearTimeout(timer);
if (response.status === 200)
{
try
{
const data = JSON.parse(response.responseText);
const errores = data.matches.map((match) => {
// Validar que match y sus propiedades existan
const palabraProblematica = // <-- Renombrar
// para claridad
match?.context?.text?.substring(
match?.context?.offset || 0,
(match?.context?.offset || 0) +
(match?.context?.length || 0)) ||
"(sin contexto)";
// Obtener la palabra original del contexto si
// es posible
const palabraOriginal =
match?.context?.text?.substring(
match?.context?.offset || 0,
(match?.context?.offset || 0) +
(match?.context?.length || 0)) ||
palabraProblematica; // Fallback a la
// problemática
const sugerencia =
match?.replacements?.[0]?.value ||
match?.context?.text || "(sin sugerencia)";
const tipo =
"ortografia"; // Valor predeterminado ya que
// se eliminó la categoría
const severidad =
match?.rule?.issueType === "misspelling"
? "alta"
: "media";
const motivo =
match?.message ||
`Regla API: ${
match?.rule
?.id}`; // <-- Mensaje de la regla
return {
palabraOriginal, // <-- Palabra original del
// contexto
palabraProblematica, // <-- Palabra que
// disparó la regla
sugerencia,
tipo,
severidad,
motivo, // <-- Mensaje de la regla
origen : 'API' // <-- Indicar origen
};
});
resolve({ errores, apiStatus : "success" });
}
catch (e)
{
resolve(
{ errores : [], apiStatus : "parse_error" });
}
}
else
{
resolve({ errores : [], apiStatus : "api_error" });
}
},
onerror : function() {
clearTimeout(timer);
resolve({ errores : [], apiStatus : "network_error" });
}
});
});
}
//********************************************************************************************************************************
// Nombre: filtrarErrores
// Descripción: Elimina duplicados y errores menores
//********************************************************************************************************************************
function filtrarErrores(errores)
{
const unicos = []; // Array para almacenar errores únicos
const vistas = new Set();
errores.forEach((error) => {
// Usar palabraProblematica para la clave de unicidad
const clave =
`${error.palabraProblematica}-${error.sugerencia}-${error.tipo}`;
if (!vistas.has(clave))
// Si la clave no está en el Set, es un error único
{
vistas.add(clave);
unicos.push(error);
}
});
return unicos.sort((a, b) => {
if (a.severidad === b.severidad)
// Ordenar por severidad (alta primero)
return 0;
return a.severidad === "alta" ? -1 : 1;
});
}
//********************************************************************************************************************************
// Nombre: tieneTildesIncorrectas
// Fecha modificación: 2025-04-10 21:30 GMT-5
// Autor: mincho77
// Entradas:
// - palabra (string): Palabra a evaluar
// - config (opcional): {
// ignorarMayusculas: true,
// considerarAdverbios: true,
// considerarMonosílabos: false
// }
// Salidas: boolean - true si la palabra requiere corrección de tilde
// Descripción:
// Evalúa si una palabra en español tiene tildes incorrectas según las
// reglas RAE. Incluye casos especiales para adverbios, hiatos, diptongos y
// monosílabos.
//********************************************************************************************************************************
function tieneTildesIncorrectas(palabra, config = {})
{
if (typeof palabra !== "string" || palabra.length === 0)
return false;
const settings = {
ignorarMayusculas : config.ignorarMayusculas !==
false, // No marcar errores en MAYÚSCULAS
considerarAdverbios :
config.considerarAdverbios !==
false, // Evaluar adverbios terminados en -mente
considerarMonosílabos :
config.considerarMonosílabos || false, // Seguir reglas pre-2010
};
// Normalizar palabra (quitar tildes existentes para evaluación)
const palabraNormalizada = palabra.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
const tieneTildeActual = /[áéíóú]/.test(palabra);
// 1. Reglas para palabras específicas (excepciones)
const reglasEspecificas = {
// Adverbios terminados en -mente
mente :
settings.considerarAdverbios && /mente$/i.test(palabra)
? tieneTildesIncorrectas(palabra.replace(/mente$/i, ""), config)
: false,
// Monosílabos
monosilabos : settings.considerarMonosílabos &&
[
"fe",
"fue",
"fui",
"vio",
"dio",
"lia",
"lie",
"lio",
"rion",
"ries",
"se",
"te",
"de",
"si",
"ti"
].includes(palabraNormalizada),
// Casos especiales
solo : palabraNormalizada === "solo" && !tieneTildeActual,
este : /^este(s)?$/i.test(palabraNormalizada) && !tieneTildeActual,
aun : palabraNormalizada === "aun" && !tieneTildeActual,
guion : palabraNormalizada === "guion" && !tieneTildeActual,
hui : palabraNormalizada === "hui" && !tieneTildeActual
};
if (Object.values(reglasEspecificas).some((v) => v))
return true;
// 2. Reglas generales de acentuación
const silabas = separarSilabas(palabraNormalizada);
const numSilabas = silabas.length;
const ultimaLetra = palabraNormalizada.slice(-1);
// Palabras agudas (tildan en última sílaba)
if (numSilabas === 1)
return false;
// Monosílabos ya evaluados
const esAguda = numSilabas === 1 ||
(numSilabas > 1 && silabas[numSilabas - 1].acento);
const debeTildarAguda =
esAguda && /[nsaeiouáéíóú]$/i.test(palabraNormalizada);
const palabraLower = palabra.toLowerCase();
if (correccionesEspecificas[palabraLower])
{
return aplicarCapitalizacion(palabra,
correccionesEspecificas[palabraLower]);
}
// Determinar sílaba a tildar
if (numSilabas > 2 && esEsdrujula(palabra))
{
silabaTildada = numSilabas - 3;
}
else if (numSilabas > 1 && esGrave(palabra))
{
silabaTildada = numSilabas - 2;
}
else if (esAguda(palabra))
{
silabaTildada = numSilabas - 1;
}
if (silabaTildada >= 0)
{
return aplicarTildeSilaba(palabra, silabas, silabaTildada);
}
return palabra;
}
// ==================== FUNCIONES AUXILIARES ====================
//********************************************************************************************************************************
// Nombre: separarSilabas
// Fecha modificación: 2025-04-10 22:15 GMT-5
// Autor: mincho77
// Entradas: palabra (string) – Palabra a separar en sílabas.
// Salidas: Array<{ texto: string, acento: boolean }> – Lista de sílabas
// Descripción: Separa la palabra en sílabas y determina si cada sílaba
// tiene acento. Implementación simplificada para propósitos de
// normalización visual.
//********************************************************************************************************************************
function separarSilabas(palabra)
{ // Implementación simplificada (usar librería completa en producción)
const vocalesFuertes = /[aeoáéó]/;
const vocalesDebiles = /[iuü]/;
const silabas = [];
let silabaActual = "";
let tieneVocalFuerte = false;
for (let i = 0; i < palabra.length; i++)
{
const c = palabra[i];
silabaActual += c;
if (vocalesFuertes.test(c))
{
tieneVocalFuerte = true;
}
// Lógica simplificada de separación
if (i < palabra.length - 1 &&
((vocalesFuertes.test(c) &&
vocalesFuertes.test(palabra[i + 1])) ||
(vocalesDebiles.test(c) &&
vocalesFuertes.test(palabra[i + 1]) && !tieneVocalFuerte)))
{
silabas.push(
{ texto : silabaActual, acento : tieneVocalFuerte });
silabaActual = "";
tieneVocalFuerte = false;
}
}
if (silabaActual)
{
silabas.push({ texto : silabaActual, acento : tieneVocalFuerte });
}
return silabas;
}
function esGrave(palabra)
{
if (!palabra || typeof palabra !== "string")
{
return false;
}
const silabas = separarSilabas(palabra);
const numSilabas = silabas.length;
const tieneTilde = /[áéíóú]/.test(palabra);
let silabaTonicaIndex = -1;
if (tieneTilde)
{
for (let i = 0; i < silabas.length; i++)
{
if (/[áéíóú]/.test(silabas[i]))
{
silabaTonicaIndex = i;
break;
}
}
}
// Si tiene tilde, verificar si está en la penúltima sílaba
if (tieneTilde && silabaTonicaIndex === numSilabas - 2)
{
return true; // Es grave con tilde correcta
}
// Si NO tiene tilde, verificar si DEBERÍA ser grave
if (!tieneTilde && numSilabas > 1 &&
!/[ns]$/.test(palabra.slice(-1).toLowerCase()) &&
/[aeiou]$/.test(palabra.slice(-1).toLowerCase()))
{
return true; // Debería ser grave (sin tilde)
}
return false;
}
function esAguda(palabra)
{
if (!palabra || typeof palabra !== "string")
{
return false;
}
const silabas = separarSilabas(palabra); // **NECESITA IMPLEMENTARSE**
const numSilabas = silabas.length;
const tieneTilde = /[áéíóú]/.test(palabra);
let silabaTonicaIndex = -1;
if (tieneTilde)
{
for (let i = 0; i < silabas.length; i++)
{
if (/[áéíóú]/.test(silabas[i]))
{
silabaTonicaIndex = i;
break;
}
}
}
// Si tiene tilde, verificar si está en la última sílaba
if (tieneTilde && silabaTonicaIndex === numSilabas - 1)
{
return true; // Es aguda con tilde correcta
}
// Si NO tiene tilde, verificar si DEBERÍA ser aguda
if (!tieneTilde && numSilabas > 1 &&
/[ns]$/.test(palabra.slice(-1).toLowerCase()) &&
/[aeiou]$/.test(palabra.slice(-1).toLowerCase()))
{
return true; // Debería ser aguda (sin tilde)
}
return false;
}
//---------------------------------------------------
function tipoVocal(vocal)
{
const debiles = "iu";
const fuertes = "aeoáéó";
if (debiles.includes(vocal.toLowerCase()))
{
return "débil";
}
if (fuertes.includes(vocal.toLowerCase()))
{
return "fuerte";
}
return null; // No es una vocal
}
function agruparVocales(silaba)
{
let nuevaSilaba = "";
let i = 0;
while (i < silaba.length)
{
const char = silaba[i];
const siguiente = silaba[i + 1];
const siguiente2 = silaba[i + 2];
const tipo1 = tipoVocal(char);
if (tipo1)
{
if (siguiente && tipoVocal(siguiente))
{
const tipo2 = tipoVocal(siguiente);
if (tipo1 === "fuerte" && tipo2 === "fuerte")
{
nuevaSilaba += char; // Hiato: se separan
i++;
continue;
}
else if (tipo1 === "fuerte" && tipo2 === "débil")
{
if (siguiente2 && tipoVocal(siguiente2) === "débil")
{
nuevaSilaba +=
char + siguiente + siguiente2; // Triptongo
i += 3;
continue;
}
nuevaSilaba += char + siguiente; // Diptongo creciente
i += 2;
continue;
}
else if (tipo1 === "débil" && tipo2 === "fuerte")
{
nuevaSilaba += char + siguiente; // Diptongo decreciente
i += 2;
continue;
}
else if (tipo1 === "débil" && tipo2 === "débil")
{
nuevaSilaba += char + siguiente; // Diptongo
i += 2;
continue;
}
}
nuevaSilaba += char;
i++;
}
else
{
nuevaSilaba += char;
i++;
}
}
return nuevaSilaba;
}
function separarSilabas(palabra)
{
if (!palabra || typeof palabra !== "string")
{
return [];
}
palabra = palabra.toLowerCase();
let silabas = [];
let buffer = "";
for (let i = 0; i < palabra.length; i++)
{
buffer += palabra[i];
if (esSeparable(palabra, i))
{
silabas.push(agruparVocales(buffer));
buffer = "";
}
}
silabas.push(agruparVocales(buffer)); // Añadir la última sílaba
return silabas;
}
function esSeparable(palabra, indice)
{
if (indice === palabra.length - 1)
{
return false; // No separar al final
}
const char = palabra[indice];
const siguiente = palabra[indice + 1];
if (!tipoVocal(char) && tipoVocal(siguiente))
{
return true; // Consonante seguida de vocal
}
if (tipoVocal(char) && tipoVocal(siguiente))
{
return tipoVocal(char) === "fuerte" &&
tipoVocal(siguiente) === "fuerte"; // Hiato
}
if (!tipoVocal(char) && !tipoVocal(siguiente))
{
// Dos consonantes seguidas
if (indice > 0 && tipoVocal(palabra[indice - 1]))
{
if ([ "l", "r" ].includes(siguiente) &&
!["b", "c", "d", "f", "g", "k", "p", "t"].includes(char))
{
return false; // No separar "bl", "cl", "dr", etc.
}
return true;
}
}
return false;
}
///-----------------------------------------
//********************************************************************************************************************************
// Nombre: aplicarCapitalizacion
// Fecha modificación: 2025-04-10 22:15 GMT-5
// Autor: mincho77
// Entradas: original (string) – Palabra original
// corregida (string) – Palabra corregida
// Salidas: string – Palabra corregida con mayúsculas/minúsculas
// Descripción: Aplica mayúsculas/minúsculas a la palabra corregida
// según la original. Mantiene mayúsculas y minúsculas en la primera letra
// y el resto de la palabra.
//********************************************************************************************************************************
function aplicarCapitalizacion(original, corregida)
{
if (original === original.toUpperCase())
{
return corregida.toUpperCase();
}
else if (original[0] === original[0].toUpperCase())
{
return corregida[0].toUpperCase() + corregida.slice(1);
}
return corregida;
}
//********************************************************************************************************************************
// Nombre: aplicarTildeSilaba
// Fecha modificación: 2025-04-10 22:15 GMT-5
// Autor: mincho77
// Entradas: palabra (string) – Palabra original
// silabas (Array<{ texto: string, acento: boolean }>) – Lista de
// sílabas
// indiceSilaba (number) – Índice de la sílaba a tildar
// Salidas: string – Palabra con tilde aplicada
// Descripción: Aplica tilde a la sílaba especificada
// según las reglas de acentuación. La sílaba se identifica por su índice
// en la lista de sílabas. La función asume que la palabra ya ha sido
// separada en sílabas y que el índice es válido.
//********************************************************************************************************************************
function aplicarTildeSilaba(palabra, silabas, indiceSilaba)
{
let resultado = "";
let posActual = 0;
silabas.forEach((silaba, i) => {
if (i === indiceSilaba)
{
const conTilde = silaba.texto.replace(
/([aeiou])([^aeiou]*)$/, (match, vocal, resto) => {
return (
vocal.normalize("NFD").replace(/[\u0300-\u036f]/g, "") +
"́" + resto);
});
resultado += conTilde;
}
else
{
resultado += silaba.texto;
}
});
return resultado;
}
//********************************************************************************************************************************
// Nombre: esHiatoObligatorio
// Fecha modificación: 2025-05-03 23:12
// Autor: mincho77
// Entradas: palabra (string)
// Salidas: booleano (true si se considera que debería llevar tilde por
// hiato obligatorio) Descripción: Evalúa si una palabra debería llevar
// tilde por formar hiato acentual aunque no la tenga escrita.
//********************************************************************************************************************************
function esHiatoObligatorio(palabra)
{
const hiatosConocidos = [
"vehiculo",
"vehiculos",
"rio",
"tio",
"mia",
"mio",
"frio",
"envio",
"tardia",
"estaria",
"baul",
"continua",
"confia",
"rubi",
"paul"
];
const palabraSinTilde = palabra.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
return hiatosConocidos.includes(palabraSinTilde);
}
//********************************************************************************************************************************
//********************************************************************************************************************************
// Nombre: applyNormalization
// Fecha modificación: 2025-04-15
// Hora: 13:30:00
// Autor: mincho77
// Entradas: Ninguna directamente (usa el arreglo `changes` ya cargado)
// Salidas: Aplica acciones en WME y muestra resultados
// Prerrequisitos: `changes` debe contener objetos válidos con `place`,
// `newName`, y opcionalmente `delete`
//********************************************************************************************************************************
function applyNormalization(changes)
{
if (!Array.isArray(changes) || changes.length === 0)
{
showModal({
title : "Información",
message : "No hay cambios seleccionados para aplicar",
confirmText : "Aceptar",
type : "info"
});
return;
}
let lastAttemptedPlace = null;
let cambiosRechazados = 0;
try
{
changes.forEach((change) => {
lastAttemptedPlace = {
name : change.originalName ||
change.place.attributes?.name || "Sin nombre",
id : change.place.getID?.() || "ID no disponible"
};
if (change.delete)
{
const DeleteObject = require("Waze/Action/DeleteObject");
const action = new DeleteObject(change.place);
W.model.actionManager.add(action);
}
else
{
const UpdateObject = require("Waze/Action/UpdateObject");
const action =
new UpdateObject(change.place, { name : change.newName });
W.model.actionManager.add(action);
}
});
observarErroresDeWME(changes.length, lastAttemptedPlace);
W.controller?.setModified?.(true);
showModal({
title : "Éxito",
message : `${
changes
.length} cambio(s) enviados. Clic en Guardar para aplicar en WME.`,
type : "success",
autoClose : 2000
});
}
catch (error)
{
console.error("Error aplicando cambios:", error);
showModal({
title : "Error",
message :
"Error al aplicar cambios. Ver consola para detalles.",
confirmText : "Aceptar",
type : "error"
});
}
}
//********************************************************************************************************************************
// Nombre: evaluarOrtografiaConTildes
// Fecha modificación: 2025-04-02
// Autor: mincho77
// Entradas: name (string) - Nombre del lugar
// Salidas: objeto con errores detectados
// Descripción:
// Evalúa palabra por palabra si falta una tilde en las palabras que lo
// requieren, según las reglas del español. Primero normaliza el nombre y
// luego verifica si las palabras necesitan una tilde.
//********************************************************************************************************************************
function evaluarOrtografiaConTildes(name)
{ // Si el nombre está vacío, retornar inmediatamente una promesa resuelta
if (!name)
{
return Promise.resolve(
{ hasSpellingWarning : false, spellingWarnings : [] });
}
const palabras = name.trim().split(/\s+/);
const spellingWarnings = [];
console.log(
`[evaluarOrtografiaConTildes] Verificando ortografía de: ${name}`);
palabras.forEach(
async (
palabra,
index) => { // Normalizar la palabra antes de cualquier verificación
let normalizada = await normalizePlaceName(palabra, true);
// Ignorar palabras con "&" o que sean emoticonos
if (/^[A-Za-z]&[A-Za-z]$/.test(normalizada) ||
/^[\u263a-\u263c\u2764\u1f600-\u1f64f\u1f680-\u1f6ff]+$/.test(
normalizada))
{
return; // No verificar ortografía
}
// Excluir palabras específicas como "y" o "Y"
if (normalizada.toLowerCase() === "y" ||
/^\d+$/.test(normalizada) || normalizada === "-")
{
return; // Ignorar
}
// Excluir palabras específicas como "e" o "E"
if (normalizada.toLowerCase() === "e" ||
/^\d+$/.test(normalizada) || normalizada === "-")
{
return; // Ignorar
}
// Verificar si la palabra está en la lista de excluidas
if (excludeWords.some((w) => w.toLowerCase() ===
normalizada.toLowerCase()))
{
return; // Ignorar palabra excluida
}
// Validar que no tenga más de una tilde
const cantidadTildes =
(normalizada.match(/[áéíóú]/g) || []).length;
if (cantidadTildes > 1)
{
spellingWarnings.push({
original : palabra,
sugerida : null, // No hay sugerencia válida
tipo : "Error de tildes",
posicion : index
});
return;
}
// Verificar ortografía usando la API de LanguageTool
checkSpellingWithAPI(normalizada)
.then((errores) => {
errores.forEach((error) => {
spellingWarnings.push({
original : error.palabra,
sugerida : error.sugerencia,
tipo : "LanguageTool",
posicion : index
});
});
})
.catch((err) => {
console.error(
"Error al verificar ortografía con LanguageTool:", err);
});
});
return {
hasSpellingWarning : spellingWarnings.length > 0,
spellingWarnings
};
}
//********************************************************************************************************************************
// Nombre: toggleSpinner
// Fecha modificación: 2025-03-31
// Autor: mincho77
// Entradas:
// show (boolean) - true para mostrar el spinner, false para ocultarlo
// message (string, opcional) - mensaje personalizado a mostrar junto al
// spinner Salidas: ninguna (modifica el DOM) Prerrequisitos: debe existir
// el estilo CSS del spinner en el documento Descripción: Muestra u oculta
// un indicador visual de carga con un mensaje opcional. El spinner usa un
// emoji de reloj de arena (⏳) con animación de rotación para indicar que
// el proceso está en curso.
//********************************************************************************************************************************
function toggleSpinner(show,
message = S('spinnerCheckingMessage'),
progress = null) // Usar S() para mensaje por defecto
{
let existingSpinner = document.querySelector(".spinner-overlay");
if (existingSpinner)
{
if (show)
{ // Actualiza el mensaje y el progreso si el spinner ya existe
const spinnerMessage =
existingSpinner.querySelector(".spinner-message");
spinnerMessage.innerHTML = `
${message}
${
progress !== null
? `<br><strong>${progress}${
S('spinnerProgressMessage')}</strong>` // Usar S() para
// parte del
// progreso
: ""}
`;
}
else
{
existingSpinner.remove(); // Ocultar el spinner
}
return;
}
if (show)
{
const spinner = document.createElement("div");
spinner.className = "spinner-overlay";
spinner.innerHTML = `
<div class="spinner-content">
<div class="spinner-icon">⏳</div>
<div class="spinner-message">
${message}
${
progress !== null
? `<br><strong>${progress}${
S('spinnerProgressMessage')}</strong>` // Usar S() para
// parte del progreso
: ""}
</div>
</div>
`;
document.body.appendChild(spinner);
}
}
// Agregar los estilos CSS necesarios
const spinnerStyles = `
<style>
.spinner-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
}
.spinner-content {
background: white;
padding: 20px;
border-radius: 8px;
text-align: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
}
.spinner-icon {
font-size: 24px;
margin-bottom: 10px;
animation: spin 1s linear infinite; /* Aseguramos que la animación esté activa */
display: inline-block;
}
.spinner-message {
color: #333;
font-size: 14px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>`;
// Insertar los estilos al inicio del documento
document.head.insertAdjacentHTML("beforeend", spinnerStyles);
if (!Array.prototype.flat)
{
Array.prototype.flat = function(depth = 1) {
return this.reduce(function(flat, toFlatten) {
return flat.concat(Array.isArray(toFlatten)
? toFlatten.flat(depth - 1)
: toFlatten);
}, []);
};
}
//********************************************************************************************************************************
// Nombre: escapeHtml
// Fecha modificación: 2025-06-20 18:30 GMT-5
// Autor: mincho77
// Entradas:
// - unsafe (string|any): Valor a escapar
// Salidas:
// - string: Texto escapado seguro para usar en HTML
// Prerrequisitos:
// - Ninguno
// Descripción:
// Convierte caracteres especiales en entidades HTML para prevenir XSS.
// Escapa los siguientes caracteres:
// & → &
// < → <
// > → >
// " → "
// ' → '
// Si el input no es string, lo convierte a string.
// Devuelve string vacío si el input es null/undefined.
//********************************************************************************************************************************
function escapeHtml(unsafe)
{
if (unsafe === null || unsafe === undefined)
return "";
return String(unsafe)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
//********************************************************************************************************************************
// Nombre: escapeRegExp
// Descripción: Escapa caracteres especiales para usar en new RegExp().
//********************************************************************************************************************************
function escapeRegExp(string)
{
// $& significa la cadena completa coincidente
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
let cambiosRechazados = 0;
//**********************************************************************
// Nombre: observarErroresDeWME
// Fecha modificación: 2025-04-15
// Hora: 13:01:25
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Descripción: Observa errores de WME y muestra un modal si se detecta
// un mensaje de error relacionado con restricciones de edición.
// Prerrequisitos: Ninguno
//**********************************************************************
function observarErroresDeWME(totalEsperado, lastAttemptedPlace)
{
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList)
{
for (const node of mutation.addedNodes)
{
if (node.nodeType === 1 &&
node.innerText?.includes(
"That change isn't allowed at this time"))
{
observer.disconnect();
const ahora = new Date().toLocaleString("es-CO");
const historico = JSON.parse(
localStorage.getItem("rechazosWME") || "[]");
historico.push({
timestamp : ahora,
motivo : "Cambio no permitido por WME",
lugar : lastAttemptedPlace?.name || "Desconocido",
id : lastAttemptedPlace?.id || "N/A"
});
localStorage.setItem("rechazosWME",
JSON.stringify(historico));
showModal({
title : "Resultado parcial",
message :
`⚠️ Algunos lugares no pudieron ser modificados por restricciones de WME.\n` +
`Verifica el historial o vuelve a intentarlo.`,
confirmText : "Aceptar",
type : "warning"
});
break;
}
}
}
});
observer.observe(document.body, { childList : true, subtree : true });
}
//******************************************************************************************************************************************************************
// Nombre: openFloatingPanel
// Fecha modificación: 2025-04-15
// Hora: 13:01:25
// Autor: mincho77
// Entradas: placesToNormalize (array) - Arreglo de lugares a normalizar
// Salidas: Ninguna
// Descripción: Abre un panel flotante con una tabla para normalizar nombres
// de lugares. Permite aplicar cambios, excluir palabras y agregar
// palabras especiales. Incluye un botón para cerrar el panel.
// Prerrequisitos: Ninguno
//******************************************************************************************************************************************************************
function openFloatingPanel(placesToNormalize)
{
// Cierra panel previo si existe
const existingPanel =
document.getElementById("normalizer-floating-panel");
if (existingPanel)
{
existingPanel.remove();
}
const panel = document.createElement("div");
panel.id = "normalizer-floating-panel";
panel.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 90%; max-width: 1200px; max-height: 80vh; background: white;
padding: 0; /* Quitar padding principal para controlar scroll interno */ border-radius: 8px; box-shadow: 0 0 25px rgba(0,0,0,0.4);
z-index: 10000; overflow-y: auto; font-family: Arial, sans-serif;
`;
let html = `
<style>
#normalizer-table { width: 100%; border-collapse: collapse; margin: 15px 0; }
#normalizer-table th { background: #2c3e50; color: white; padding: 10px; text-align: left; }
#normalizer-table td { padding: 8px 10px; border-bottom: 1px solid #eee; vertical-align: top; /* Alinear contenido arriba */ }
.warning-row { background: #fff8e1; }
.normalize-btn, .apply-btn, .add-exclude-btn, .add-special-btn, .view-place-btn { /* Añadido .view-place-btn */
padding: 8px 16px; /* Aumentar el tamaño del botón */
margin: 2px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: all 0.3s;
}
.normalize-btn { background: #3498db; color: white; }
.apply-btn { background: #2ecc71; color: white; }
.add-exclude-btn { background: #e67e22; color: white; }
/* Estilo modificado para el botón Ver/Link */
.view-place-btn {
background: none; /* Sin fondo */
color: #333; /* Color oscuro para el icono */
padding: 2px; /* Padding reducido */
font-size: 16px; /* Ajustar tamaño si es necesario */
}
.add-special-btn { background: #9b59b6; color: white; }
/* Estilo para el botón de cerrar */
.close-btn {
position: absolute; /* Posición absoluta relativa al header sticky */ top: 15px; right: 15px;
background: #e74c3c; color: white; border: none;
width: 30px; height: 30px; border-radius: 50%; font-weight: bold;
cursor: pointer; z-index: 11; /* Encima de todo */
}
input[type="checkbox"] { transform: scale(1.3); margin: 0 5px; }
input[type="text"] { width: 100%; padding: 5px; box-sizing: border-box; }
/* Estilos para sticky header */
.panel-header-sticky {
position: sticky;
top: 0;
background-color: white;
padding: 15px 20px 10px 20px; /* Padding para el header */
z-index: 10;
border-bottom: 1px solid #ccc; /* Línea divisoria */
}
#normalizer-table th {
position: sticky;
top: 75px; /* Ajusta esto según la altura del header */
z-index: 5; /* Debajo del header principal pero encima del contenido */
}
</style>
<style> /* Estilos adicionales para <details> */
details > summary { list-style: none; cursor: pointer; }
details > summary::-webkit-details-marker { display: none; } /* Ocultar marcador por defecto en Chrome/Safari */
.error-summary-arrow { display: inline-block; transition: transform 0.2s; margin-right: 5px; }
details[open] .error-summary-arrow { transform: rotate(90deg); }
</style>
<!-- Contenedor para header pegajoso -->
<div class="panel-header-sticky">
<button class="close-btn" id="close-panel-btn">×</button>
<h2 style="color: #2c3e50; margin-top: 0; margin-bottom: 5px;">Normalizador de Nombres</h2>
<div style="margin: 0; color: #7f8c8d;">
<span id="places-count">${placesToNormalize.length} ${
S('placesToReview')}</span> |
<span id="ready-count-display" style="color: #27ae60; font-weight: bold;">0 ${
S('placesReadyToNormalize')}</span> <!-- Nuevo contador -->
</div>
</div>
<div style="padding: 0 20px 20px 20px;"> <!-- Contenedor para el resto del contenido con padding -->
<table id="normalizer-table">
<thead> <!-- Usar helper S() para cabeceras -->
<tr>
<th width="5%">${S('applyCol')}</th> <!-- 1 -->
<th width="5%">${S('deleteCol')}</th> <!-- 2 -->
<th width="5%">${S('permaCol')}</th> <!-- 3 - Perma -->
<th width="8%">${
S('categoryCol')}</th> <!-- Nueva Columna Categoría -->
<th width="5%">${
S('typeCol')}</th> <!-- 4 - Tipo Place -->
<th width="20%">${
S('currentNameCol')}</th> <!-- 5 -->
<th width="25%">${S('normalizedNameCol')}</th> <!-- 6 -->
<th width="15%">${S('problemCol')}</th> <!-- 7 -->
<th width="10%">${S('actionsCol')}</th> <!-- 8 -->
</tr>
</thead>
<tbody>`;
placesToNormalize.forEach((place, index) => {
const {
originalName,
newName,
hasSpellingWarning,
spellingWarnings = [], // This now contains objects from
// detectarTilde where esValida is false
place : venue
} = place;
const placeId = venue.getID();
// Obtener la primera categoría o un guion si no hay
const category = venue.attributes.categories?.[0] || '-';
// Determinar tipo de lugar (Área o Punto)
// --- Lógica robusta para obtener tipo de geometría ---
const geometry = venue.getOLGeometry();
let geometryType = null;
if (geometry)
{
if (typeof geometry.getType === 'function')
{
geometryType = geometry.getType(); // Método estándar OL3+
}
else if (geometry.CLASS_NAME)
{
// Fallback para versiones anteriores/diferentes (ej:
// "OpenLayers.Geometry.Polygon")
geometryType = geometry.CLASS_NAME.split('.').pop();
}
}
const isArea = geometryType === 'Polygon';
const placeTypeIcon = isArea ? '⭔' : '⊙';
// Usar S() para obtener el tooltip correcto según el idioma y tipo
const placeTypeTitle =
isArea ? S('placeTypeAreaTooltip') : S('placeTypePointTooltip');
html += `
<tr>
<td>
<input type="checkbox" class="normalize-checkbox" data-index="${
index}" data-type="full">
</td>
<td>
<input type="checkbox" class="delete-checkbox" data-index="${
index}">
</td>
<td> <!-- 3 - Celda para el botón Perma/Ver -->
<button class="view-place-btn" data-index="${
index}" title="Ver detalles del lugar">🔗</button>
</td>
<td>${escapeHtml(category)}</td> <!-- Nueva Celda para Categoría -->
</td>
<td> <!-- 4 - Celda para el icono de tipo con tooltip -->
<span title="${placeTypeTitle}">${placeTypeIcon}</span>
</td>
<td>${escapeHtml(originalName)}</td>
<td>
<input type="text" class="new-name-input" value="${
escapeHtml(newName)}"
data-index="${index}" data-place-id="${
placeId}" data-type="full"
data-original="${escapeHtml(originalName)}">
</td><td>${
originalName !== newName ? S('normalizationLabel') : ""}${
spellingWarnings.length > 0
? ` / ${spellingWarnings.length} Errores`
: ""}</td>
<td>
<button class="normalize-btn" data-index="${
index}">NrmliZer</button>
<button class="add-exclude-btn" data-word="${
escapeHtml(
originalName)}" data-index="${index}">ExcludeWrd</button>
</td>
</tr>`;
// Si hay errores, añadir la fila desplegable
if (spellingWarnings.length > 0)
{
html += `
<tr>
<td colspan="9" style="padding-top: 0; padding-bottom: 5px;"> <!-- Ajustado colspan a 9 -->
<details>
<summary style="color: red;">
<span class="error-summary-arrow">▶</span> ${
// Usar helper S()
spellingWarnings.length} error(es) encontrado(s)
</summary>
<ul style="margin-top: 5px; padding-left: 20px;">
${
spellingWarnings
.map((warning, warningIndex) =>
` <li style="margin-bottom: 5px;">
${S('errorInLabel')} "${
escapeHtml(warning.palabraProblematica ||
warning.palabraOriginal)}"
(${
escapeHtml(warning.motivo || warning.tipo ||
'Ortografía')})<br>
${S('suggestionLabel')} "<strong>${
escapeHtml(warning.sugerida)}</strong>"
<button class="btn apply-suggestion-btn"
data-index="${index}"
data-problem-word="${
escapeHtml(warning.palabraProblematica ||
warning.palabraOriginal)}"
data-suggestion="${
escapeHtml(warning.sugerida)}"
style="margin-left: 10px; background-color: #27ae60; color: white; padding: 2px 6px; font-size: 0.9em;">
${S('useSuggestionButtonLabel')}
</button>
</li>
`)
.join('')}
</ul>
</details>
</td>
</tr>`;
}
});
html += `</tbody></table>
<div style="margin-top: 20px; text-align: right;">
<button id="apply-all-btn" style="background: #27ae60; color: white; padding: 10px 20px;
border: none; border-radius: 4px; font-weight: bold;">${
S('applySelectedButton')}</button>
<button id="cancel-btn" style="background: #e74c3c; color: white; padding: 10px 20px;
border: none; border-radius: 4px; margin-left: 10px; font-weight: bold;">${
S('cancelButton')}</button>
</div>
</div>`;
panel.innerHTML = html;
document.body.appendChild(panel);
// --- Función para actualizar el contador de listos ---
function updateReadyCount()
{
const readyCount =
panel
.querySelectorAll(
'.normalize-checkbox[data-type="full"]:checked')
.length;
const countDisplay = panel.querySelector('#ready-count-display');
if (countDisplay)
{
countDisplay.innerHTML =
`${readyCount} ${S('placesReadyToNormalize')}`;
}
}
// --- Fin función contador ---
// Llamada inicial para establecer el contador en 0
updateReadyCount();
// --- Añadir listener directo a checkboxes de aplicar ---
panel.querySelectorAll('.normalize-checkbox[data-type="full"]')
.forEach(checkbox => {
checkbox.addEventListener(
'change',
updateReadyCount); // Llamar a update en cualquier cambio
});
// --- Fin listener checkboxes ---
// --- Añadir listener para el botón de cerrar (X) ---
const closePanelBtn = panel.querySelector("#close-panel-btn");
if (closePanelBtn)
{
closePanelBtn.addEventListener("click", () => panel.remove());
}
else
{
console.error("Error: No se encontró el botón #close-panel-btn");
}
// --- Fin listener botón cerrar ---
//********
// Eventos dinámicos para cada lugar
panel.querySelectorAll('.normalize-btn').forEach(btn => {
btn.addEventListener("click", function() {
const row = this.closest("tr");
const input = row.querySelector(".new-name-input");
const checkbox = row.querySelector(".normalize-checkbox");
if (input && checkbox)
{
checkbox.checked = true;
this.textContent = "Listo";
}
});
});
panel.querySelectorAll('.add-special-btn').forEach(btn => {
btn.addEventListener("click", function() {
const word = this.dataset.word;
if (word)
{
addSpecialWord(word);
const row = this.closest("tr");
const checkbox = row.querySelector(".normalize-checkbox");
const normalizeButton = row.querySelector(".normalize-btn");
if (checkbox)
checkbox.checked = false;
if (normalizeButton)
normalizeButton.textContent = "NrmliZer";
}
// Actualiza contador después de cambiar estado
updateReadyCount();
});
});
panel.querySelectorAll('.new-name-input').forEach(input => {
input.addEventListener("input", function() {
const row = this.closest("tr");
const checkbox = row.querySelector(".normalize-checkbox");
const normalizeButton = row.querySelector(".normalize-btn");
// const originalNormalized = this.dataset.originalNormalized;
const originalNormalized = this.dataset.original;
if (checkbox && normalizeButton)
{
if (this.value.trim() !== originalNormalized.trim())
{
checkbox.checked = true;
normalizeButton.textContent = "Listo";
}
else
{
checkbox.checked = false;
normalizeButton.textContent = "NrmliZer";
}
}
// Actualizar contador después de cambiar estado por input
updateReadyCount();
});
});
//*****
// Nuevo: Botones especiales dinámicos
panel.querySelectorAll('[id^="special-btn-"]').forEach(btn => {
btn.addEventListener("click", (e) => {
const placeId = e.target.getAttribute("data-place-id");
console.log("Click en botón especial, placeId:", placeId);
//*****
// Eventos dinámicos por cada lugar
placesToNormalize.forEach((place, index) => {
const normalizedInput =
document.getElementById(`normalized-name-${index}`);
const normalizeButton =
document.getElementById(`normalize-btn-${index}`);
if (normalizeButton)
{
normalizeButton.addEventListener("click", () => {
if (checkbox)
checkbox.checked = true;
normalizeButton.textContent = "Listo";
});
}
else
{
console.warn(`No se encontró normalize-btn-${index}`);
}
// const fixButton =
// document.getElementById(`fix-btn-${index}`);
const specialButton =
document.getElementById(`special-btn-${index}`);
if (specialButton)
{
specialButton.addEventListener("click", () => {
if (place.originalName)
{
addSpecialWord(place.originalName);
if (checkbox)
checkbox.checked = false;
if (normalizeButton)
normalizeButton.textContent = "NrmliZer";
}
});
}
else
{
console.warn(`No se encontró special-btn-${index}`);
}
const checkbox = panel.querySelector(
`.normalize-checkbox[data-index="${index}"]`);
// Cambio manual del texto
if (normalizedInput && checkbox && normalizeButton)
{
normalizedInput.addEventListener("input", () => {
if (normalizedInput.value.trim() !==
place.normalizedName.trim())
{
checkbox.checked = true;
normalizeButton.textContent = "Listo";
}
else
{
checkbox.checked = false;
normalizeButton.textContent = "NrmliZer";
}
});
}
// Botón agregar palabra especial
if (specialButton)
{
specialButton.addEventListener("click", () => {
if (place.originalName)
{
addSpecialWord(place.originalName);
if (checkbox)
checkbox.checked = false;
if (normalizeButton)
normalizeButton.textContent = "NrmliZer";
}
});
}
else
{
console.error(`No se encontró special-btn-${index}`);
}
});
//*****
});
});
// Botón cancelar
// const cancelBtn = document.getElementById("cancel-btn");
const cancelBtn = panel.querySelector("#cancel-btn");
if (cancelBtn)
{
cancelBtn.addEventListener("click", () => panel.remove());
}
else
{
console.error("El botón 'cancel-btn' no se encontró en el DOM.");
}
// Botón aplicar todos
const applyAllBtn = panel.querySelector("#apply-all-btn");
if (applyAllBtn)
{
applyAllBtn.addEventListener("click", () => { // 1. Filtrar los
// lugares marcados
// para aplicar
const selectedChanges =
placesToNormalize
.filter((_, index) => {
const checkbox = panel.querySelector(
// Asegúrate de seleccionar solo los checkboxes
// principales o todos si quieres aplicar cambios de
// advertencias también
`.normalize-checkbox[data-index="${
index}"][data-type="full"]`);
return checkbox && checkbox.checked;
})
.map(
placeData =>
({...placeData })); // Crear copias para no mutar el
// array original directamente aquí
// 2. Actualiza 'newName' con el valor actual del input ANTES
// de aplicar
selectedChanges.forEach((change) => {
const index = placesToNormalize.findIndex(
p =>
p.id === change.id); // Encontrar índice original por ID
const inputElement =
panel.querySelector(`.new-name-input[data-index="${
index}"][data-type="full"]`);
if (inputElement)
{
change.newName =
inputElement.value; // Sobrescribir newName con el
// valor del input
}
});
if (selectedChanges.length === 0)
{
showModal({
title : "Advertencia",
message :
"No se seleccionaron lugares para aplicar cambios.",
confirmText : "Aceptar",
type : "warning"
});
return;
}
// 3. Llamar a applyNormalization con los datos actualizados
applyNormalization(selectedChanges);
panel.remove();
});
}
else
{
console.error("El botón 'apply-all-btn' no se encontró en el DOM.");
}
// Evento para marcar el checkbox de "Aplicar" al modificar un
// texto, y lógica de exclusión para "Eliminar"
panel.querySelectorAll(".new-name-input").forEach((input) => {
input.addEventListener("input", function() {
const row = this.closest("tr");
const applyCheckbox = row?.querySelector(".normalize-checkbox");
const deleteCheckbox = row?.querySelector(".delete-checkbox");
const original = this.dataset.original || "";
const current = this.value.trim();
if (applyCheckbox && deleteCheckbox)
{
if (current !== original)
{
applyCheckbox.checked = true;
deleteCheckbox.checked = false;
}
else
{
applyCheckbox.checked = false;
}
// Actualizar contador después de cambiar estado por input
updateReadyCount();
}
});
});
// Evento para marcar "Aplicar" si se selecciona "Eliminar" (sólo
// una vez)
panel.querySelectorAll(".delete-checkbox").forEach((checkbox) => {
checkbox.addEventListener("change", function() {
const row = this.closest("tr");
const applyCheckbox = row?.querySelector(".normalize-checkbox");
if (this.checked && applyCheckbox)
{
applyCheckbox.checked = true;
// Actualizar contador si se marca eliminar
updateReadyCount();
}
});
});
// Evento para normalizar el nombre al hacer clic en "NrmliZer"
panel.querySelectorAll(".normalize-btn").forEach((btn) => {
btn.addEventListener("click", async function() {
const row = this.closest("tr");
const input =
row.querySelector(".new-name-input[data-type='full']");
const applyCheckbox =
row.querySelector("input.normalize-checkbox");
const deleteCheckbox =
row.querySelector("input.delete-checkbox");
if (!input)
return;
// Animación
let dots = 0;
const originalText = "NrmliZer";
const interval = setInterval(() => {
dots = (dots + 1) % 4;
this.textContent = originalText + ".".repeat(dots);
}, 500);
try
{
input.value = await normalizePlaceName(input.value, true);
if (applyCheckbox)
applyCheckbox.checked = true;
if (deleteCheckbox)
deleteCheckbox.checked = false;
clearInterval(interval);
this.textContent = "✓ Ready";
this.style.backgroundColor = "#95a5a6";
this.disabled = true;
// Actualizar contador después de normalizar
updateReadyCount();
}
catch (error)
{
console.error("Error al normalizar:", error);
clearInterval(interval);
this.textContent = originalText;
}
});
});
// Evento para los botones "Usar sugerencia" dentro de <details>
panel.querySelectorAll(".apply-suggestion-btn").forEach((btn) => {
btn.addEventListener("click", function() {
const index = parseInt(this.dataset.index, 10);
const problemWord = this.dataset.problemWord;
const suggestion = this.dataset.suggestion;
if (isNaN(index) || !problemWord || !suggestion)
{
console.error("Datos inválidos en botón de sugerencia:",
this.dataset);
return;
}
const placeData = placesToNormalize[index];
// const warning = placeData.spellingWarnings[warningIndex];
const mainInput = panel.querySelector(
`.new-name-input[data-index="${index}"][data-type="full"]`);
const mainCheckbox =
panel.querySelector(`.normalize-checkbox[data-index="${
index}"][data-type="full"]`);
// if (!placeData || !warning || !mainInput || !mainCheckbox)
if (!placeData || !mainInput || !mainCheckbox)
return;
// Verificar si la palabra original tiene tildes incorrectas
const currentValue = mainInput.value;
const regex =
new RegExp('\\b' + escapeRegExp(problemWord) + '\\b',
'i'); // Busca la palabra exacta (case-insensitive)
const newValue = currentValue.replace(regex, suggestion);
mainInput.value = newValue;
placeData.newName = newValue;
mainCheckbox.checked = true;
// Actualizar contador después de aplicar sugerencia
updateReadyCount();
this.disabled = true;
this.textContent = "Corregido";
});
});
panel.querySelectorAll(".add-special-btn").forEach((btn) => {
btn.addEventListener("click", function() {
const name = this.dataset.word;
openAddSpecialWordPopup(
name); // Llamar al modal para seleccionar palabras
});
});
panel.querySelectorAll(".add-exclude-btn").forEach((btn) => {
btn.addEventListener("click", function() {
const word = this.dataset.word;
if (word)
{
openAddSpecialWordPopup(
word, "excludeWords"); // Llama al popup para agregar
// a palabras excluidas
}
});
});
// Evento para el botón "Ver Place" (icono del ojo)
panel.querySelectorAll(".view-place-btn").forEach((btn) => {
btn.addEventListener("click", function() {
const index = parseInt(this.dataset.index, 10);
if (isNaN(index) || index < 0 ||
index >= placesToNormalize.length)
{
console.error("Índice de lugar inválido para 'Ver Place':",
this.dataset.index);
return;
}
const placeObject = placesToNormalize[index].place;
if (placeObject && W && W.selectionManager)
{
W.selectionManager.setSelectedModels([ placeObject ]);
}
else
{
console.error("No se pudo seleccionar el lugar:",
placeObject);
}
});
});
}
//********************************************************************************************************************************
// Nombre: checkOnlyTildes (4)
// Fecha modificación: 2025-06-21
// Autor: mincho77
// Entradas:
// - original (string): Palabra original a comparar.
// - sugerida (string): Palabra sugerida a comparar.
// Salidas:
// - boolean:
// - true si las palabras son iguales excepto por tildes.
// - false si difieren en otros caracteres o si alguna es
// undefined/null.
// Descripción:
// Compara dos palabras ignorando tildes/diacríticos para determinar si la
// única diferencia entre ellas es la acentuación. Utiliza normalización
// Unicode para una comparación precisa. Optimizada para reducir operaciones
// innecesarias.
//********************************************************************************************************************************
function checkOnlyTildes(original, sugerida)
{
if (typeof original !== "string" || typeof sugerida !== "string")
{
return false;
}
if (original === sugerida)
{
return false;
}
const normalize = (str) =>
str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
return normalize(original) === normalize(sugerida);
}
//********************************************************************************************************************************
// Nombre: openDeletePopup
// Fecha modificación: 2025-04-14
// Autor: mincho77
// Entradas:
// - index (number): Índice de la palabra a eliminar.
// Salidas: Ninguna. Muestra un modal de confirmación.
// Descripción:
// Muestra un modal de confirmación para eliminar una palabra de la lista de
// exclusiones. Si el usuario confirma, elimina la palabra de la lista y
// actualiza el almacenamiento local.
//********************************************************************************************************************************
function openDeletePopup(index)
{
const wordToDelete = excludeWords[index];
if (!wordToDelete)
{
console.error(`No se encontró la palabra en el índice ${index}`);
return;
}
showModal({
title : "Eliminar palabra",
message :
`¿Estás seguro de que deseas eliminar la palabra <strong>${
wordToDelete}</strong>?`,
confirmText : "Eliminar",
cancelText : "Cancelar",
type : "warning",
onConfirm : () => {
// Eliminar la palabra de la lista
excludeWords.splice(index, 1);
// Actualizar localStorage
localStorage.setItem("excludeWords",
JSON.stringify(excludeWords));
// Actualizar la interfaz
renderExcludedWordsPanel();
showModal({
title : "Éxito",
message : "La palabra fue eliminada correctamente.",
confirmText : "Aceptar",
type : "success",
autoClose : 2000,
});
},
});
}
//********************************************************************************************************************************
// Nombre: openDeletePopupForDictionary
// Fecha modificación: 2025-07-28
// Autor: mincho77
// Entradas:
// - index (number): Índice de la palabra a eliminar en la lista
// `dictionaryWords`. Salidas: Ninguna. Muestra un modal de confirmación
// para eliminar del diccionario. Descripción: Muestra un modal para
// confirmar la eliminación de una palabra del diccionario activo. Si el
// usuario confirma, elimina la palabra de `spellDictionaries`, actualiza
// `localStorage`, `dictionaryWords` y renderiza el panel.
//********************************************************************************************************************************
function openDeletePopupForDictionary(index)
{
const wordToDelete =
dictionaryWords[index]; // Obtener la palabra de la lista plana
if (!wordToDelete)
{
console.error(
`[Diccionario] No se encontró la palabra en el índice ${index}`);
return;
}
showModal({
title : "Eliminar palabra del Diccionario",
message :
`¿Estás seguro de que deseas eliminar la palabra <strong>${
escapeHtml(wordToDelete)}</strong> del diccionario?`,
confirmText : "Eliminar",
cancelText : "Cancelar",
type : "warning",
onConfirm : () => {
// 1. Encontrar y eliminar la palabra de la estructura principal
// spellDictionaries
const firstLetter = wordToDelete.charAt(0).toLowerCase();
const currentDictLang = spellDictionaries[activeDictionaryLang];
if (currentDictLang && currentDictLang[firstLetter])
{
const letterArray = currentDictLang[firstLetter];
const wordIndexInLetterArray =
letterArray.indexOf(wordToDelete);
if (wordIndexInLetterArray > -1)
{
letterArray.splice(wordIndexInLetterArray,
1); // Eliminar del array de la letra
// Opcional: Si el array de la letra queda vacío,
// eliminar la letra
if (letterArray.length === 0)
{
delete currentDictLang[firstLetter];
}
// 2. Actualizar localStorage
localStorage.setItem(
`spellDictionaries_${activeDictionaryLang}`,
JSON.stringify(currentDictLang));
// 3. Actualizar la lista plana global y renderizar
dictionaryWords =
Object.values(currentDictLang).flat().sort();
renderDictionaryWordsPanel();
showModal({
title : "Éxito",
message :
"La palabra fue eliminada correctamente del diccionario.",
confirmText : "Aceptar",
type : "success",
autoClose : 2000,
});
}
else
{
console.error(`[Diccionario] No se encontró "${
wordToDelete}" en el array de la letra '${
firstLetter}'.`);
}
}
else
{
console.error(`[Diccionario] No se encontró la letra '${
firstLetter}' o el diccionario para el idioma ${
activeDictionaryLang}.`);
}
},
});
}
//********************************************************************************************************************************
// Nombre: evaluarOrtografiaNombre
// Fecha modificación: 2025-04-10 20:45 GMT-5
// Autor: mincho77
// Entradas:
// - name (string): Nombre a evaluar.
// - opciones (object): Opciones de configuración.
// - timeout (number): Tiempo máximo de espera para la API (ms).
// - usarCache (boolean): Si se debe usar caché para resultados
// - modoEstricto (boolean): Si se debe aplicar modo estricto.
// Salidas:
// - Promise: Objeto que contiene el resultado de la evaluación.
// Descripción: Evalúa la ortografía de un nombre utilizando reglas locales
// y la API de LanguageTool. Devuelve un objeto con advertencias de
// ortografía y metadatos sobre la evaluación. Incluye un sistema de caché
// para evitar llamadas duplicadas durante la sesión. Prerrequisitos:
// Funciones auxiliares como tieneTildesIncorrectas y corregirTildeLocal.
//********************************************************************************************************************************
function evaluarOrtografiaNombre(name, opciones = {})
{
const config = {
timeout : opciones.timeout || 5000,
usarCache : opciones.usarCache !== false,
modoEstricto : opciones.modoEstricto || false
};
// Cache simple (evita llamadas duplicadas durante la sesión)
const cache = evaluarOrtografiaNombre.cache ||
(evaluarOrtografiaNombre.cache = new Map());
const cacheKey = `${config.modoEstricto}-${name}`;
if (config.usarCache && cache.has(cacheKey))
{
return Promise.resolve(cache.get(cacheKey));
}
return new Promise((resolve) => { // 1. Validación de entrada
if (typeof name !== "string" || name.trim().length === 0)
{
const resultado = {
hasSpellingWarning : false,
spellingWarnings : [],
metadata : { apiStatus : "invalid_input" }
};
cache.set(cacheKey, resultado);
return resolve(resultado);
}
const inicio = Date.now();
let timeoutExcedido = false;
// 2. Timeout de seguridad
const timeoutId = setTimeout(() => {
timeoutExcedido = true;
const resultado = {
hasSpellingWarning : false,
spellingWarnings : [],
metadata : {
apiStatus : "timeout",
tiempoRespuesta : Date.now() - inicio
}
};
cache.set(cacheKey, resultado);
resolve(resultado);
}, config.timeout);
// 3. Primero verificar reglas locales (sincrónicas)
const problemasLocales = [];
const palabras = name.split(/\s+/);
palabras.forEach((palabra) => {
if (tieneTildesIncorrectas(palabra))
{
problemasLocales.push({
original : palabra,
sugerida : corregirTildeLocal(palabra),
tipo : "Tilde incorrecta",
origen : "Reglas locales"
});
}
});
// 4. Si hay problemas locales y no es modo estricto, devolver
// inmediato
if (problemasLocales.length > 0 && !config.modoEstricto)
{
clearTimeout(timeoutId);
const resultado = {
hasSpellingWarning : true,
spellingWarnings : problemasLocales,
metadata : { apiStatus : "local_rules_applied" }
};
cache.set(cacheKey, resultado);
return resolve(resultado);
}
// 5. Consultar API LanguageTool
GM_xmlhttpRequest({
method : "POST",
url : "https://api.languagetool.org/v2/check",
headers : {
"Content-Type" : "application/x-www-form-urlencoded",
Accept : "application/json"
},
data : `language=es&text=${encodeURIComponent(name)}`,
onload : (response) => {
if (timeoutExcedido)
return;
clearTimeout(timeoutId);
const tiempoRespuesta = Date.now() - inicio;
let resultado;
try
{
if (response.status === 200)
{
const data = JSON.parse(response.responseText);
const problemasAPI = data.matches.map(
(match) => ({
original : match.context.text.substring(
match.context.offset,
match.context.offset +
match.context.length),
sugerida : match.replacements[0]?.value ||
match.context.text,
tipo : "Ortografía", // Cambiar a "Ortografía"
origen : "API",
regla : match.rule.id,
contexto : match.context.text
}));
// Combinar resultados locales y de API
const todosProblemas =
[...problemasLocales, ...problemasAPI ];
resultado = {
hasSpellingWarning : todosProblemas.length > 0,
spellingWarnings : todosProblemas,
metadata : {
apiStatus : "success",
tiempoRespuesta,
totalErrores : todosProblemas.length
}
};
}
else
{
resultado = {
hasSpellingWarning :
problemasLocales.length > 0,
spellingWarnings : problemasLocales,
metadata : {
apiStatus : `api_error_${response.status}`,
tiempoRespuesta
}
};
}
}
catch (error)
{
resultado = {
hasSpellingWarning : problemasLocales.length > 0,
spellingWarnings : problemasLocales,
metadata :
{ apiStatus : "parse_error", tiempoRespuesta }
};
}
cache.set(cacheKey, resultado);
resolve(resultado);
},
onerror : () => {
if (timeoutExcedido)
return;
clearTimeout(timeoutId);
const resultado = {
hasSpellingWarning : problemasLocales.length > 0,
spellingWarnings : problemasLocales,
metadata : {
apiStatus : "network_error",
tiempoRespuesta : Date.now() - inicio
}
};
cache.set(cacheKey, resultado);
resolve(resultado);
}
});
});
}
//********************************************************************************************************************************
// Nombre: corregirTildeLocal
// Fecha modificación: 2025-04-25 05:45 GMT-5
// Autor: mincho77
// Entradas:
// - palabra (string): Palabra a corregir
// Salidas: (string): Palabra corregida o la original si no hay corrección.
// Descripción: Esta función corrige las tildes de palabras específicas en
// español. Se basa en un objeto de correcciones predefinido. Si la palabra
// no está en el objeto, se devuelve la palabra original.
//********************************************************************************************************************************
function corregirTildeLocal(palabra)
{
const correcciones = {
aun : "aún", // Adverbio de tiempo
tu : "tú", // Pronombre personal
mi : "mí", // Pronombre personal
el : "él", // Pronombre personal
si : "sí", // Afirmación o pronombre reflexivo
de : "dé", // Verbo dar
se : "sé", // Verbo saber o ser
mas : "más", // Adverbio de cantidad
te : "té", // Sustantivo (bebida)
que : "qué", // Interrogativo o exclamativo
quien : "quién", // Interrogativo o exclamativo
como : "cómo", // Interrogativo o exclamativo
cuando : "cuándo", // Interrogativo o exclamativo
donde : "dónde", // Interrogativo o exclamativo
cual : "cuál", // Interrogativo o exclamativo
cuanto : "cuánto", // Interrogativo o exclamativo
porque : "porqué", // Sustantivo (la razón)
porqué : "por qué", // Interrogativo o exclamativo
};
return correcciones[palabra.toLowerCase()] || palabra;
}
//********************************************************************************************************************************
// Nombre: addTildeToVowel
// Fecha modificación: 2025-07-26
// Autor: mincho77
// Entradas: vocal (string) - Una vocal sin tilde (a, e, i, o, u)
// Salidas: string - La vocal con tilde (á, é, í, ó, ú)
// Descripción: Añade la tilde a una vocal.
//********************************************************************************************************************************
function addTildeToVowel(vowel)
{
const tildes = {
'a' : 'á',
'e' : 'é',
'i' : 'í',
'o' : 'ó',
'u' : 'ú',
'A' : 'Á',
'E' : 'É',
'I' : 'Í',
'O' : 'Ó',
'U' : 'Ú'
};
return tildes[vowel] || vowel;
}
//********************************************************************************************************************************
// Nombre: generarSugerenciaTilde
// Fecha modificación: 2025-07-26
// Autor: mincho77
// Entradas: palabra (string) - La palabra con posible error de tilde.
// Salidas: string - La palabra con la tilde corregida, o la original si no
// se pudo corregir. Descripción: Intenta corregir la tilde de una palabra
// basándose en las reglas detectadas por detectarTilde.
// Prioriza añadir tildes faltantes.
//********************************************************************************************************************************
function generarSugerenciaTilde(palabra)
{
const tildeData = detectarTilde(palabra);
// No retornar si es válida, intentar corregir de todas formas por si
// falta tilde
const silabas = obtenerSilabasAproximadas(palabra);
const totalSilabas = silabas.length;
const terminaEnVocalNS = [ 'a', 'e', 'i', 'o', 'u', 'n', 's' ].includes(
palabra.slice(-1)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase());
// Intentar añadir tilde si falta
if (!tildeData.tieneTilde)
{
let silabaIndexTarget = -1;
if (tildeData.tipo === 'aguda' && terminaEnVocalNS &&
totalSilabas > 0)
silabaIndexTarget = totalSilabas - 1;
else if (tildeData.tipo === 'grave' && !terminaEnVocalNS &&
totalSilabas > 1)
silabaIndexTarget = totalSilabas - 2;
else if (tildeData.tipo === 'esdrújula' && totalSilabas > 2)
silabaIndexTarget = totalSilabas - 3;
else if (tildeData.tipo === 'sobresdrújula' && totalSilabas > 3)
silabaIndexTarget =
totalSilabas - 4; // Asumiendo la 4ta desde el final
if (silabaIndexTarget >= 0 && silabaIndexTarget < silabas.length)
{
const silabaTarget = silabas[silabaIndexTarget];
let palabraCorregidaArray = palabra.split('');
let offset =
silabas.slice(0, silabaIndexTarget).join('').length;
for (let i = silabaTarget.length - 1; i >= 0; i--)
{ // Buscar última vocal en la sílaba
const charIndex = offset + i;
const char = palabraCorregidaArray[charIndex];
if ("aeiouAEIOU".includes(char))
{
palabraCorregidaArray[charIndex] =
addTildeToVowel(char);
return palabraCorregidaArray.join('');
}
}
}
}
return palabra; // Devolver original si no se pudo corregir
}
//********************************************************************************************************************************
// Nombre: scanPlaces
// Fecha modificación: 2025-04-10 18:30 GMT-5
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Descripción: Escanea los lugares en el mapa y normaliza sus nombres.
// Filtra los lugares que no tienen nombre y procesa aquellos que requieren
// normalización. Muestra un panel flotante con los lugares a normalizar y
// permite aplicar cambios, excluir palabras y agregar palabras especiales.
// Incluye un botón para cerrar el panel. Prerrequisitos: Funciones
// auxiliares como normalizePlaceName y evaluarOrtografiaNombre.
//********************************************************************************************************************************
function scanPlaces()
{
const maxPlacesToScan = // Renombrar para evitar confusión con la
// variable global
parseInt(document.getElementById("maxPlacesInput")?.value || 100, 10);
if (!W?.model?.venues?.objects)
{ // Verificar si el modelo de WME está disponible
console.error("Modelo WME no disponible");
return;
}
const allPlaces =
Object.values(W.model.venues.objects)
.filter((place) => {
// Filtrar lugares que no tienen nombre
if (!place?.attributes?.name)
{
return false;
}
return true;
})
.slice(0, maxPlacesToScan); // Usar la variable local
if (allPlaces.length === 0)
{ // Si no se encontraron lugares
toggleSpinner(false);
showNoPlacesFoundMessage(); // Mostrar el mensaje mejorado
return;
}
let processedCount = 0;
const useAPI = document.getElementById("useSpellingAPI")
?.checked; // Verificar si se usará la API
const placesToNormalize = [];
const processBatch = async (index) => {
if (index >= allPlaces.length)
{ // Si ya se procesaron todos los lugares
// Ocultar spinner
toggleSpinner(false);
if (placesToNormalize.length > 0)
{
openFloatingPanel(placesToNormalize);
}
else
{
showModal({
title : "Advertencia",
message :
"No se encontraron lugares que requieran normalización.",
confirmText : "Entendido",
type : "warning"
});
}
return;
}
const place = allPlaces[index];
const originalName = place.attributes.name ||
""; // Asegura que originalName sea string
const placeId = place.getID(); // Obtener ID
try
{
// *** MODIFICADO: Verificar si el lugar es editable y si la función existe ***
let isEditable = true; // Asumir editable por defecto si la función no existe
if (typeof place.isEditable === 'function') {
isEditable = place.isEditable();
}
if (!isEditable) {
console.log(`[scanPlaces] Saltando lugar ${placeId} (no editable por el usuario actual).`);
// Opcional: Podrías añadirlo a una lista separada o simplemente saltarlo
processedCount++; // Asegúrate de incrementar el contador
setTimeout(() => processBatch(index + 1), 10); // Procesar el siguiente rápido
return; // Saltar el resto del procesamiento para este lugar
}
console.log(`[scanPlaces] Processing index ${index}, placeId ${
placeId}, name: "${originalName}"`); // Log inicio
// 1. Llamar a la función centralizada de evaluación
const evalConfig = {
usarAPI : useAPI, // Usar el valor del checkbox
reglasLocales :
true, // Asumimos que siempre queremos reglas locales
timeout : 30000 // Timeout para la API
};
// Pasar placeId a evaluarOrtografiaCompleta para que pueda
// usarlo normalizePlaceName
const evalResult = await evaluarOrtografiaCompleta(
originalName, placeId, evalConfig);
// 2. Mapear los errores al formato esperado por el panel
// flotante Los errores ya deberían tener 'palabraOriginal' y
// 'palabraProblematica'
const spellingWarningsForPanel = evalResult.errores.map(
error => ({
palabraOriginal : error.palabraOriginal ||
error.palabraProblematica ||
'?', // Fallback
palabraProblematica : error.palabraProblematica ||
error.palabraOriginal ||
'?', // Fallback
sugerida : error.sugerencia,
tipo : error.tipo,
motivo : error.motivo || `Severidad: ${error.severidad}`,
origen :
error.origen || 'Desconocido' // Origen (Local o API)
}));
// 3. Determinar el nombre final (el 'normalizado' de la
// evaluación)
const finalCorrectedName = evalResult.normalizado;
processedCount++;
toggleSpinner(
true,
`${S('spinnerProcessingMessage')} (${processedCount}/${
// Usar S() aquí`Procesando lugares... (${processedCount}/${
allPlaces.length})`, // Usar allPlaces.length
Math.round((processedCount / allPlaces.length) * 100));
console.log(
`Lugar: ${originalName}, Errores locales detectados:`,
spellingWarningsForPanel);
// 4. Añadir al panel si hubo advertencias o si el nombre final
// difiere del original
console.log(
`[scanPlaces] Checking if place ${placeId} needs review`);
if (spellingWarningsForPanel.length > 0 ||
originalName !== finalCorrectedName)
{
placesToNormalize.push({
id : placeId, // Usar el ID obtenido al inicio
originalName,
newName : // Usar el nombre normalizado/corregido de la
// evaluación
finalCorrectedName, // Nombre corregido SOLO
// por reglas locales
// inicialmente
hasSpellingWarning :
spellingWarningsForPanel.length > 0,
spellingWarnings :
spellingWarningsForPanel, // Pasar las advertencias
// detalladas
place
});
console.log(`[scanPlaces] Place ${
placeId} added to normalization list.`);
}
else
{
console.log(
`[scanPlaces] Place ${placeId} does NOT need review.`);
}
setTimeout(() => processBatch(index + 1), 50);
// Asegura que el contador avance y el spinner se actualice
// incluso si hay error
}
catch (error)
{
console.error(`Error procesando lugar ${place.getID()} (${
originalName || 'N/A'}):`,
error);
// Asegura que el contador avance y el spinner se actualice
processedCount++;
toggleSpinner(
true,
`Procesando lugares... (${processedCount}/${
allPlaces.length})`, // Usar allPlaces.length
Math.round((processedCount / allPlaces.length) * 100));
setTimeout(() => processBatch(index + 1), 50);
}
};
processBatch(0);
}
//********************************************************************************************************************************
// Nombre: renderDictionaryWordsPanel
// Fecha modificación: 2025-04-14
// Autor: mincho77
// Entradas: Ninguna (usa la variable global dictionaryWords).
// Salidas: Ninguna.
// Descripción:
// Limpia y renderiza la lista de palabras del diccionario en el panel
// lateral. Ordena las palabras alfabéticamente y actualiza el localStorage.
//********************************************************************************************************************************
function renderDictionaryWordsPanel()
{
const container = document.getElementById("dictionary-words-list");
if (!container)
{
console.warn(
"[PlacesNameNormalizer] No se encontró el contenedor 'dictionary-words-list'.");
return;
}
container.innerHTML = "";
const dict = spellDictionaries[activeDictionaryLang] || {};
const words =
Object.values(dict).flat().sort((a, b) => a.localeCompare(b));
dictionaryWords = words; // Actualiza global para búsquedas
const ul = document.createElement("ul");
ul.style.listStyle = "none";
words.forEach((word) => {
const li = document.createElement("li");
li.style.display = "flex";
li.style.justifyContent = "space-between";
li.style.alignItems = "center";
li.style.padding = "5px 0";
const wordSpan = document.createElement("span");
wordSpan.textContent = word;
li.appendChild(wordSpan);
const btnContainer = document.createElement("div");
btnContainer.style.display = "flex";
btnContainer.style.gap = "10px";
const editBtn = document.createElement("button");
editBtn.textContent = "✏️";
editBtn.title = "Editar";
editBtn.style.cursor = "pointer";
editBtn.addEventListener("click", () => {
const index =
dictionaryWords.indexOf(wordSpan.textContent.trim());
if (index !== -1)
{
openEditPopup(index, "dictionaryWords");
}
});
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "🗑️";
deleteBtn.title = "Eliminar";
deleteBtn.style.cursor = "pointer";
deleteBtn.addEventListener("click", () => {
const index =
dictionaryWords.indexOf(wordSpan.textContent.trim());
if (index !== -1)
{
openDeletePopupForDictionary(index);
}
});
btnContainer.appendChild(editBtn);
btnContainer.appendChild(deleteBtn);
li.appendChild(btnContainer);
ul.appendChild(li);
});
container.appendChild(ul);
}
//**********************************************************************
// Nombre: waitForDOM
// Fecha modificación: 2025-04-15
// Hora: 13:01:25
// Autor: mincho77
// Entradas: selector (string) - Selector CSS del elemento a esperar
// callback (function) - Función a ejecutar cuando se encuentra el
// elemento
// interval (number) - Intervalo de tiempo entre intentos en ms
// maxAttempts (number) - Número máximo de intentos
// Salidas: Ninguna
// Descripción: Espera a que un elemento del DOM esté disponible y ejecuta
// la función de callback. Si no se encuentra el elemento después de un
// número máximo de intentos, se muestra un mensaje de advertencia en la
// consola.
//**********************************************************************
function waitForDOM(selector, callback, interval = 300, maxAttempts = 20)
{
let attempts = 0;
const checkExist = setInterval(() => {
const element = document.querySelector(selector);
attempts++;
if (element)
{
clearInterval(checkExist);
callback(element);
}
else if (attempts >= maxAttempts)
{
clearInterval(checkExist);
console.warn(
`[PlacesNameNormalizer] No se encontró el elemento ${
selector} después de ${maxAttempts} intentos.`);
}
}, interval);
}
//********************************************************************************************************************************
// Nombre: renderExcludedWordsPanel
// Fecha modificación: 2025-03-30
// Autor: mincho77
// Entradas: Ninguna (usa la variable global excludeWords).
// Salidas: Ninguna.
// Descripción: Limpia y renderiza la lista de palabras excluidas en el
// panel lateral. Ordena las palabras alfabéticamente y actualiza el
// localStorage.
//********************************************************************************************************************************
function renderExcludedWordsPanel()
{
const container = document.getElementById("normalizer-sidebar");
if (!container)
{
console.warn(`[${
SCRIPT_NAME}] No se encontró el contenedor "normalizer-sidebar".`);
return;
}
// Limpiar el contenedor para evitar acumulaciones
container.innerHTML = "";
// Crear un elemento <ul> para la lista
const list = document.createElement("ul");
list.style.listStyle = "none"; // Opcional: eliminar viñetas
// Iterar sobre cada palabra de la lista excluida
excludeWords.forEach((word, index) => {
const li = document.createElement("li");
li.style.display = "flex";
li.style.justifyContent = "space-between";
li.style.alignItems = "center";
li.style.padding = "5px 0";
// Crear un <span> que muestre la palabra
const wordSpan = document.createElement("span");
wordSpan.textContent = word;
li.appendChild(wordSpan);
// Crear un contenedor para los botones
const btnContainer = document.createElement("div");
btnContainer.style.display = "flex";
btnContainer.style.gap = "10px";
// Botón de editar
const editBtn = document.createElement("button");
editBtn.textContent = "✏️";
editBtn.title = "Editar";
editBtn.style.cursor = "pointer";
// Asigna el event listener de editar, pasando el índice y el tipo
// de lista
editBtn.addEventListener(
"click", () => { openEditPopup(index, "excludeWords"); });
btnContainer.appendChild(editBtn);
// Botón de borrar
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "🗑️";
deleteBtn.title = "Eliminar";
deleteBtn.style.cursor = "pointer";
deleteBtn.addEventListener("click", () => {
openDeletePopup(index); // Llama a la función para mostrar el
// modal de confirmación
});
btnContainer.appendChild(deleteBtn);
li.appendChild(btnContainer);
list.appendChild(li);
});
container.appendChild(list);
}
//********************************************************************************************************************************
// Nombre: setupDragAndDrop
// Fecha modificación: 2025-04-22
// Hora: 22:37
// Autor: mincho77
// Entradas: Ninguna (usa la variable global type).
// Salidas: Ninguna.
// Descripción: Soporta archivos .txt y .xml para diccionario ortográfico.
//********************************************************************************************************************************
function setupDragAndDrop({ dropZoneId, onFileProcessed, type })
{
const dropZone = document.getElementById(dropZoneId);
if (!dropZone)
{
console.warn(
`[setupDragAndDrop] No se encontró el elemento con ID '${
dropZoneId}'`);
return;
}
// 🔁 Evitar que el navegador abra el archivo en toda la ventana
["dragenter", "dragover", "drop"].forEach(eventName => {
window.addEventListener(eventName, e => {
e.preventDefault();
e.stopPropagation();
});
});
// 🟩 Efecto visual al arrastrar
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.style.borderColor = "#4CAF50";
dropZone.style.backgroundColor = "#eaffea";
});
// 🔙 Restablecer el estilo si sale del área
dropZone.addEventListener("dragleave", () => {
dropZone.style.borderColor = "#ccc";
dropZone.style.backgroundColor = "";
});
// 📥 Manejar el archivo soltado
dropZone.addEventListener("drop", (event) => {
event.preventDefault();
event.stopPropagation();
dropZone.style.borderColor = "#ccc";
dropZone.style.backgroundColor = "";
const file = event.dataTransfer.files[0];
if (!file)
return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result.trim();
let words = [];
if (file.name.endsWith(".xml"))
{
try
{
const parser = new DOMParser();
const xml =
parser.parseFromString(content, "application/xml");
const wordNodes = xml.getElementsByTagName("word");
words = Array.from(wordNodes)
.map(n => n.textContent.trim())
.filter(Boolean);
}
catch (err)
{
console.error("❌ Error al parsear XML:", err);
showModal({
title : "Error",
message : "No se pudo leer el archivo XML.",
confirmText : "Aceptar",
type : "error",
});
return;
}
}
else
{
words = content.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
}
if (typeof onFileProcessed === "function")
{
onFileProcessed(words);
}
};
reader.readAsText(file);
});
}
//********************************************************************************************************************************
// Nombre: handleImportList
// Fecha modificación: 2025-03-30
// Autor: mincho77
// Entradas: Ninguna (depende del input file "importListInput" y checkbox
// "replaceExcludeListCheckbox"). Salidas: Ninguna. Descripción: Lee un
// archivo seleccionado por el usuario, procesa sus líneas para extraer
// palabras válidas, y actualiza la lista de palabras excluidas
// (localStorage y panel).
//********************************************************************************************************************************
function handleImportList()
{
const fileInput = document.getElementById("importListInput");
const replaceCheckbox =
document.getElementById("replaceExcludeListCheckbox");
if (!fileInput || !fileInput.files || fileInput.files.length === 0)
{
showModal({
title : "Información",
message : "No se seleccionó ningún archivo.",
confirmText : "Aceptar",
type : "info"
});
return;
}
const file = fileInput.files[0];
const reader = new FileReader();
reader.onload = function(event) {
const rawLines = event.target.result.split(/\r?\n/);
const lines =
rawLines
.map((line) => line.replace(/[^\p{L}\p{N}().\s-]/gu, "").trim())
.filter((line) => line.length > 0);
if (lines.length === 0)
{
showModal({
title : "Error",
message : "El archivo no contiene datos válidos.",
confirmText : "Aceptar",
type : "error"
});
return;
}
if (replaceCheckbox && replaceCheckbox.checked)
{
excludeWords = [];
}
else
{
excludeWords =
JSON.parse(localStorage.getItem("excludeWords")) || [];
}
excludeWords = [...new Set([...excludeWords, ...lines ]) ]
.filter((w) => w.trim().length > 0)
.sort((a, b) => a.localeCompare(b));
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
renderExcludedWordsPanel(); // Refresca la lista después de importar
showModal({
title : "Éxito",
message : `Se importaron ${
lines.length} palabras a la lista de palabras especiales.`,
confirmText : "Aceptar",
type : "success"
});
fileInput.value = ""; // Reinicia el input de archivo
};
reader.onerror = function() {
showModal({
title : "Error",
message :
"Hubo un problema al leer el archivo. Inténtalo nuevamente.",
confirmText : "Aceptar",
type : "error"
});
};
reader.readAsText(file);
}
//********************************************************************************************************************************
// Nombre: renderSpecialWordsPanel
// Fecha modificación: 2025-04-25 05:45 GMT-5
// Autor: mincho77
// Entradas: Ninguna (usa la variable global specialWords).
// Salidas: Ninguna.
// Descripción: Limpia y renderiza la lista de palabras especiales en el
// panel lateral. Ordena las palabras alfabéticamente y actualiza el
// localStorage. Se utiliza para mostrar las palabras especiales que se
// pueden agregar o editar.
//********************************************************************************************************************************
function renderSpecialWordsPanel()
{
const container = document.getElementById("special-words-list");
if (!container)
{
console.warn(
"[PlacesNameNormalizer] No se encontró el contenedor 'special-words-list'.");
return;
}
container.innerHTML = ""; // Limpia el contenedor
// Ordenar las palabras alfabéticamente
const sortedWords = specialWords.sort((a, b) => a.localeCompare(b));
// Crear una lista de palabras
const ul = document.createElement("ul");
ul.style.listStyle = "none";
sortedWords.forEach((word) => {
const li = document.createElement("li");
li.textContent = word;
ul.appendChild(li);
});
container.appendChild(ul);
}
//********************************************************************************************************************************
// Nombre: addWordsToList
// Fecha modificación: 2025-04-25 05:45 GMT-5
// Autor: mincho77
// Entradas:
// - words (string[]): Palabras a agregar.
// - listType (string): Tipo de lista ("specialWords" o "dictionaryWords").
// Salidas: Ninguna.
// Descripción: Agrega palabras a la lista correspondiente (especiales o del
// diccionario). Evita duplicados y actualiza el localStorage. También
// renderiza la lista correspondiente en el panel lateral y muestra un
// mensaje de éxito.
//********************************************************************************************************************************
function addWordsToList(words, listType)
{
// Determinar la lista correspondiente
let targetList;
if (listType === "specialWords")
{
targetList = specialWords;
}
else if (listType === "dictionaryWords")
{
targetList = dictionaryWords;
}
else
{
console.error(`Tipo de lista desconocido: ${listType}`);
return;
}
// Agregar palabras a la lista, evitando duplicados
const newWords = words.filter((word) => !targetList.includes(word));
targetList.push(...newWords);
// Guardar en localStorage
localStorage.setItem(listType, JSON.stringify(targetList));
// Renderizar la lista correspondiente
if (listType === "specialWords")
{
renderSpecialWordsPanel();
}
else if (listType === "dictionaryWords")
{
renderDictionaryWordsPanel();
}
// Mostrar mensaje de éxito
showModal({
title : "Éxito",
message : `Se agregaron ${newWords.length} palabra(s) a la lista ${
listType}.`,
confirmText : "Aceptar",
type : "success",
autoClose : 1500,
});
}
//********************************************************************************************************************************
// Nombre: openAddSpecialWordPopup
// Fecha modificación: 2025-04-25 04:56
// Autor: mincho77
// Entradas:
// - name (string): Nombre de la palabra o frase a agregar.
// - listType (string): Tipo de lista ("specialWords" o "excludeWords").
// Salidas: Ninguna.
// Descripción: Abre un modal para agregar palabras especiales o excluidas.
// Permite seleccionar palabras de una frase y agregarlas a la lista
// correspondiente. Actualiza el localStorage y renderiza la lista en el
// panel lateral. Muestra mensajes de éxito o advertencia según corresponda.
//********************************************************************************************************************************
function openAddSpecialWordPopup(name, listType = "specialWords")
{
const words = name.split(/\s+/); // Dividir el nombre en palabras
const modal = document.createElement("div");
modal.className = "custom-modal-overlay";
modal.innerHTML = `
<div class="custom-modal">
<div class="custom-modal-header">
<h3>Agregar Palabras ${
listType === "excludeWords" ? "Excluidas" : "Especiales"}</h3>
<button class="close-modal-btn" title="Cerrar">×</button>
</div>
<div class="custom-modal-body">
<p>Selecciona las palabras que deseas agregar como ${
listType === "excludeWords" ? "excluidas" : "especiales"}:</p>
<ul style="list-style: none; padding: 0;">
${
words
.filter((word) => {
// Si es para excluir, no mostrar si ya está excluida
// (comparando en minúsculas)
return !(listType === "excludeWords" &&
excludeWords.some((ex) => ex.toLowerCase() ===
word.toLowerCase()));
})
.map((word, index) => `
<li>
<label>
<input type="checkbox" class="special-word-checkbox" data-word="${
escapeHtml(word)}" id="word-${index}">
${escapeHtml(word)}
</label>
</li>
`)
.join("")}
</ul>
</div>
<div class="custom-modal-footer">
<button id="add-selected-words-btn" class="modal-btn confirm-btn">Agregar</button>
<button id="cancel-add-words-btn" class="modal-btn cancel-btn">Cancelar</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Manejar el cierre del modal
modal.querySelector(".close-modal-btn")
.addEventListener("click", () => modal.remove());
modal.querySelector("#cancel-add-words-btn")
.addEventListener("click", () => modal.remove());
// Manejar la acción de agregar palabras seleccionadas
modal.querySelector("#add-selected-words-btn")
.addEventListener("click", () => {
const selectedWords = Array
.from(modal.querySelectorAll(
".special-word-checkbox:checked"))
.map((checkbox) => checkbox.dataset.word);
if (selectedWords.length > 0)
{
selectedWords.forEach((word) => {
if (listType === "excludeWords")
{
if (!excludeWords.includes(word))
{
excludeWords.push(word);
}
}
else
{
addWordsToList([ word ], listType);
}
});
// Guardar en localStorage y actualizar la interfaz
if (listType === "excludeWords")
{
localStorage.setItem("excludeWords",
JSON.stringify(excludeWords));
renderExcludedWordsPanel();
}
else
{
localStorage.setItem("specialWords",
JSON.stringify(specialWords));
renderSpecialWordsPanel();
}
// Mostrar mensaje de éxito con tiempo reducido
showModal({
title : "Éxito",
message :
`Se agregaron ${selectedWords.length} palabra(s) como ${
listType === "excludeWords" ? "excluidas"
: "especiales"}.`,
type : "success",
autoClose : 1000, // Tiempo reducido a 1 segundos
});
}
else
{
// Mostrar mensaje de advertencia si no se seleccionó ninguna
// palabra
showModal({
title : "Advertencia",
message : "No seleccionaste ninguna palabra.",
type : "warning",
autoClose : 1000, // Tiempo reducido a 1 segundos
});
}
modal.remove();
});
}
//********************************************************************************************************************************
// Nombre: normalizePlaceName
// Fecha modificación: 2025-04-15
// Autor: mincho77
// Entradas: name (string): Nombre del lugar a normalizar.
// placeId (string): ID del lugar (opcional, para caché o logs
// futuros).
// Salidas: Promise<string>: Promesa que resuelve con el nombre normalizado
// básico. Descripción: Aplica normalización básica (mayúsculas, artículos,
// exclusiones, especiales).
// YA NO llama a la API de ortografía directamente para aplicar
// sugerencias.
//********************************************************************************************************************************
async function normalizePlaceName(name,
placeId = null) // useSpellingAPI = false)
{
if (!name)
{
return "";
}
// Usar la variable global 'normalizeArticles' que se actualiza con el
// listener. Recordar: normalizeArticles = true significa NO normalizar
// (checkbox marcado). Por lo tanto, la condición para normalizar (poner
// en minúscula) debe ser cuando normalizeArticles es false.
const shouldLowercaseArticle =
activeDictionaryLang === 'SP' && !normalizeArticles;
const articles =
[ "el", "la", "los", "las", "de", "del", "al", "y", "e", "en" ];
const words = name.trim().split(/\s+/);
const isRoman = (word) =>
/^(i{1,3}|iv|v|vi{0,3}|ix|x|xi{0,3}|xiv|xv|xvi{0,3}|xix|xx|xxi{0,3}|xxiv|xxv|xl)$/i
.test(word);
const normalizedWords =
await Promise.all(words.map(async (word, index) => {
// 🛑 Verificar si es palabra especial y retornarla intacta
const palabraEspecial = esPalabraEspecial(word);
if (palabraEspecial)
return palabraEspecial;
const lowerWord = word.normalize("NFD").toLowerCase();
// Reglas de reemplazo...
if (lowerWord === "él" || lowerWord === "el")
return word;
if (lowerWord === "sa" || lowerWord === "s.a")
return "S.A";
if (lowerWord === "sas" || lowerWord === "s.a.s")
return "S.A.S";
if (lowerWord === "[p]")
return "";
if (/^\d+$/.test(word))
return word;
if (isRoman(word))
return word.toUpperCase();
if (/^[A-Za-z]+'[A-Za-z]/.test(word))
{
return (word.charAt(0).toUpperCase() +
word.slice(1, word.indexOf("'") + 1) +
word.slice(word.indexOf("'") + 1).toLowerCase());
}
// Poner artículo en minúscula solo si está permitido (checkbox
// desmarcado en SP)
if (shouldLowercaseArticle && articles.includes(lowerWord) &&
index !== 0)
return lowerWord;
// --- Exclude Word Check (CRITICAL) ---
const matchedExcludeWord = wordLists.excludeWords.find(
(w) => w.normalize("NFD").toLowerCase() === lowerWord);
if (matchedExcludeWord)
{
console.log(
`"${word}" matched exclude word: "${matchedExcludeWord}"`);
return matchedExcludeWord; // Return the *original* form
}
// --- Main Normalization Logic ---
// Ya no usamos spellingSuggestion aquí, solo capitalización
// básica
let normalizedWord;
normalizedWord =
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
return normalizedWord;
}));
let newName =
normalizedWords.join(" ")
// Restaurar la cadena original de .replace()
.replace(/\s*\|\s*/g, " - ")
.replace(/([(["'])\s*([\p{L}])/gu,
(match, p1, p2) => p1 + p2.toUpperCase())
.replace(/\s*-\s*/g, " - ")
.replace(/\b(\d+)([A-Z])\b/g,
(match, num, letter) => num + letter.toUpperCase())
.replace(/\.$/, "")
.replace(/&(\s*)([A-Z])/g,
(match, space, letter) =>
"&" + space + letter.toUpperCase());
newName = newName.replace(/([A-Za-z])'([A-Za-z])/g,
(match, before, after) =>
`${before}'${after.toLowerCase()}`);
newName = newName.replace(
/-\s*([a-z])/g, (match, letter) => `- ${letter.toUpperCase()}`);
// *** NUEVO: Capitalizar después de punto y espacio ***
newName = newName.replace(
/\.\s+([a-z])/g, (match, letter) => `. ${letter.toUpperCase()}`);
return newName.replace(/\s{2,}/g, " ").trim();
}
//********************************************************************************************************************************
// Nombre: init
// Fecha modificación: 2025-04-09
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - El objeto global W debe estar disponible.
// - Deben estar definidas las funciones: initializeExcludeWords,
// createSidebarTab, waitForDOM, renderExcludedWordsPanel y
// setupDragAndDropImport. Descripción: Esta función espera a que el entorno
// de edición de Waze (WME) esté completamente cargado, verificando que
// existan los objetos necesarios para iniciar el script. Una vez
// disponible, inicializa la lista de palabras excluidas, crea el tab
// lateral personalizado, y espera a que el DOM del tab esté listo para
// renderizar el panel de palabras excluidas y activar la funcionalidad de
// arrastrar y soltar para importar palabras. Finalmente, expone globalmente
// las funciones applyNormalization y normalizePlaceName.
//********************************************************************************************************************************
function init()
{
if (!W || !W.userscripts || !W.model || !W.model.venues)
{
console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
setTimeout(init, 1000);
return;
}
console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);
initializeExcludeWords();
createSidebarTab();
waitForDOM("#normalizer-tab", () => {
console.log("[init] Sidebar listo");
renderExcludedWordsPanel();
waitForDOM("#dictionary-words-list", (element) => {
console.log("Contenedor del diccionario encontrado:", element);
// renderDictionaryWordsPanel(); // Movido arriba
attachDictionarySearch(); // ✅ Ejecuta el buscador sobre los
// <li>
});
setupDragAndDrop({
dropZoneId : "drop-zone",
onFileProcessed : (words) => {
excludeWords =
[...new Set([...excludeWords, ...words ]) ].sort();
localStorage.setItem("excludeWords",
JSON.stringify(excludeWords));
renderExcludedWordsPanel();
showModal({
title : "Éxito",
message : `Se importaron ${
words
.length} palabras a la lista de palabras especiales.`,
confirmText : "Aceptar",
type : "success",
});
},
type : "excludeWords",
});
setupDragAndDrop({
dropZoneId : "dictionary-drop-zone",
onFileProcessed : (words) => {
const nuevoDiccionario = {};
for (const palabra of words)
{
const letra = palabra.charAt(0).toLowerCase();
if (!nuevoDiccionario[letra])
{
nuevoDiccionario[letra] = [];
}
nuevoDiccionario[letra].push(palabra);
}
for (const letra in nuevoDiccionario)
{
if (!spellDictionaries[activeDictionaryLang][letra])
{
spellDictionaries[activeDictionaryLang][letra] = [];
}
const conjunto = new Set([
...spellDictionaries[activeDictionaryLang][letra],
...nuevoDiccionario[letra]
]);
spellDictionaries[activeDictionaryLang][letra] =
Array.from(conjunto).sort();
}
localStorage.setItem(
`spellDictionaries_${activeDictionaryLang}`,
JSON.stringify(spellDictionaries[activeDictionaryLang]));
dictionaryWords =
Object.values(spellDictionaries[activeDictionaryLang])
.flat()
.sort();
renderDictionaryWordsPanel();
showModal({
title : "Éxito",
message : `Se importaron ${
words.length} palabras al diccionario.`,
confirmText : "Aceptar",
type : "success",
});
},
type : "dictionaryWords"
});
// Configurar el selector de idioma DESPUÉS de que el HTML esté en
// el DOM
waitForElement("#dictionaryLanguageSelect", (selector) => {
selector.value =
activeDictionaryLang; // Establecer valor inicial
// configurarCambioIdiomaDiccionario(); // Ya no se llama aquí,
// se llama desde attachLanguageChangeListener
attachLanguageChangeListener(); // Adjuntar el listener inicial
});
attachDetailsToggleListeners(); // Adjuntar listeners de detalles
// iniciales
window.applyNormalization = applyNormalization;
window.normalizePlaceName = normalizePlaceName;
if (W && W.model && W.model.venues)
{
W.model.venues.on("zoomchanged", () => {
placesToNormalize = [];
const existingPanel =
document.getElementById("normalizer-floating-panel");
if (existingPanel)
{
existingPanel.remove();
}
console.log(
"Cambio de zoom detectado: Se ha reiniciado la búsqueda de lugares.");
});
}
});
}
// Inicia el script
init();
// --------------------------------------------------------------------
// Fin del script principal
// **************************************************************************
// Nombre: NormalizerUtils
// Fecha modificación: 2025-04-29
// Hora: 07:50
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Objeto global con funciones relacionadas a ortografía y tildes
// Prerrequisitos: Las funciones detectarTilde, separarSilabas y
// validarReglasAcentuacion deben estar ya definidas Descripción: Define un
// objeto global con utilidades de ortografía para facilitar su uso en
// consola o desde otros módulos
// **************************************************************************
window.NormalizerUtils = {
detectarTilde,
separarSilabas : obtenerSilabasAproximadas, // Usar la versión adaptada
validarReglasAcentuacion // Mantener si existe
};
unsafeWindow.NormalizerUtils = window.NormalizerUtils;
unsafeWindow.normalizePlaceName = normalizePlaceName;
unsafeWindow.applyNormalization = applyNormalization;
window.addEventListener("dragover",
e => e.preventDefault(),
{ passive : false }); // <-- Añadir passive: false
window.addEventListener("drop",
e => e.preventDefault(),
{ passive : false }); // <-- Añadir passive: false
function exposeNormalizerTools()
{
if (typeof window.NormalizerUtils === "undefined")
{
window.NormalizerUtils = {};
}
window.NormalizerUtils.detectarTilde = detectarTilde;
window.NormalizerUtils.separarSilabas =
obtenerSilabasAproximadas; // Exponer la versión adaptada
window.NormalizerUtils.validarReglasAcentuacion =
validarReglasAcentuacion;
console.log("✅ NormalizerUtils disponible en window");
}
// Siempre exponer NormalizerTools después de un pequeño retardo
setTimeout(exposeNormalizerTools,
2000);
})();