Ordenando alfabéticamente con caracteres propios del idioma en Apps Script

Una de las necesidades más frecuentes cuando se manipulan datos es la de tener que ordenarlos.

En este artículo abordaremos la ordenación de cadenas alfanuméricas (esto es, secuencias de texto) contenidas en estructuras de datos implementadas mediante vectores (arrays) bidimensionales en Google Apps Script (GAS).

Pero presentemos formalmente el:

TL;DR

Vamos a ordenar tablas de datos usando GAS atendiendo a criterios alfabéticos. Y veremos qué problemas surgen cuando el texto contiene elementos particulares de nuestro idioma 🇪🇸, tales como vocales con tilde (áéíóú) y otras cosillas, y cómo podemos solucionarlos.

TABLA DE CONTENIDOS


Los datos a ordenar están en una hoja de cálculo

Cuando me iniciaba en GAS di por sentado que todo comenzaba y acababa en una hoja de cálculo. Normal, las hojas de cálculo son el alfa, aunque no el omega, de la gestión de datos, por supuesto. Y probablemente si tú también estás andando ese camino, tus primeros scripts estarán alojados en una de ellas. Lo cierto es que nos gustan las hdc porque son ideales para almacenar y manipular información de manera inmediata y (relativamente) estructurada.

Partamos por ello de esta situación:

☝ Disponemos de una hoja de cálculo que contiene una tabla con una lista de personas caracterizadas por su nombre, primer apellido, segundo apellido, DNI e email. Nada inusual.

Sí, tenemos minúsculas  (fila 5), aunque se trate de un error, y un apellido que comienza con vocal con tilde (fila 7).

Podemos ordenar el intervalo de datos A2:E7 con pasmosa rapidez usando el método...

Range.sort(sortSpecObj)

...del servicio de Apps Script de hojas de cálculo. Este método admite como parámetro único un objeto sortSpecObj para indicar la columna o columnas por las que se va a ordenar y el criterio, ascendente o descendente, utilizado en cada una de ellas.

Comencemos nuestras aventuras de ordenación alfabética con esta primera función GAS, que lleva una copia de los datos en las celdas A2:E7 a A9:E14, y aplica sobre este último intervalo el método  Range.sort() en la línea 8, que puedes ver destacada aquí abajo:

/**
 * Ordena un intervalo de datos de una hoja de cálculo
 */
function ordenarHdc() { 
  const rangoOrigen = SpreadsheetApp.getActiveSheet().getRange('A2:E7');
  const rangoDestino = SpreadsheetApp.getActiveSheet().getRange('A9');
  rangoOrigen.copyTo(rangoDestino);
  rangoDestino.offset(0, 0, 6, 5).sort([{column: 2, ascending:true}, {column: 3, ascending:true}, {column: 1, ascending:true}]);
  }

El parámetro  sortSpecObj es un vector de pares {column, ascending} que sirve para explicitar cómo deseamos realizar la ordenación. En nuestro ejemplo lo haremos por la columna 2 (B, primer apellido) y en caso de empate por las columnas 3 (C, segundo apellido) y 1 (A, nombre) secuencialmente, utilizando en todos los casos un criterio ascendente (ascending: true).

El resultado es el esperado y contempla perfectamente las particularidades ortográficas del castellano ☑️:

Las vocales acentuadas ("Álvarez") e iniciales en minúscula ("cabrera")  no suponen un problema para Range.sort().

Pero ¿cuáles son esas peculiaridades por lo que hace a la ordenación alfabética 🔠 en castellano? Pues según el Servicio Lingüístico de la UOC:

¿Cómo se deben tratar las tildes, diéresis y minúsculas al ordenar alfabéticamente en castellano?

La cosa por tanto parece que funciona como debe. Pero no pienses que ya hemos terminado.

¿Y si los datos a ordenar *NO* están en una hoja de cálculo?

El procedimiento anterior ordena directamente un intervalo de datos de una hoja de cálculo. Pero qué ocurre si:

  1. Nuestro script no funciona sobre una hoja de cálculo.
  2. Los datos a ordenar proceden de otro lugar. Tal vez han sido obtenidos por medio de la invocación de una API (por ejemplo, lista de profesores o alumnos de una clase de Classroom) o introducidos manualmente por el usuario en un cuadro de diálogo.

En estas situaciones no parece la mejor idea trasladar los datos a ordenar a una hoja de cálculo, aplicar Range.sort() para, a continuación, leer el resultado y eliminar diligentemente todo rastro del intervalo de datos intermedio utilizado de manera provisional.

Pero antes de proseguir, vamos a prepararnos un par de funciones auxiliares Apps Script que nos permitan explorar esta circunstancia:


/**
 * Devuelve un vector con nombres, apellidos, dnis e emails de un conjunto de personas
 * [[Nombre, apellido1, apellido2, dni, email]]
 */
function obtenerUsuarios(referenciaRango) {
  // Aquí el proceso que obtiene la lista de usuarios, por ejemplo, pero podría ser cualquier otra cosa:
  const usuarios = SpreadsheetApp.getActiveSheet().getRange(referenciaRango).getValues();
  return usuarios;
}

function visualizarUsuarios(referenciaCelda, usuarios) {
  SpreadsheetApp.getActiveSheet().getRange(referenciaCelda).offset(0, 0, usuarios.length, usuarios[0].length).setValues(usuarios);
}

La función obtenerUsuarios(referenciaRango) va a simular la adquisición de los datos a ordenar. En este caso simplemente los tomaremos, por comodidad y de manera parametrizada mediante referenciaRango, del intervalo A2:E7 de partida de nuestro ejemplo... pero recuerda, podrían proceder de cualquier otro lugar.

Por su parte, visualizarUsuarios(referenciaCelda, usuarios) simplemente lleva al intervalo de datos caracterizado por su celda superior izquierda en referenciaCelda el contenido del vector bidimensional usuarios.

Estamos esencialmente usando una hoja de cálculo porque nos resulta conveniente para demostrar los distintos procesos de ordenación que desarrollaremos en un instante. Pero insisto, la idea es que no tiene por qué haber una hoja de cálculo de por medio.

Usando Array.prototype.sort() de Java Script (a lo bruto)

Nuestra primera herramienta de ordenación nos venía dada de serie gracias al servicio Spreadsheet de Apps Script.

Como probablemente sepas, GAS es algo así como un Java Script que se ejecuta en los servidores de Google... con alguna que otra peculiaridad. Así que veamos que facilidades nos ofrece Java Script para esto de ordenar cositas.

Y, afortunadamente, Java Script dispone también del método nativo...

Array.prototype.sort([compareFunction])

...para ordenar los elementos de un vector.

Vamos a ver qué pasa cuando lo usamos a lo bruto sobre el intervalo de datos a ordenar que, recordemos, se representa por medio de un array o vector bidimensional, esto es, un vector (filas) de vectores (columnas):

[
["María Isabel", "Reyes", "Vicente", "12924769B", mariaisabel.reyes@..."],
["Alba", "Reyes", "Castillo", "91646488K",  "alba.reyes@..."],
...
["Celia", "Álvarez", "Vicente", "16243949S",  "celia.alvarez@..."]
]

Para ello nos bastarán estas líneas...


/**
 * Ordena un *vector 2D* (matriz) usando Array.sort()
 * https://developer.mozilla.org/es/docs/Web/JavaScript/Referencia/Objetos_globales/Array/sort
 */
function ordenarSort1() {
  const matrizOrdenada = obtenerUsuarios('A2:E7').sort();
  visualizarUsuarios('A16', matrizOrdenada);  
}

...que producirán este resultado:

Resultado de Array.Prototype.sort()  sobre un vector 2D.

Como puedes apreciar, la ordenación no se ha realizado de acuerdo con el primer apellido de cada persona, sino que se ha utilizado la columna del nombre en su lugar. Mal empezamos ❌.

De hecho,  lo que realmente está ocurriendo es que se concatenan todos lo elementos (nombre + apellido 1 + ... + email) de cada vector fila y se utilizan esas meta cadenas de texto para realizar la ordenación. Fíjate, Diego Felip aparece más arriba que Diego Nieto en la tabla ordenada (como debe ser, por otra parte). 

Pero cuidadín, porque  los campos nombre, apellido 1 y apellido 2 de cada persona en la tabla pueden ser de distintas longitudes. Ordenar alfabéticamente, sin más, las secuencias de texto resultantes de su concatenación puede no ofrecer el mismo resultado que aplicar una ordenación alfabética de manera secuencial sobre cada elemento.

Además, este comportamiento cuando lo que se ordenan no son cadenas simples sino vectores - fila de cadenas de texto no aparece claramente especificado en la definición oficial del método. Una razón más para que te olvides de este método si lo que pretendes es ordenar los elementos - fila dispuestos en una matriz.

¿Y si lo aplicamos sobre un vector unidimensional, como parece ser aconsejable, concretamente sobre la columna apellido 1?


/**
 * Ordena un *vector* usando Array.sort()
 * https://developer.mozilla.org/es/docs/Web/JavaScript/Referencia/Objetos_globales/Array/sort
 */
function ordenarSort2() {
  const matrizOrdenada = obtenerUsuarios('B2:B7').flat().sort();
  visualizarUsuarios('B23', matrizOrdenada.map(elemento => [elemento]));  
}

⚠️ Cuidado:
Los métodos .getValues() y .setValues() utilizados para leer y escribir datos de la hoja de cálculo esperan vectores 2D para representar filas y columnas, aunque el intervalo contenga una fila o columna única. Para adaptarnos a esto, usamos ahora por una parte Array.prototype.flat(), que en la línea 6 aplana la estructura del vector 2D leído desde la hoja de cálculo y, por otra, recorremos los elementos del vector resultante de la ordenación usando Array.prototype.map(), en la línea 7, para encajarlos en una matricial bidimensional antes de actualizar el contenido de las celdas. Sí, lo sé esto no es lo más intuitivo del mundo mundial, especialmente si estás dando tus primeros pasos con GAS, pero es lo que hay 🤷‍♂️.

Esto es lo que obtenemos:

Resultado de Array.Prototype.sort()  sobre un vector 1D (campo único de ordenación).

Parece que la cosa no funciona bien ni así:

  • El apellido con letra minúscula inicial (cabrera), que hemos deslizado en nuestra tabla precisamente para poner esto en evidencia, no aparece en la posición correcta.
  • El apellido con vocal con tilde inicial (Álvarez) tampoco.

☝ Esto está pasando porque Array.sort() ordena atendiendo al valor Unicode de los caracteres de cada cadena de texto. Y en esta codificación las letras minúsculas y los caracteres acentuados (o con diéresis, circunflejos, etc.) tienen asignados numeritos superiores a las letras mayúsculas mondas y lirondas.

Y aunque en idiomas como el inglés lo anterior da un poco igual, en nuestras queridas lenguas romances (y en muchas otras) el resultado no es aceptable.

Un comentario más antes de continuar nuestro camino. En el caso de preferir una ordenación alfabética descendente nos bastaría con utilizar el método Array.prototype.reverse() sobre el vector resultante de la ordenación.

Así, en el caso de ordenar un intervalo con varias columnas (vector de vectores)...

const matrizOrdenada = obtenerUsuarios('A2:E7').sort().reverse();

...o así cuando nos limitamos a una sola columna (vector de elementos simples):

const matrizOrdenada = obtenerUsuarios('B2:B7').flat().sort().reverse();

Array.prototype.sort() y su [compareFunction] opcional

Seguramente te habrás percatado de que en al apartado anterior me he hecho el loco un poquito (bueno, totalmente) por lo que hace a ese parámetro opcional compareFunction que espera amorosamente nuestro  Array.prototype.sort().

Pero he preferido generar cierto caos y alarma para motivar la necesidad de un "algo más" que nos solventara la papeleta. Y ese algo más es precisamente esta función de comparación, que eleva hiperbólicamente la potencia de esta estrategia de ordenación que nos facilita Java Script.

Gracias a ella vamos a introducir (programar) un criterio de comparación absolutamente detallado y granular para los elementos que se están tratando de ordenar.

Esta función de comparación, compareFunction(valor1, valor2) debe devolver uno de estos tres resultados posibles:

  • 1 si valor1 > valor2.
  • 0 si valor1 = valor2.
  • -1 si valor1 < valor2.

Cada vez que Array.prototype.sort() compara dos elementos cualesquiera del vector a ordenar para ver dónde tiene que colocarlos, invocará a nuestra función de comparación personalizada, y moverá ficha (elemento del vector) según el resultado devuelto. Maravilloso.

Desmontando Array.prototype.sort().

Clarinete, ¿verdad? Claro que sí.

Pues vamos a ver cómo introducir esta nueva posibilidad para solventar el primero de los problemas con el que nos encontramos en el apartado anterior, esto es, la ordenación de vectores no exclusivamente unidimensionales.


 /**
 * Ordena un vector 2D (matriz) usando Array.sort() y una función de comparación sobre elementos de texto
 * https://developer.mozilla.org/es/docs/Web/JavaScript/Referencia/Objetos_globales/Array/sort
 */
function ordenarSort3() {
  const matrizOrdenada = obtenerUsuarios('A2:E7').sort((v1, v2) => {
    // Concatenar apellido1 + apellido2 + nombre (sin espacios) con separador ' ' para ordenar por nombre completo
    const elemento1 = v1[1].replace(/ /g, '') + ' ' + v1[2].replace(/ /g, '') + ' ' + v1[0].replace(/ /g, '');
    const elemento2 = v2[1].replace(/ /g, '') + ' ' + v2[2].replace(/ /g, '') + ' ' + v2[0].replace(/ /g, '');  
    // Si el1 > el2 devuelve 1
    // Si el1 = el2 devuelve 0
    // Si el1 < el2 devuelve -1
    if (elemento1 > elemento2) return 1;
    else if (elemento1 < elemento2) return -1;
    else return 0;
    // De comparar valores numéricos la función podría ser algo tan sencillo como: v1 - v2.
  });
  visualizarUsuarios('A30', matrizOrdenada);  
}

Lo que hacemos es usar una función lambda que concatena en el orden apropiado los campos que contienen apellido 1, apellido 2 y nombre (líneas 8, 9) sobre las constantes elemento 1 y elemento 2, para seguidamente devolver un valor (1, 0, -1) e indicarle así a sort() cuál es la relación de orden que les atribuimos a dichos elementos. ¡A que mola!

Bastaría con invertir la lógica anterior (líneas 13 - 15) para lograr una ordenación descendente. Sin problemas.

☝ Fíjate en que usamos una triquiñuela: se eliminan todos los espacios en blanco que pudiera haber en los campos que determinan el orden resultante y se concatenan utilizando un espacio como carácter separador. De este modo se garantiza que los elementos de la tabla sí queden correctamente ordenados.

Aquí el resultado, aplicando una ordenación ascendente:

Resultado de Array.Prototype.sort(compareFunction) .

Ahora sí se han tenido en cuenta correctamente las columnas que nos interesan... pero desgraciadamente el segundo problema del que hablábamos hace un momento sigue haciendo de las suyas y nuestros queridos cabrera y Álvarez quedan nuevamente, de modo erróneo, al final de la tabla.

¿Y ahora que?

El método localeCompare() entra en juego

Java Script dispone de numerosas funciones y características diseñas para acomodar la internacionalización, esto es, las peculiaridades de distintos idiomas y países por lo que hace al formato de números, fechas, monedas, etc.

Ya he hablado de toLocaleString() 🔢 y toLocaleDateString() 📅 en alguna ocasión, así que seguro que me estás viendo venir.

Por eso, saquemos ahora del banquillo a un nuevo jugador:

String.prototype.localeCompare(compareString)

☝ En su modo de uso más simple nos devuelve un valor positivo, negativo o cero, en función de la relación de orden alfabético existente entre las cadenas de texto involucradas, pero respetando escrupulosamente las convenciones del idioma local detectado o especificado.

De hecho, y al menos en mi experiencia, cuando se utiliza en un script GAS siempre devuelve 1, 0 o -1, por lo que podemos usarla directamente dentro de nuestra función de comparación personalizada en Array.prototype.sort().

En cualquier caso, y como digo a menudo, precaución amigo conductor con este tipo de detalles de implementación interna, que no sería raro que exhibiesen un comportamiento diferente en algún momento.

La cosa quedaría de este modo:


/**
 * Ordena un vector 2D (matriz) usando Array.sort() y una función de comparación sobre elementos de texto
 * respetando los particularidades ortograficas locales
 * https://developer.mozilla.org/es/docs/Web/JavaScript/Referencia/Objetos_globales/Array/sort
 */
function ordenarSort4() {
  const matrizOrdenada = obtenerUsuarios('A2:E7').sort((v1, v2) => {
    // Concatenar apellido1 + apellido2 + nombre (sin espacios) con separador ' ' para ordenar por nombre completo
    const elemento1 = v1[1].replace(/ /g, '') + ' ' + v1[2].replace(/ /g, '') + ' ' + v1[0].replace(/ /g, '');
    const elemento2 = v2[1].replace(/ /g, '') + ' ' + v2[2].replace(/ /g, '') + ' ' + v2[0].replace(/ /g, ''); 
    // ¡Usamos String.localeCompare()!
    return elemento1.localeCompare(elemento2);
  });
  visualizarUsuarios('A37', matrizOrdenada);  
}

Fíjate en la línea 12 del fragmento de código anterior. Ahí está la magia que compara, uno a uno, los elementos 1 y 2 produce finalmente esta muy correctamente ordenada tabla:

Resultado de Array.Prototype.sort(compareFunction)  con  .localeCompare().

Siguientes pasos, despedida y cierre

En este repositorio de GitHub encontrarás los trocitos de código utilizados en el artículo.

Y esta es la hoja de cálculo donde he desarrollado el ejemplo:

👉  Ordenar [ES] 👈

Gracias por acompañarme duante lo que, según las estadísticas de mi editor,  deben haber sido unos 13 minutejos.

No sé si será esa una cifra fiable o no, el caso es que aunque hemos recorrido un buen trecho... quedan flecos (siempre quedan flecos, en GAS y en la vida, afortunadamente) .

¿Qué pasaría si deseáramos realizar una ordenación por múltiples campos de texto, respetando las convenciones locales, y con criterios alfabéticos ascendentes y descendentes combinados?

¿Podemos generalizar nuestra función de ordenación alfabética de un intervalo de datos de modo que se comporte de un modo análogo al método Range.sort(sortSpecObj) que nos ofrece graciosamente el servicio de hojas de cálculo de Google Apps Script?

Por supuesto. Y como puedes suponer, la clave está en la implementación de la función personalizada de comparación, que ademas esconde alguna que otra triquiñuela adicional.

Peso esa es otra historia y será (probablemente) contada en otra ocasión.