RecogeCV, una webapp de recogida de currículums


¿Otra webapp? ¿Para qué?

Con el Reglamento General de Protección de Datos y la posterior Ley Orgánica 3/2018 de Protección de Datos Personales y garantía de los derechos digitales (RGPD y LOPDGDD respectivamente para los amigos) muchas cosas han cambiado, y en la práctica no siempre para bien de manera efectiva, si me permites, en el ámbito del tratamiento de los datos personales de los usuarios.

La ambigüedad ¿inevitable? presente en ambos textos legales y la falta (aún) de desarrollos reglamentarios verticales y guías de adaptación específicas tan inequívocas como a algunos nos gustaría no ponen las cosas demasiado fáciles para la empresa de a pie que debe y quiere cumplir con la ley. Si a esto unimos la diversidad de criterios entre las distintas instituciones nacionales encargadas de velar por su cumplimiento en los países de la UE e incluso alguna que otra sentencia judicial contradictoria, no es raro encontrarse en alguna que otra institución con adaptaciones concretas que proponen soluciones de máximos para tratar de curarse en salud, protegiéndose así del mejor modo ante posibles interpretaciones adversas de la normativa. Lo que viene a ser matar moscas a cañonazos. Maravilloso.


Y es precisamente este contexto el que fundamenta lo que viene a continuación. El artefacto digital que presento en esta publicación trata de encajar algo que ocurre con inevitable frecuencia en un centro de formación, pongamos que sea el "Centro de FP Chachi", en la normativa vigente en materia de protección de datos. Me refiero a la recepción a través del correo electrónico de las candidaturas laborales, acompañadas de sus inevitables curriculum vítae (CV, en adelante), de personas interesadas en entrar a formar parte de su plantilla. Y el caso es que los CV suelen contener (casi) todos los datos personales del mundo mundial, ¿verdad?

Ya tenemos la "mosca", pero ¿dónde está el "cañón"?

Veamos, el candidato efectúa una clara acción positiva (enviar un email con un archivo adjunto) mediante la cual manifiesta que está de acuerdo en comunicarnos su información personal y en que la revisemos cuidadosamente, una y otra vez, año tras año, hasta darnos finalmente cuenta de que se trata de la persona ideal para cubrir ese puesto vacante que hubiera podido surgir. Por tanto, el candidato consiente en la cesión de sus datos personales de manera:
  • Libre. Envía su CV porque quiere.
  • Inequívoca. Cosas como abrir la aplicación de correo, redactar uno nuevo, adjuntar el CV, escribir la dirección de email del CFP Chachi... en fin, estas cosas no se hacen sin querer salvo empanada mental de dimensiones considerables.
  • Específica. Es obvio que el candidato nos hace llegar sus datos personales, académicos, etc. para que los valoremos de cara a conseguir un empleo en nuestro centro, y resulta razonable suponer que únicamente para eso.
¿Dónde está el problema pues? El problema está en que el RGPD exige además que este consentimiento sea informado. Y esto implica que debe incluir, así de memoria y en una primera capa, información sobre:
  • La identidad del responsable del tratamiento.
  • La base jurídica y finalidad del tratamiento.
  • El periodo de conservación de los datos.
  • La existencia o no de cesiones a terceros (y no, no hay cesión de datos cuando estos los gestiona un tercero en calidad de encargado de tratamiento, a ver si esto les va quedando claro a algunos).
  • Cómo ejercer los derechos establecidos en los artículos 15 a 22 del RGPD, esto es, los ARCO de toda la vida (acceso, rectificación, cancelación, oposición)... y alguno más que se ha traído el RGPD bajo el brazo.

¿Tenemos la certeza de que nuestro candidato es conocedor de todo ello antes de enviarnos su email? A que no. Pues ya la hemos liado. Por tanto podemos leer con todo el cariño del mundo email y CV, pero a continuación no tendremos otra que tirar ambos a las profundidades de la papelera digital. Y vaciarla hasta que no quede rastro. Y si nos hacemos un lavado selectivo de cerebro, mejor (no vaya a ser). Porque de lo contrario, si conservamos el email o el archivo adjunto en los registros del centro, estaríamos realizando un tratamiento de datos (personales) para el que no habríamos recabado el consentimiento adecuado. Y eso puede suponer un problema grave. Muy grave. O al menos ese parece ser el consenso al que llegan los innumerables artículos de expertos que abordan este tema y que han surgido como esporas mutantes tras la lluvia radiactiva del RGPD. Sirva como muestra este botón. El artículo es del 2011, pero ha sido modificado posteriormente a la luz (radiactiva) del RGPD. Y por supuesto la problemática se extiende sin mayor problema a los CV entregados en mano.

¿Leer la información recogida en un CV no constituye entonces un tratamiento de datos en tanto no lo conservemos? Personalmente tengo mis dudas. El propio gabinete jurídico de la AEPD respondía a una consulta que planteaba si la reproducción de imágenes en tiempo real captadas por un sistema de videovigilancia, pero sin grabación de las mismas, constituía un tratamiento de datos en los términos recogidos por el RGPD. Y la respuesta es que sí. Creo que los paralelismos son evidentes y las incongruencias probablemente también.

Corolario: si quieres "hundir" a alguien, mándale tu CV (mejor si lo haces repetidamente) y después extorsiónale amenazando con una denuncia ante la AEPD. Igual sacas algo. Absurdo ¿verdad? Es lo que tiene retorcer la ley cuando esta es tan, tan, tan interpretable.

Pero volviendo a lo nuestro, ¿a que ya ves venir el "cañón"? El cañón es todo lo que sigue. Ponte cómodo.

Nuestras alternativas para solucionar el problema

Para evitar tener que andar contactando con cada uno de los candidatos que eventualmente nos hagan llegar su CV para suplicar su consentimiento, nuestro objetivo será crear un formulario web de recogida de datos que incluya toda la parafernalia legal necesaria para satisfacer las exigencias del RGPD, esto es:

Información del tratamiento + Consentimiento

Adicionalmente, podemos establecer que en el CFP Chachi solo se admitirán CV a través de nuestro formulario, volatilizando de manera inmediata cualquier peligrosísimo email portador de información confidencial y rechazando además taxativamente cualquier intento de entrega de CV en mano, incluyendo los de cuñados/as de, por supuesto.

Y ahora vayamos entrando en cuestiones de índole técnico. ¿Cómo podemos construir el dichoso formulario sin morir en el intento?

Idea 1. Un formulario de Google (bueno, bonito, barato)

¡Bien pensando! Esto está resuelto en un pispás gracias a la pregunta que permite subir archivos...

Un bonito formulario de Google con una pregunta que admite la subida de archivos.

... solo que hay un problema: la subida de archivos requiere que la persona que rellena el formulario inicie sesión utilizando una cuenta de Google, personal o de G Suite. Y esto no me parece en absoluto aceptable en este contexto. Si a ti sí, probablemente puedas evitarte el resto del artículo. En caso contrario, sigue conmigo.

Idea 2. Recurrir a una solución comercial

Aquí hay múltiples posibilidades. Por ejemplo, ¿conoces Knack? Es una herramienta que permite desarrollar fantásticas aplicaciones web sin escribir una sola línea de código. En mi centro la he usado para construir alguna que otra aplicación de uso interno y doy fe de que es una auténtica barbaridad. Con Knack es posible en, literalmente unos minutos, construir una miniaplicación de recogida de CV que almacene sus datos en una estructura de base de datos relacional con la que podemos interactuar a través de una sencilla interfaz de usuario. Y con el tiempo que nos sobra hasta nos damos el lujo de añadirle una página de estadísticas. Algo como esto:

Knack: hay un mundo mejor, pero es más caro.

El pequeño inconveniente de Knack es que es de pago. El plan más económico supone $390 al año, aunque hay un descuento del 25% para centros de formación sostenidos con fondos públicos. Si ya usas Knack en tu centro o piensas hacerlo, probablemente el modo más inmediato de construir tu formulario de recogida de CV sea este, lo que además podría plantar la semilla de una aplicación interna de gestión del talento del personal del centro y bolsa de trabajo para sus propios alumnos... y quién sabe qué más cosas.

Por otro lado, si prefieres solventar el problema que nos ocupa de un modo más económico, otra posibilidad interesante podría ser la de recurrir a la excelente plantilla para hojas de cálculo de Google denominada File Uploads Form, desarrollada por Amit Agarwal usando Google Apps Script. Es tremendamente completa, incluye un editor de formularios, envía notificaciones personalizadas, admite validación de campos mediante expresiones regulares... y mucho más. En fin, un desarrollo tremendamente currado, como es habitual en todo lo que hace Amit. Ahora bien, no nos va a salir tampoco gratis, tiene un coste de entre $29 y $199 (pago único) en estos momentos. Amit Agarwal también nos ofrece un script similar, pero mucho, mucho más simple, esta vez no obstante de manera gratuita (pequeño señuelo totalmente entendible para llevarnos a su producto comercial).

Idea 3. Con estas manitas y... GAS

Llegados a este punto, la proposición muy decente que te hago es la de utilizar las inevitables hojas de cálculo de Google, potenciadas por Google Apps Script, para resolver el problema concreto descrito hace ya demasiadas líneas: la recepción de CV previo registro del consiguiente consentimiento informado del candidato.

Y con ello nace, tachán, tachán...


¿Cómo funciona RecogeCV?

RecogeCV está basado en una hoja de cálculo con dos pestañas:
  • Control: Como su nombre sugiere, permite ajustar algunos parámetros relativos al funcionamiento del formulario de recogida de datos (candidaturas). Los trataremos en detalle inmediatamente.
  • CV: Se trata de una tabla en la que se reciben tanto los valores introducidos en los distintos campos del formulario como un enlace privado (y esto es lo importante) al propio documento subido por el usuario. Las candidaturas se presentan en orden cronológico descendente, es decir, en primera posición siempre veremos la más reciente.
RecogeCV, de un vistazo.

Pero antes de seguir tal vez quieras obtener  una copia de RecogeCV. Aquí tienes la plantilla lista para que comiences a jugar con ella.

https://docs.google.com/spreadsheets/d/1xnW8cC4Ym8sec23pHBxvOUbkm_Bir29UpR-TDSw8rhw/template/preview

Comencemos por la pestaña Control, que dispone de tres secciones claramente diferenciadas y delimitadas por otros tantos interruptores. Las celdas en las que RecogeCV espera que introduzcamos cositas tienen fondo morado, y cambian a azul cuando hemos establecido valores en ellas. El resto de celdas están protegidas para evitar ediciones accidentales (solo se mostrará una advertencia al intentar escribir en ellas).

En la primera, en cuya parte superior se encuentra el interruptor general que activa o desactiva el formulario y un mensaje que confirma su estado, podrás configurar algunos aspectos generales de su aspecto:


Veamos cada elemento:
  • Título: Se muestra tanto como título de la página web que despliega el formulario como de su propio texto de encabezado.
  • Tamaño: Tamaño del texto del título. Un número más pequeño indica un tamaño mayor.
  • Color tema: Puedes indicar un color que servirá para personalizar el de algunos elementos del formulario tales como iconos, botones y texto de los campos. Puedes dejarlo en blanco para mantener el valor por defecto. El color se debe expresar en la típica notación HTML hexadecimal (#RRVVAA), por ejemplo #FF0000 para un rojo puro. Puedes ayudarte para escoger el tuyo de este completo selector.
  • URL logo encabezado: Debe apuntar directamente a una imagen (formato usable en web como JPG, PNG o GIF), que se mostrará también en el encabezado del formulario. Justo debajo se mostrará una vista previa de la imagen para que compruebes que RecogeCV puede acceder a ella correctamente. ¿Alternativas a la hora de alojar la imagen y obtener el dichoso URL? No, simplemente subirla a Drive no funciona. Te voy a dar dos, además de la obvia (botón derecho sobre una imagen publicada en una página web  copiar dirección de imagen): Usa Dropbox (sustituye en el enlace para compartir el sufijo ?dl=0 por ?raw=1) o simplemente crea un dibujo en Drive, publícalo y copia su URL (pública). Mano de santo, con la ventaja de que además si modificas el dibujo los cambios se propagarán al formulario.
  • URL carpeta destino: Esta es la URL de la carpeta donde RecogeCV irá guardando los CV enviados. Puedes copiar y pegar su URL, tal y como aparece en Drive en la barra del navegador, o hacer clic en el botón anexo Seleccionar carpeta para escogerla empleando un cómodo selector. Si no indicas ninguna, los CV se almacenarán en la carpeta donde se encuentra la hoja de cálculo. Puedes hacer clic sobre el URL que se muestra en la celda para acceder raudo y veloz a la carpeta de archivo de CV. El ID de la carpeta, justo debajo, en gris y menor tamaño, aparecerá automáticamente tras seleccionarla. Puedes ignorarlo. Este valor se obtiene automáticamente por medio de una expresión regular y la función REGEXEXTRACT a partir del URL. Realmente es un vestigio procedente de la fase de desarrollo de RecogeCV que probablemente eliminaré en próximas versiones dado que aquí no pinta ya casi nada.
  • Instrucciones: Se mostrarán justo debajo de la imagen y el título del formulario.
Estos ajustes afectan del siguiente modo al formulario publicado:

Efecto de los ajustes sobre el aspecto del formulario.

Vamos con el segundo bloque, que sirve para activar o desactivar la presencia de una cláusula legal, que podemos personalizar (Texto consentimiento tratamiento), y la consiguiente exigencia de la aceptación de las condiciones del tratamiento sobre los datos enviados. Realmente en este caso es necesaria siempre, pero me dio por incluir en el diseño de RecogeCV esta posibilidad para utilizar los denominados scriptlets convencionales de Google Apps Script, que no había tenido la oportunidad de usar hasta ahora en ninguno de mis miniproyectos.

Modelo de cláusula legal para la obtención del consentimiento. ¡Adáptala, por el amor de Dios!

Y por último, en la parte inferior del panel de control, nos encontramos con una sección que permite activar (o no) el envío de notificaciones por correo electrónico cada vez que se recibe una nueva candidatura a través del formulario a los responsables del mismo. RecogeCV no envía, por ahora, notificación alguna a los candidatos que registran sus CV (más sobre esto, al final). Creo que en este caso todos los ajustes son lo suficientemente explícitos, así que no abundaré en ellos.

Configuración del sistema de notificaciones de RecogeCV.

En la segunda pestaña (CV), se despliega una sencilla tabla que recoge los datos de las candidaturas recibidas hasta el momento, incluyendo sus enlaces correspondientes a los archivos (CV) recibidos, que quedan archivados en la carpeta indicada en la pestaña anterior. Las candidaturas más recientes aparecerán siempre en las posiciones superiores. Aunque no están activados por defecto, puedes utilizar los conocidos controles de filtro sobre la hoja para facilitar la localización de cualquier candidatura, sin que ello afecte (¡espero!) al funcionamiento del script.

Tabla de respuestas del formulario.

El desplegable Caducidad CV (meses) permite establecer un plazo, pasado el cual, RecogeCV destacará en rojo la celda con la fecha de recepción de la candidatura. No, el RGPD no obliga a bloquear o eliminar los CV con más de 2 años de antigüedad, como he podido leer en algún que otro artículo sobre protección de datos. Tan solo dice que deben ser conservados durante el menor tiempo necesario para satisfacer la finalidad de su tratamiento y que deben establecerse mecanismos para la revisión de su vigencia y exactitud. No está de más, por tanto, que tomemos alguna medida, aunque sea de mínimos, en este sentido.

Activando RecogeCV

Una vez configurados todos estos aspectos, es el momento de publicar el script adjunto a la hoja de cálculo como webapp y obtener su URL para comenzar a recoger candidaturas laborales. Esto lo conseguiremos del siguiente modo:
  1. En la hoja de cálculo de RecogeCV haremos Herramientas → Editor de secuencias de comandos.
  2. Aparecerá el editor de scripts. Ahora seleccionaremos Publicar → Implementar como aplicación web.
  3. Se mostrará el cuadro de diálogo para desplegar (publicar) el script como aplicación web. En mi caso el botón inferior en la captura de pantalla muestra el texto Actualizar dado que el script ya ha sido publicado con anterioridad. De no ser así leerás Publicar.
Activando RecogeCV como aplicación web.

Varias cosas importantes aquí. La primera, el URL de la parte superior es el que nos permitirá acceder al formulario a través de un navegador. También lo podremos utilizar para incrustar el formulario en nuestra web.

Más cosas, el script se debe ejecutar como yo (osea, , cuando trabajes sobre tu propia copia de la plantilla que te proporciono). De este modo tendrá acceso a la carpeta establecida como contenedor para almacenar los CV con tus propias credenciales de seguridad.

Por último, cualquier persona, incluso de forma anónima, debe tener acceso a la aplicación (formulario). Eso es justo lo que pretendíamos.

Si en algún momento realizas cambios en el código de RecogeCV deberás repetir el proceso anterior, publicando una nueva versión (versión del proyecto → Nuevo).

Y por fin, pertrechados con el URL del proyecto web, solo tenemos que acceder a él a través de un navegador o, mejor, utilizarlo para embeber la aplicación en nuestro propio sitio web. Esto queda así de apañado:

RecogeCV incrustado en un sitio web creado con Google Sites.

Probando RecogeCV

¿Quieres probar por ti mismo este script sin necesidad de hacerte una copia de la plantilla ni andar publicando scripts? He preparado una instancia pública de RecogeCV para ello. Tanto la hoja de cálculo como la carpeta en Drive en la que se recibirán los CV está compartida con el mundo mundial en solo lectura. En este caso he desactivado el envío de notificaciones a los gestores del formulario. Puedes enviar tu candidatura simulada e inmediatamente comprobar de qué modo queda registrada en la hoja de cálculo y carpeta asociadas. ¡No envíes información real!

👉 Sitio de prueba 👈

De estar activadas las notificaciones por email, tendrían un aspecto como el que te muestro aquí:

Así son las notificaciones que envía RecogeCV.

Como puedes ver, se incluyen toda la información enviada por el candidato, así como enlaces al propio CV, a la hoja de cálculo de control y a la carpeta donde se archivan los CV.

Mirando bajo el capó de CV

Soy consciente de que esta publicación se ha extendido ya más allá quizás de lo humanamente soportable. De hecho estoy ahora mismo acariciando la idea de dejar los detalles íntimos de RecogeCV para una 2ª parte.

Pero un artículo que tenga que ver con GAS en este blog difícilmente estaría completo sin dedicar algunas líneas a sus interioridades.... y además mi TOC me lo impide, así que ¡qué demonios, vamos a por ello! Si eres de los que de pequeño desmontaban las cosas para ver cómo eran por dentro, quédate a mi vera. En caso contrario, sáltate este apartado sin remordimiento alguno.

A estas alturas está meridiamente claro que RecogeCV hace lo que hace gracias principalmente a un script GAS publicado como aplicación web. En este blog ya hemos hablado anteriormente de artefactos digitales similares, concretamente en febrero del año pasado presentaba una aplicación de consulta de notas que funcionaba de un modo similar:


En esta ocasión el desarrollo que presento es algo más complejo y sofisticado, aunque esencialmente se trata igualmente de una aplicación descompuesta en dos partes claramente diferenciadas:
  • Una que se ejecuta en los servidores de Google y tiene acceso a la hoja de cálculo, Drive, etc.  del propietario de la webapp. Esto es el código GAS, digamos, puro.
  • Otra que lo hace en el navegador del usuario (la interfaz HTML / JS / CSS en la que se presenta el formulario).
La gracia del asunto está en conectar ambos mundos, para lo que se pueden utilizar esencialmente dos estrategias, los llamados scriptlets y/o la comunicación asíncrona cliente / servidor por medio de la API JavaScript google.script.run. De ambas cosas ya se habló, aunque con desigual profundidad, en el articulo referenciado anteriormente. Y como entonces, vuelvo a sugerirte que leas con extrema atención la sección dedicada a la creación de interfaces de usuario de la excelente documentación que proporciona Google sobre sus Apps Script. Su comprensión es clave para dominar este tinglado.

En esta ocasión usaremos ambas.

En lo que sigue abundan los detalles técnicos. Si estás aprendiendo GAS (como yo), posiblemente te encuentres (espero) con cositas interesantes. Además, te facilitaré algunas indicaciones relevantes a la hora de introducir modificaciones en la aplicación y adaptarla a tus necesidades.

Pero veamos primeramente como está construido RecogeCV, en líneas generales, mediante un diagrama con cajitas y esas cosas.

Diagrama de bloques de RecogeCV.

No es el objetivo de este artículo hacer un análisis pormenorizado y exhaustivo de la implementación, pero sí me gustaría comentar algunas cosillas sobre algunos de los nueve módulos que forman la aplicación.

El código de RecogeCV lo encontrarás en la propia plantilla suministrada en el apartado anterior o en este repositorio de GitHub.

Código.gs

Es el bloque principal de la aplicación. Se trata de código Google Apps Script que, como sabemos, se ejecuta en el lado de los servidores de Google.  En sus primeras líneas te encontrarás con un montón de constantes. Es importante que las tengas en cuenta si realizas cambios en las celdas de la hoja de cálculo, puesto que sirven para parametrizar cosas como el nombre de sus pestañas o la posición de las celdas que son leídas o escritas desde el script.

// Constantes
var HOJA_AJUSTES = 'Control';
var FORM_ACTIVO = 'B3';
var TITULO = 'B5';
var COLOR_TEMA = 'F5';
var H_TITULO = 'H5';
var URL_LOGO = 'B7';
var URL_CARPETA = 'B10';
var ID_CARPETA = 'B11';
var INSTRUCCIONES = 'B13';
var CONSENTIMIENTO_ACTIVO = 'B15';
var CONSENTIMIENTO = 'B17';
var NOTIFICAR_ACTIVO = 'B19';
var NOMBRE_EMAIL = 'B21'
var ASUNTO_EMAIL = 'B23';
var TEXTO_PERSONALIZADO = 'B25';
var DESTINATARIOS = 'B27';
var NOMBRE_APP = 'RecogeCV';

var HOJA_CV = 'CV';
var FIL_CV = 3;
var COL_FECHA = 1;
var COL_NOMBRE = 2;
var COL_APELLIDOS = 3;
var COL_DNI = 4;
var COL_EMAIL = 5;
var COL_TEL = 6;
var COL_URL = 7;
var COL_OK = 8;

La función doGet() es el punto de entrada al formulario, que se construye a partir de un archivo HTML (formularioWeb.html), del que hablaremos en unos instantes, a modo de plantilla parametrizada mediante printing scriplets (yo prefiero denominarlos scriptlets explícitos). Así, al evaluar la plantilla (formularioWeb.evaluate) se insertan en su lugar el texto de encabezado (en el tamaño escogido en la pestaña Control), la imagen de cabecera y las instrucciones del formulario.

// Generar formulario web

function doGet(e) {

  var hdc = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HOJA_AJUSTES);
  var formularioWeb = HtmlService.createTemplateFromFile('formularioWeb');
  
  // Rellenar elementos de plantilla
  
  formularioWeb.titulo = hdc.getRange(TITULO).getValue();
  formularioWeb.htitulo = hdc.getRange(H_TITULO).getValue();
  formularioWeb.urlImgLogo = hdc.getRange(URL_LOGO).getValue();
  formularioWeb.instrucciones = hdc.getRange(INSTRUCCIONES).getValue();   
  formularioWeb.consentimiento = hdc.getRange(CONSENTIMIENTO).getValue();
  
  return formularioWeb.evaluate().setTitle(hdc.getRange(TITULO).getValue());
    
}

Por ejemplo, en este fragmento del código HTML en el módulo formularioWeb se sustituiría la etiqueta <?= instrucciones ?> por el texto de las instrucciones, tal y como se han escrito en la celda correspondiente de la pestaña Control:

      <div class="row left-align">
        <div class="col s12">
          <p><?= instrucciones ?></p>
        </div>
      </div>

Un poco más abajo se encuentra la función enviarFormulario(), que es invocada desde el código JavaScript en el archivo formularioWeb_js.html empleando el mecanismo de llamada asíncrona desde JavaScript  que implementa google.script.run. El parámetro e es un objeto enviado desde el lado cliente de la aplicación que contiene, como propiedades, los valores introducidos en cada uno de los campos del formulario. El archivo adjunto seleccionado por el usuario se recibe como un objeto de la clase blob. A continuación se inserta una fila en la parte superior de la tabla de candidaturas (pestaña CV) y se registra en ella la información correspondiente a nombre, apellidos, DNI, email, teléfono y casilla de consentimiento, además de la fecha y hora actuales. Todo el código de esta función está encerrado en un bloque try {} .. catch(e) {} para cazar posibles errores en tiempo de ejecución, que serían devueltos al cliente (formulario web) y mostrados en pantalla.

// Recibir datos del formulario

function enviarFormulario(e) {

  // ¡Aunque no se devuelva nada con return, si se ha producido
  // un error puede cazarse en el lado JS con .withFailureHandler(function(valor){})!
    
  // Todo dentro de un bloque try / catch para cazar y mostrar errores
  
  try {
    
    var hdc = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HOJA_CV);
    
    // Si hay candidatura en fila de respuestas (se comprueba fecha), insertar una nueva
    // Arriba siempre los envíos más recientes
    
    if (hdc.getRange(FIL_CV, COL_FECHA).getValue() != '') {
    
      hdc.insertRowBefore(FIL_CV);   
      
    }
    
    hdc.getRange(FIL_CV, COL_FECHA).setValue(new Date()).setNumberFormat('dd/mm/yy HH:mm');
    hdc.getRange(FIL_CV, COL_NOMBRE).setValue(e.nombre);
    hdc.getRange(FIL_CV, COL_APELLIDOS).setValue(e.apellidos);
    hdc.getRange(FIL_CV, COL_DNI).setValue(e.dni);
    hdc.getRange(FIL_CV, COL_EMAIL).setValue(e.email);
    hdc.getRange(FIL_CV, COL_TEL).setValue(e.telefono);
    
        // Mostrar estado del checkbox y ajustar validación de datos de la celda para que aparezca [X]/[]
    
    hdc.getRange(FIL_CV, COL_OK).setValue(e.ok == 'on' ? true : false);
    hdc.getRange(FIL_CV, COL_OK).setDataValidation(SpreadsheetApp.newDataValidation().requireCheckbox());

Lo siguiente es llevar a la carpeta escogida por el usuario (pestaña Control) el archivo que ha subido nuestro candidato. El nombre del archivo (archivoNombre) se genera concatenando apellidos, nombre, # y el propio nombre del archivo enviado por el candidato. También se genera aquí el URL del CV, ya almacenado en Drive, para que los gestores del formulario puedan simplemente hacer clic sobre él para abrirlo.  De todo ello se encarga este bloque:

    // Copiar archivo en carpeta de Drive y generar URL
    // e.cv.getContenType solo se fija en la extensión, por tanto no es un método válido
    // para determinar si realmente se trata de un PDF. Habría que convertir el blob a binario y
    // comprobar si los 4 primeros bytes son 25 50 44 46 (ASCII %PDF-1.3), pero no creo que
    // merezca la pena complicarse tanto la vida
    // https://www.filesignatures.net/index.php?page=search&search=PDF&mode=EXT
        
    // Crea archivo en carpeta destino en Drive, si no se ha especificado se utiliza la de la hoja de cálculo
     
    var carpetaDestino =  SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HOJA_AJUSTES).getRange(ID_CARPETA).getValue();
    
    if (carpetaDestino == '') {
    
      carpetaDestino = DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()).getParents().next();
    
    }
    else {
    
      carpetaDestino = DriveApp.getFolderById(carpetaDestino);
    
    } 
    
    var archivo = carpetaDestino.createFile(e.cv);
    var archivoNombre = e.apellidos + ' ' + e.nombre + ' # ' + e.cv.getName();
    archivo.setName(archivoNombre);

A continuación se comprueba si las notificaciones por email están activadas (pestaña Control), y en su caso se llama a la función enviarEmail(), pasándole los parámetros necesarios.

    // Enviar notificaciones por email a gestores de candidaturas, si procede
    
    var notificar = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HOJA_AJUSTES).getRange(NOTIFICAR_ACTIVO).getValue();
    
    if (notificar == true) {
    
      var errorEmail = enviarEmail({nombre: e.nombre,
                     apellidos: e.apellidos,
                     dni: e.dni,
                     email: e.email,
                     telefono: e.telefono,
                     consentimiento: e.ok == 'on' ? 'Exigida y aceptada' : 'No exigida',
                     archivoNombre: archivoNombre,
                     archivoURL: archivo.getUrl(),
                     carpetaNombre: carpetaDestino.getName(),
                     carpetaURL: SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HOJA_AJUSTES).getRange(URL_CARPETA).getValue()
                    });                       
    }

Por último, se comprueba si se han producido errores, devolviendo al bloque de código JavaScript en el lado del cliente diversos valores como resultado para que sean tratados de manera oportuna. No quiero dejar de mencionar el hecho de que no es posible mostrar en pantalla mensajes de error en este contexto, dado que este código se está ejecutando en virtud de una llamada desde una función JavaScript en el ámbito del cliente. Algo como SpreadsheetApp.getUi().alert('Error') ocasionaría un error en tiempo de ejecución. No queda otra, por tanto, que pasárselos al cliente para que sean tratados allí.

    if (errorEmail) {
    
      // Salir devolviendo a cliente posible error al enviar email
      
      return errorEmail;
    
    }
    else {
      
      // Devolver resultado a formulario web
          
      return '✅ Datos enviados';
      
    } 
  } 
  catch (e) {
  
    return '❌ Error: ' + e;
  
  }
}

La última función dentro de este módulo es enviarEmail(). Es la encargada de notificar mediante correo electrónico la recepción de una  nueva candidatura. Nada especialmente destacable, tan solo decir que se emplea nuevamente una plantilla HTML (plantillaEmail.html) que es rellenada de manera dinámica mediante scriptlets explícitos.

function enviarEmail(candidatura) {

  try {

    var hdc = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HOJA_AJUSTES);
    var asunto = hdc.getRange(ASUNTO_EMAIL).getValue();
    var destinatarios = hdc.getRange(DESTINATARIOS).getValue();
    var remitenteNombre = '[' + NOMBRE_APP + '] ' + hdc.getRange(NOMBRE_EMAIL).getValue();
    
    // Construimos plantilla de mensaje HTML
    
    var plantillaHtml = HtmlService.createTemplateFromFile('plantillaEmail');
    
    // Valores de scriptlets
    
    plantillaHtml.urlImgLogo =hdc.getRange(URL_LOGO).getValue();
    plantillaHtml.textoPersonalizado = hdc.getRange(TEXTO_PERSONALIZADO).getValue();
    plantillaHtml.titulo =hdc.getRange(TITULO).getValue();
    plantillaHtml.candidato = candidatura.nombre + ' ' + candidatura.apellidos;
    plantillaHtml.instrucciones =hdc.getRange(INSTRUCCIONES).getValue();
    plantillaHtml.dni = candidatura.dni;
    plantillaHtml.email = candidatura.email;
    plantillaHtml.telefono = candidatura.telefono;
    plantillaHtml.archivoURL = candidatura.archivoURL;
    plantillaHtml.ok = candidatura.consentimiento;
    plantillaHtml.archivoNombre = candidatura.archivoNombre;
    plantillaHtml.carpetaURL = candidatura.carpetaURL;
    plantillaHtml.carpetaNombre = candidatura.carpetaNombre;
    plantillaHtml.hdcURL = SpreadsheetApp.getActiveSpreadsheet().getUrl();
    plantillaHtml.hdcNombre = SpreadsheetApp.getActiveSpreadsheet().getName();

    // Crear documento HTML con resultados vía scriptlets
    
    var mensajeHTML = plantillaHtml.evaluate().setTitle(asunto);
    
    // Enviar email(s)
    
    MailApp.sendEmail(destinatarios,
                      asunto,'Necesitas un cliente de correo capaz de mostrar HTML.',
                      {name: remitenteNombre, htmlBody:mensajeHTML.getContent()});
                      
  }
  
  catch (e) {
      
   return '❌ Error: ' + e;
  
  }
}

panelCarpeta.html (y pickerCarpeta.gs)

Se trata de un simple "cascarón" para desplegar el cuadro de diálogo de selección de carpeta, que es algo que se consigue exclusivamente con código JavaScript. Fíjate en el modo en que se han incluido los bloques JavaScript y CSS necesarios para su funcionamiento. En lugar de simplemente incluir su contenido de manera explícita dentro de este módulo, se ha recurrido nuevamente a sendos scriptlets para conseguir embeberlo a partir de sus respectivos módulos, panelCarpeta_js(.html) y css(.html). De esta manera cumplimos de un modo sencillo y elegante el sano principio de separar HTML, CSS y JavaScript en ficheros independientes.

<!DOCTYPE html>
<html>
  <head>
    <base target="_top"> 
    
    <!-- Incluir css -->
    <?!= HtmlService.createHtmlOutputFromFile('css').getContent(); ?>

  </head>
  
  <body>   
  </body>
  
  <?!= HtmlService.createHtmlOutputFromFile('panelCarpeta_js').getContent(); ?>
      
</html>

En efecto, lo que queda encerrado entre <?!= .. ?>  en este archivo HTML es código GAS nativo, que se ejecuta en el ámbito del servidor una única vez en el momento de preparar la página web que va a desplegarse. Es decir, la página se genera de modo dinámico, aunque posteriormente ya solo podría modificarse mediante código JavaScript en el lado del client. En esta ocasión se ha empleado un tipo especial de scriptlets explícitos, los denominados forzados (!), para evitar que el preprocesador de la clase HtmlService realice cualquier modificación sobre ellos por razones de seguridad y nos desmonte el resultado, que no es otra cosa que código insertado dentro de un documento HTML que deberá interpretar un navegador

El panel modal en el que se despliega todo este montaje se construye mediante la siguiente función en el módulo pickerCarpeta.gs:

// Crea panel modal para desplegar el selector de carpetas (pickerCarpeta.gs)

function seleccionarCarpeta() {

  var panel = HtmlService.createTemplateFromFile('panelCarpeta')
    .evaluate()
    .setWidth(710)
    .setHeight(610);
  SpreadsheetApp.getUi().showModalDialog(panel,'Seleccionar carpeta destino para CV');

}

panelCarpeta_js.html (y pickerCarpeta.gs)

Aquí encontrarás el código JavaScript realmente responsable de gestionar el cuadro de diálogo utilizado para seleccionar la carpeta destino para los CV.


Ha sido tomado en gran medida de un ejemplo facilitado en la documentación de Google, y lógicamente adaptado a mis necesidades. Para poder usar un cuadro de diálogo de este tipo hay que obtener una clave de desarrollador (DEVELOPER_KEY) en la Consola de APIs de Google, de hecho existe hasta un pequeño asistente para ello. Eso hace que el script tenga que estar asociado a un proyecto en la consola de Google Cloud, en lugar de utilizar el que de manera predeterminada se gestiona desde Apps Script en casos más sencillos. Sí, esto no ha sido siempre así, de hecho este nuevo comportamiento se ha introducido hace apenas unos meses.

Todo el proceso está descrito de un modo muy detallado en la documentación de referencia y, en cualquier caso, no es el momento ni el lugar para meternos en ese bonito berenjenal, que tiene alguna que otra arista. De todos modos no tienes nada de qué preocuparte. Si haces una copia de la plantilla de RecogeCV que te proporciono, el selector de carpetas debería simplemente funcionar sin intervención de tu parte.

La totalidad del código de este módulo se ejecuta en el ámbito del cliente. La carpeta seleccionada se devuelve al código GAS en el servidor mediante una invocación asíncrona a la función del servidor recibircarpeta() en el módulo pickerCarpeta.gs, que establece el valor de la celda correspondiente en la pestaña Control de modo que muestre su URL.

      // Pasar a servidor (panelCarpeta_js.html)
      
      google.script.run
        .withSuccessHandler(function() { google.script.host.close(); })
        .recibirCarpeta(url);
      
      } else if (action == google.picker.Action.CANCEL) {
        // M.toast({html: 'Selección de carpeta cancelada'});
        google.script.host.close();
      }

// Recibe información de la carpeta seleccionada por el usuario (pickerCarpeta.gs)

function recibirCarpeta(carpeta) {

  SpreadsheetApp.getActiveSheet().getRange(URL_CARPETA).setValue(carpeta);

}

formularioWeb.html

Ya vamos llegando al meollo del asunto. Este módulo es el que muestra el formulario. Pero como dijo Jack el Destripador, vamos por partes.

Este documento HTML se genera siguiendo la misma estrategia dinámica que en el caso de panelCarpeta.html. Los bloques CSS y JavaScript son incluidos en él utilizando scriptlets, aunque, como veremos, con una salvedad importante.

Para que la parte web de RecogeCV tenga un aspecto más vistoso y agradable, he utilizado el framework Materialize. Esto, junto con el uso de la librería jQuery, me permite conseguir mejores resultados en menos tiempo. Estas dependencias deben indicarse en los encabezados de los módulos css.html formularioWeb_js.html.

<!-- css.html -->

<!-- Fuente de iconos de Google -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">

<!-- CSS Materialize -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">

<!-- formularioWeb_js.html -->

<!-- Google API -->
<script src="https://apis.google.com/js/api.js"></script>

<!-- jQuery ¡¡cargar ANTES que Materialize!! -->
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>

<!-- Materialize -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>

Pero es que adicionalmente he usado una vez más scriptlets explícitos dentro del módulo css.html para modificar el color de algunos elementos del formulario de acuerdo con el especificado en la pestaña Control:

<!-- css.html -->
<style>

  /* Botones */
  
  .btn {
    background-color: <?!= colorTema ?>;
  }
  
  /* Etiquetas formulario */

  .input-field>label {
    color: <?!= colorTema ?>;
  }

  /* Línea campos formulario al conseguir foco */
  
  input:focus {
    border-bottom: 1px solid <?!= colorTema ?>;
    box-shadow: 0 1px 0 0 <?!= colorTema ?>;
  }
  
  /* Iconos */
 
  .input-field>.material-icons {
     color: <?!= colorTema ?>;
  }

  /* Casillas de verificación */
  
  [type="checkbox"].filled-in:checked + span:not(.lever):after {
    background-color: <?!= colorTema ?>;
    border-color: <?!= colorTema ?>;
  }

</style>

Esto requeriría de una instanciación de scriptlets a dos niveles a partir de los valores establecidos para ellos en la función doGet() en Código.gs, que afectaría inicialmente al contenido del archivo formularioWeb.html y, en la medida en que este incluye código de css.html por medio de una instrucción como <?!= HtmlService.createHtmlOutputFromFile('css').getContent(); ?>, también a continuación al contenido de este último. 

Pero parece que el lío no funciona del modo esperado cuando se complica tanto, así que tendremos que recurrir a una argucia para conseguir nuestro objetivo. Este código es el realmente utilizado en el encabezado de formularioWeb.html para incluir el bloque CSS con el color del tema correctamente parametrizado:

  <head>
  
    <base target="_top">
      
    <!-- Incluir css *parametrizado* para aplicar hacks a colores Materialize
         La evaluación anidada de printing scriptlets no parece funcionar, así que opto
         por combinar scriptlet (obtener valor) + printing scriptlet (evaluar css parametrizado). -->
    
    <? var a = HtmlService.createTemplateFromFile('css');
       a.colorTema = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HOJA_AJUSTES).getRange(COLOR_TEMA).getValue(); ?> 
    <?!= a.evaluate().getContent(); ?>

  </head>

Lo sé, no es la cosa más intuitiva del mundo, pero no he encontrado por el momento otra estrategia válida. El código anterior combina el uso de scriptlets explícitos, que "imprimen" el resultado de su evaluación en el código HTML del documento, con los no explícitos (o estándar), que únicamente sirven para ejecutar código GAS en el lado del cliente sin producir un resultado directo (<? .. >).

Estos scriplets estándar se emplean un poco más abajo para mostrar el formulario o un mensaje indicando que está cerrado en función del interruptor general en la pestaña Control:

      <!-- Mostrar formulario o mensaje de inactivo -->
      
      <? if (SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HOJA_AJUSTES).getRange(FORM_ACTIVO).getValue() == true) { ?>    
    
        <!-- Aquí va el formulario -->
 
      <? } else { ?>
        
        <div class="center-align flow-text">
          <div class="row center-align col s12">
            <i class="large material-icons red-text accent-3-text">flash_off</i>
          </div>
          <div class="row">
            <p>Este formulario no está aceptando respuestas</p>
          </div>
        </div>

      <? } ?>   
     
    </div>
    
  </body>

Esto dice RecogeCV cuando está cerrado.

Y los usaremos una vez más para conseguir que aparezca o no la sección que muestra la información relacionada con el tratamiento de datos y exige marcar la casilla de consentimiento:

          <!-- Mostrar o no sección con consentimiento -->
          
          <? if (SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HOJA_AJUSTES).getRange(CONSENTIMIENTO_ACTIVO).getValue() == true) { ?>
          
            <div class="row valign-wrapper">   
              <div class="left">
                <i class="material-icons left medium red-text accent-3-text">announcement</i>
              </div>
              <div class="right">
                <small>
                <blockquote><?= consentimiento ?></blockquote>
                </small>
              </div>
            </div>              
            
            <div class="row center-align">  
              <label for="chk_ok">
              <input type="checkbox" id="chk_ok" name="ok" class="filled-in" required="" aria-required="true">
              <span>He leído y acepto la política de privacidad</span>
              </label>
            </div>
          
          <? } ?>

El fragmento de código anterior comprueba el estado de la casilla de verificación en la pestaña Control que indica si el bloque del formulario relativo al consentimiento debe mostrarse o no (intrucción IF GAS dentro de un scriptlet estándar). Si es que sí, se inserta el código HTML necesario en la página . Y si es que no, sencillamente no se hace nada.

En definitiva, RecogeCV demuestra (aunque no sé si de un modo ejemplar) el uso de los tres tipos de scriptlets disponibles en GAS, que como ves molan. Y mucho.
  • Estándar, para ejecutar código GAS arbitrario al generar la página HTML y generar contenido de manera condicional.
  • Explícitos, para parametrizar el contenido del documento web que muestra el formulario y algunos aspectos de su CSS asociado.
  • Explícitos forzados, para incluir el código CSS y JavaScript de módulos externos en los documentos HTML.
Dediquemos ahora unas líneas para hablar del propio formulario, cuya presentación se ha organizado en dos columnas con enorme facilidad gracias a Materialize. Todos los campos se han ajustado como obligatorios (en el CFP Chachi pensamos que todos los candidatos deben tener nombre, apellidos, DNI, email y teléfono), aunque esta consideración es fácilmente modulable: basta con eliminar el atributo required del elemento input correspondiente y la línea <span class="helper-text">Requerido</span> justo debajo.

          <!-- Formulario: Nombre | Apellidos -->
          
          <div class="row">
            <div class="input-field col s6 l4 offset-s0 offset-l2">
              <i class="material-icons prefix">person</i>
              <input id="txt_nombre" name="nombre" type="text" class="validate" required>
              <label for="txt_nombre">Nombre</label>
              <span class="helper-text">Requerido</span>
            </div>
            <div class="input-field col s6 l4">
              <i class="material-icons prefix">person_add</i>
              <input id="txt_apellidos" name="apellidos"  type="text" class="validate" required aria-required="true">
              <label for="txt_apellidos">Apellidos</label>
              <span class="helper-text">Requerido</span>
            </div>  
          </div>

Con respecto al elemento del formulario que permite adjuntar un archivo, decir que el cuadro de diálogo de selección solo muestra los elementos de tipo PDF. Para admitir otros distintos (los dioses nos protejan de los CV en formato .DOCX y similares), basta con modificar el atributo accept=".pdf, application/pdf", utilizando en lugar de pdf los tipos MIME apropiados. No obstante es muy fácil saltarse este control, solo hay que cambiarle la extensión al archivo. Por cierto, Si hurgas en el código de la función enviarFormulario() en el módulo Código.gs, encontrarás un comentario acerca de cómo realizar un control mucho más estricto. Yo he decidido que simplemente no merecía la pena.

            <div class="file-field input-field col s6 l4">
              <a class="btn"><i class="material-icons left">cloud_upload</i>
                <span>CV</span>
                <input type="file" id="txt_cv" name="cv" required aria-required="true" accept=".pdf, application/pdf">
              </a>
              <div class="file-path-wrapper">
                <input class="file-path validate" type="text">
                <span class="helper-text">Requerido (PDF)</span>
              </div>
            </div>
          </div>

¿Se podría haber hecho algo para que este selector de archivos conectara con el Google Drive del candidato para tomar de allí directamente el CV? Pues claro, pero ya me estaba quedando sin "GAS" a estas alturas. Por otro lado, tampoco acababa de ver eso de que la webapp utilizara un mecanismo de autenticación vía OAUTH contra la cuenta de Google de la persona que rellena el formulario. Quizás más adelante.

Para terminar, echémosle un ojo al módulo que contiene el JavaScript que propulsa el funcionamiento del formulario.

formularioWeb_js.html

El código es muy sencillo. Se asigna un manejador al evento de acción del botón de envío del formulario que, además de algunas cuestiones visuales de menor importancia, realiza una llamáda asíncrona a la función GAS enviarFormulario(), que ya hemos desmenuzado cuando hablábamos del módulo Código.gs.

<script>

  $(function() {
      
    // Manejador clic en botón de envío de formulario
    
    $('#formCV').on('submit', function(e){
    
      M.toast({html: 'Enviando datos'});      
      
      // Impedir mecanismo de envío convencional del formulario
      
      e.preventDefault();    
      
      // Sustituir botón por spinner
    
      $('#btn_enviar').addClass('hide');
      $('#spinner').removeClass('hide');
      $('#area_estado').addClass('hide'); // por si venimos de error al enviar
      
      // Tratamos de hacer visible el spinner
          
      $('#spinner').get(0).scrollIntoView({ behavior: 'smooth'});
       
      google.script.run
      .withSuccessHandler(function(resultado) {
      
        M.toast({html: resultado});
        
        if (resultado == '✅ Datos enviados') {
                
          // Desactivar Spinner y mostrar mensaje
          
          $('#spinner').addClass('hide');
          $('#area_estado').removeClass('hide');
          $('#msj_estado').text('✅ Datos enviados, puedes cerrar la página.');
                    
        }
        else { // Se han producido errores
        
          // Reactivar botón
          
          $('#spinner').addClass('hide');
          $('#btn_enviar').removeClass('hide');
          $('#area_estado').removeClass('hide');
          $('#msj_estado').text('❌ Error al realizar la operación, quizás quieras intentarlo más tarde.');
     
        }
        
      // Tratamos de hacer visible el mensaje de estado
          
      $('#msj_estado').get(0).scrollIntoView({ behavior: 'smooth' });        
        
      })
      .withFailureHandler(function(resultado) {
      
        M.toast({html: '❌ Error al realizar la operación:\n\n' + resultado});
      
      })
      .enviarFormulario($('#formCV').get(0));
    
    });
 
 });

</script>

En este caso se utilizan las funciónes de callback .withSuccessHandler() y .withFailureHandler() del modo apropiado para informar a nuestro candidato del resultado del proceso de envío de su candidatura. Del mismo modo, para que no se aburra durante el (previsiblemente) corto tiempo de espera, se emiten mensajes de estado graias a la función M.toast() de Materialize y se muestra una barra de actividad animada, inicialmente invisible, declarada en formularioWeb.html:

        <!-- Indicador de actividad, inicialmente oculto... -->
        
        <div id="spinner"class="progress hide">
          <div class="indeterminate" id="barra_actividad"></div>
        </div>

Esto es lo que experimentará el candidato al interaccionar con nuestro formulario:

Experiencia de uso de RecogeCV para el candidato.

Mejoras, despedida y cierre

¿Mejoras? Pues se me ocurre una lista larga como un día sin hojas de cálculo. A ver, que miro en mi Trello de proyectos GAS...

Valorando mejoras para RecogeCV v2...

De todas ellas, creo que las más relevantes son las dos primeras, puesto que facilitarían el cumplimiento de los derechos de cancelación y rectificación de la información facilitada por los candidatos que marca el RGPD sin intervención de los responsables del formulario, favoreciendo además que sus datos se mantuvieran actualizados de un modo más automatizado.

¿A a ti qué se te ocurre?

Y hemos alcanzado el final. Como suelo decir en algunos de mis artículos (los más tostones), si has llegado hasta aquí te mereces un premio (o dos). No creo que sea este uno de esos finales que llegan cuando tienen que llegar, sino más bien mucho después. Como tuiteaba hace un rato, este artículo se ha apoderado de mi tiempo de manera inmisericorde y me ha exigido un desarrollo mucho más profundo del que inicialmente pensaba darle. Lo que iba a ser una escueta presentación de RecogeCV se ha convertido en... bueno, lo cierto es que no sé muy bien en qué se ha convertido, la verdad.

Para terminar, ahora sí, disculpa los errores que seguro se han deslizado a lo largo de estas líneas. He escrito este artículo de manera fragmentaria juntando ratitos de aquí y de allá a lo largo de la última semana, así que seguro que algunas cosas desentonan y otras están rematadamente mal. Iremos corrigiéndolas sobre la marcha.

Y ahora, la caja de comentarios de aquí abajo es toda tuya... 👇

Comentarios