15. Sincronización

Versión para imprimir.

A. Introducción

B. Diagrama entidad relación

Diagrama entidad relación

C. Diagrama de despliegue

Diagrama de despliegue

D. Hazlo funcionar (con videos)

  1. Prueba el ejemplo en https://sincro.rf.gd/.

  2. Copia la url de la app y pégala en varios navegadores y dispositivos.

  3. Las modificaciones que realices en dispositivo o navegador se verán reflejados en los otros dispositivos, en un máximo de 20 segundos.

  4. Puedes trabajar sin conexión en algunos dispositivos y con conexión en otros. Si conectas todos los dispositivos, estos mostrarán los mismos datos después de un tiempo.

  5. Descarga el archivo /src/sincro.zip y descompáctalo.

  6. Crea una cuenta de email con el nombre de tu sitio, por ejemplo, miapp@google.com

  7. Crea una cuenta de GitHub usando el email anterior y selecciona el nombre de usuario unsando la parte inicial del correo electrónico, por ejemplo miapp.

  8. Crea un repositorio nuevo. En el nombre del repositorio debes poner el nombre de tu cuenta seguido por .github.io; por ejemplo miapp.github.io

  9. Importa el proyecto de GitHub a Visual Studio Code

  10. Edita los archivos que desees.

  11. El archivo sw.js tiene una lista de los archivos que se instalan. El archivo instruccionesListadoSw.txt te indica como generarla usando Visual Studio Code.

  12. Prueba tu sitio localmente.

  13. Necesitas un hosting. En este ejemplo se muestra como usar el hosting. https://infinityfree.com/ Si no lo has usado, lo primero que tienes que hacer es entrar a registrar tu email con el botón Registrar. Si ya tienes tu email registrado, omite este paso.

  14. Crea una cuenta. Si ya tienes cuenta, entra a ella y crea un nuevo dominio. En este ejemplo no se crean los archivos directamente en el hosting.

  15. Sube tus archivos al hosting usando ftp.

  16. Sube tus archivos a GitHub. En este ejemplo no hay archivo sw.js ni necesitas esperar 11 o más minutos.

E. Hazlo funcionar (texto)

  1. Prueba el ejemplo en https://sincro.rf.gd/.

  2. Copia la url de la app y pégala en varios navegadores y dispositivos.

  3. Las modificaciones que realices en dispositivo o navegador se verán reflejados en los otros dispositivos, en un máximo de 20 segundos.

  4. Puedes trabajar sin conexión en algunos dispositivos y con conexión en otros. Si conectas todos los dispositivos, estos mostrarán los mismos datos después de un tiempo.

  5. Descarga el archivo /src/sincro.zip y descompáctalo.

  6. Crea tu proyecto en GitHub:

    1. Crea una cuenta de email, por ejemplo, pepito@google.com

    2. Crea una cuenta de GitHub usando el email anterior y selecciona el nombre de usuario unsando la parte inicial del correo electrónico, por ejemplo pepito.

    3. Crea un repositorio nuevo. En la página principal de GitHub cliquea 📘 New.

    4. En la página Create a new repository introduce los siguientes datos:

      • Proporciona el nombre de tu repositorio debajo de donde dice Repository name *.

      • Mantén la selección Public para que otros usuarios puedan ver tu proyecto.

      • Verifica la casilla Add a README file. En este archivo se muestra información sobre tu proyecto.

      • Cliquea License: None. y selecciona la licencia que consideres más adecuada para tu proyecto.

      • Cliquea Create repository.

  7. Importa el proyecto en GitHub:

    1. En la página principal de tu proyecto en GitHub, en la pestaña < > Code, cliquea < > Code y en la sección Branches y copia la dirección que está en HTTPS, debajo de Clone.

    2. En Visual Studio Code, usa el botón de la izquierda para Source Control.

      Imagen de Source Control
    3. Cliquea el botón Clone Repository.

    4. Pega la url que copiaste anteriormente hasta arriba, donde dice algo como Provide repository URL y presiona la teclea Intro.

    5. Selecciona la carpeta donde se guardará la carpeta del proyecto.

    6. Abre la carpeta del proyecto importado.

    7. Añade el contenido de la carpeta descompactada que contiene el código del ejemplo.

  8. Edita los archivos que desees.

  9. El archivo sw.js tiene una lista de los archivos que se instalan. El archivo instruccionesListadoSw.txt te indica como generarla usando Visual Studio Code.

  10. Cada vez que modifiques los archivos, debes modificar el valor de VERSION en el archivo sw.js para poder ver los cambios en el navegador.

  11. Haz clic derecho en index.html, selecciona PHP Server: serve project y se abre el navegador para que puedas probar localmente el ejemplo.

  12. Cuando desarrolles, es incómodo modificar la versión cada que realizas cambios; en vez de ello desinstala la app:

    1. Abre las herramientas de depuración haciendo clic derecho en la página y selecciona Inspeccionar (o Inspect si aparece en inglés).

    2. En la Pestaña Aplicación (o Application en inglés) selecciona Almacenamoento (o Storage en inglés). Cliquea Borrar datos del sitio.

    3. Recarga la app, de preferencia haciendo clic derecho en el ícono de volver a cargar la página Ïmagen del ícono de recarga y seleccionando vaciar caché y volver a cargar de manera forzada (o algo parecido). Si no aparece un menú emergente, simplemente cliquea volver a cargar la página Ïmagen del ícono de recarga. Revisa que no aparezca ningún error ni en la pestañas Consola, ni en Red.

    4. Tanbién puedes usar la combinación de teclas Ctrl+Mayúsculas+r para forzar que se actualice temporalmente el navegador en caso de que no se vean los cambios.

    5. En la Pestaña Aplicación (o Application en inglés) selecciona Almacenamiento en caché (o Cache storage en inglés). Aquí puedes revisar si el caché de la aplicación se llenó correctamente. En caso de que esté vacío, es que hubo algún error durante la carga y la app se ejecuta más lenta.

  13. Para depurar paso a paso haz lo siguiente:

    1. En el navegador, haz clic derecho en la página que deseas depurar y selecciona inspeccionar.

    2. Selecciona la pestaña Fuentes (o Sources si tu navegador está en Inglés).

    3. Selecciona el archivo donde vas a empezar a depurar.

    4. Haz clic en el número de la línea donde vas a empezar a depurar.

    5. En Visual Studio Code, abre el archivo de PHP donde vas a empezar a depurar.

    6. Haz clic en Run and Debug .

    7. Si no está configurada la depuración, haz clic en create a launch json file.

    8. Haz clic en la flechita RUN AND DEBUG, al lado de la cual debe decir Listen for Xdebug .

    9. Aparece un cuadro con los controles de depuración

    10. Selecciona otra vez el archivo de PHP y haz clic en el número de la línea donde vas a empezar a depurar.

    11. Regresa al navegador, recarga la página de manera normal y empieza a usarla.

    12. Si se ejecuta alguna de las líneas de código seleccionadas, aparece resaltada en la pestaña de fuentes. Usa los controles de depuración para avanzar, como se muestra en este video.

  14. Sube el proyecto al hosting que elijas.

    1. Crea una nueva carpeta para crear un nuevo proyecto que estará conectado directamente al servidor web por ftp.

    2. Abre la nueva carpeta con Visual Studio Code.

    3. Tecle al mismo Mayúsculas+Control+P y selecciona SFTP: Config. Aparece un archivo de configuración de FTP. Llena los datos con la configuración de FTP de tu servidor, excepto la contraseña.

    4. Cliquea el botón de SFTP y luego haz clic en la URL de tu servidos. En la barra superior te pide la contraseña y ENTER.

    5. Pásate a la parte de archivos y coloca tus archivos.

    6. Cliquea con el botón derecho en la sección de archivos y selecciona Sync: Local -> Remote.

  15. Abre un navegador y prueba el proyecto en tu hosting.

  16. En el hosting InfinityFree, la primera vez que corres la página, puede marcar un mensaje de error, pero al recargar funciona correctamente. Puedes evitar este problema usando un dominio propio.

  17. Para subir el código a GitHub, en la sección de SOURCE CONTROL, en Message introduce un mensaje sobre los cambios que hiciste, por ejemplo index.html corregido, selecciona v y luego Commit & Push.

    Imagen de Commit & Push

F. Archivos

Haz clic en los triángulos para expandir las carpetas

G. index.html

1
<!DOCTYPE html>
2
<html lang="es">
3
4
<head>
5
6
 <meta charset="UTF-8">
7
8
 <title>Sincronizacion</title>
9
10
 <meta name="viewport" content="width=device-width">
11
12
 <script type="module" src="js/lib/registraServiceWorker.js"></script>
13
 <script type="module" src="js/lib/manejaErrores.js"></script>
14
15
</head>
16
17
<body>
18
19
 <h1>Sincronizacion</h1>
20
21
 <p><a href="agrega.html">Agregar</a></p>
22
23
 <ul id="lista">
24
  <li><progress max="100">Cargando…</progress></li>
25
 </ul>
26
27
 <script type="module">
28
29
  import { muestraError } from "./js/lib/muestraError.js"
30
  import { esperaUnPocoYSincroniza } from "./js/esperaUnPocoYSincroniza.js"
31
  import {
32
   pasatiempoConsultaNoEliminados
33
  } from "./js/pasatiempoConsultaNoEliminados.js"
34
  import { renderiza } from "./js/renderiza.js"
35
  import { sincroniza } from "./js/sincroniza.js"
36
37
  pasatiempoVistaIndex()
38
39
  export async function pasatiempoVistaIndex() {
40
   try {
41
    const pasatiempos = await pasatiempoConsultaNoEliminados()
42
    renderiza(pasatiempos)
43
    sincroniza()
44
   } catch (error) {
45
    muestraError(error)
46
    esperaUnPocoYSincroniza()
47
   }
48
  }
49
50
 </script>
51
52
</body>
53
54
</html>

H. agrega.html

1
<!DOCTYPE html>
2
<html lang="es">
3
4
<head>
5
6
 <meta charset="UTF-8">
7
 <meta name="viewport" content="width=device-width">
8
9
 <title>Agregar</title>
10
11
 <script type="module" src="js/lib/registraServiceWorker.js"></script>
12
 <script type="module" src="js/lib/manejaErrores.js"></script>
13
14
</head>
15
16
<body>
17
18
 <form id="formulario">
19
20
  <h1>Agregar</h1>
21
22
  <p><a href="index.html">Cancelar</a></p>
23
24
  <p>
25
   <label>
26
    Nombre *
27
    <input name="nombre">
28
   </label>
29
  </p>
30
  <p>* Obligatorio</p>
31
  <p><button type="submit">Agregar</button></p>
32
33
 </form>
34
35
 <script type="module">
36
37
  import { pasatiempoAgrega } from "./js/pasatiempoAgrega.js"
38
39
  formulario.addEventListener("submit", pasatiempoAgrega)
40
41
 </script>
42
43
</body>
44
45
</html>

I. modifica.html

1
<!DOCTYPE html>
2
<html lang="es">
3
4
<head>
5
6
 <meta charset="UTF-8">
7
 <meta name="viewport" content="width=device-width">
8
9
 <title>Modificar</title>
10
11
 <script type="module" src="js/lib/registraServiceWorker.js"></script>
12
 <script type="module" src="js/lib/manejaErrores.js"></script>
13
14
</head>
15
16
<body>
17
18
 <form id="formulario">
19
20
  <h1>Modificar</h1>
21
22
  <p><a href="index.html">Cancelar</a></p>
23
24
  <p>
25
   <label>
26
    Nombre *
27
    <input name="nombre" value="Cargando…">
28
   </label>
29
  </p>
30
31
  <p>* Obligatorio</p>
32
33
  <p>
34
35
   <button type="submit">Guardar</button>
36
37
   <button id="botonEliminar" type="button">
38
    Eliminar
39
   </button>
40
41
  </p>
42
43
 </form>
44
45
 <script type="module">
46
47
  import {
48
   validaEntidadObligatoria
49
  } from "./js/lib/validaEntidadObligatoria.js"
50
  import { muestraObjeto } from "./js/lib/muestraObjeto.js"
51
  import { pasatiempoBusca } from "./js/pasatiempoBusca.js"
52
  import { pasatiempoElimina } from "./js/pasatiempoElimina.js"
53
  import { pasatiempoModifica } from "./js/pasatiempoModifica.js"
54
55
  const params = new URL(location.href).searchParams
56
  const id = params.get("id")
57
58
  descargaDatos()
59
60
  export async function descargaDatos() {
61
   if (id !== null && id !== "") {
62
    let modelo = await pasatiempoBusca(id)
63
    modelo = validaEntidadObligatoria("Pasatiempo", modelo)
64
    muestraObjeto(document, { nombre: { value: modelo.PAS_NOMBRE } })
65
    formulario
66
     .addEventListener("submit", event => pasatiempoModifica(event, id))
67
    botonEliminar.addEventListener("click", () => pasatiempoElimina(id))
68
   }
69
  }
70
71
 </script>
72
73
</body>
74
75
</html>

J. instruccionesListadoSw.txt

1
Generar el listado de archivos del sw.js desde Visual Studio Code.
2
1. Abrir una terminal desde el menú 
3
    Terminal > New Terminal
4
5
2. Desde la terminal introducir la orden:
6
    Get-ChildItem -path . -Recurse | Select Directory,Name | Out-File archivos.txt
7
8
3. Abrir el archivo generado, que se llama
9
    archivos.txt
10
   y sobre este, realizar los pasos que siguen:
11
12
4. Quita del archivo archivos.txt:
13
    * el encabezado,
14
    * todas las carpetas,
15
    * todos los archivos dentro de .vscode como:
16
       * el archivo .vscode/settings.json,
17
       * el archivo .vscode/launch.json,
18
    * el archivo .htaccess,
19
    * el archivo archivos.txt,
20
    * este archivo (instruccionesListadoSw.txt),
21
    * el archivo jsconfig.json,
22
    * el archivo sw.js,
23
    * el archivo de la base de datos, que termina en ".db" y
24
      está en la carpeta php,
25
    * todos los archivos de php y
26
    * las líneas en blanco del final
27
28
5. Cambia los \ por / desde Visual Studio Code con las siguientes
29
   combinaciones de teclas:
30
31
    Ctrl+H En el diálogo que aparece introduce lo siguiente:
32
    Find:\
33
    Replace:/
34
35
    Clic en el icono Reemplaza todo o Replace All y luego teclea ESC
36
 
37
6. Coloca las comillas y coma del final de cada línea desde Visual
38
   Studio Code con las siguientes combinaciones de teclas:
39
40
    Ctrl+H En el diálogo que aparece, selecciona el botón
41
            ".*"
42
           e introduce lo siguiente:
43
    Find:\s*$
44
    Replace:",
45
46
    Clic en el icono Reemplaza todo o Replace All y luego teclea ESC
47
48
7. Marca la carpeta inicial, presiona la combinación de teclas:
49
50
    Shift+Ctrl+L
51
52
    borra la selección, teclea " y luego ESC
53
54
8. Cambia las secuencias de espacios por / con las siguientes
55
   combinaciones de teclas:
56
57
    Ctrl+H En el diálogo que aparece, selecciona el botón
58
            ".*"
59
           e introduce lo siguiente:
60
    Find:\s+
61
    Replace:/
62
63
    Clic en el icono Reemplaza todo o Replace All y luego teclea ESC
64
65
9. Cambia las "/ por " con las siguientes combinaciones de teclas:
66
67
    Ctrl+H En el diálogo que aparece, quita la selección del botón
68
            ".*"
69
           e introduce lo siguiente:
70
    Find:"/
71
    Replace:"
72
73
    Clic en el icono Reemplaza todo o Replace All y luego teclea ESC
74
75
10. Copia el texto al archivo
76
     sw.js
77
    en el contenido del arreglo llamado ARCHIVOS, pero recuerda
78
    mantener el último elemento, que dice:
79
     "/"

K. archivos.txt

1
"agrega.html",
2
"index.html",
3
"modifica.html",
4
"errors/datosnojson.html",
5
"errors/eliminadoincorrecto.html",
6
"errors/errorinterno.html",
7
"errors/idincorrecto.html",
8
"errors/modificacionincorrecta.html",
9
"errors/nombreincorrecto.html",
10
"errors/resultadonojson.html",
11
"js/Bd.js",
12
"js/esperaUnPocoYSincroniza.js",
13
"js/PASATIEMPO.js",
14
"js/pasatiempoAgrega.js",
15
"js/pasatiempoBusca.js",
16
"js/pasatiempoConsultaNoEliminados.js",
17
"js/pasatiempoConsultaTodos.js",
18
"js/pasatiempoElimina.js",
19
"js/pasatiempoModifica.js",
20
"js/pasatiemposReemplaza.js",
21
"js/renderiza.js",
22
"js/sincroniza.js",
23
"js/validaPasatiempo.js",
24
"js/validaPasatiempos.js",
25
"js/lib/bdConsulta.js",
26
"js/lib/bdEjecuta.js",
27
"js/lib/consume.js",
28
"js/lib/creaIdCliente.js",
29
"js/lib/enviaJsonRecibeJson.js",
30
"js/lib/htmlentities.js",
31
"js/lib/manejaErrores.js",
32
"js/lib/muestraError.js",
33
"js/lib/muestraObjeto.js",
34
"js/lib/ProblemDetailsError.js",
35
"js/lib/recibeTexto.js",
36
"js/lib/recibeTextoObligatorio.js",
37
"js/lib/registraServiceWorker.js",
38
"js/lib/validaEntidadObligatoria.js",

L. sw.js

1
/* Este archivo debe estar colocado en la carpeta raíz del sitio.
2
 * 
3
 * Cualquier cambio en el contenido de este archivo hace que el service
4
 * worker se reinstale. */
5
6
/**
7
 * Cambia el número de la versión cuando cambia el contenido de los
8
 * archivos.
9
 * 
10
 * El número a la izquierda del punto (.), en este caso <q>1</q>, se
11
 * conoce como número mayor y se cambia cuando se realizan
12
 * modificaciones grandes o importantes.
13
 * 
14
 * El número a la derecha del punto (.), en este caso <q>00</q>, se
15
 * conoce como número menor y se cambia cuando se realizan
16
 * modificaciones menores.
17
 */
18
const VERSION = "1.00"
19
20
/**
21
 * Nombre de la carpeta de caché.
22
 */
23
const CACHE = "sincro"
24
25
/**
26
 * Archivos requeridos para que la aplicación funcione fuera de línea.
27
 */
28
const ARCHIVOS = [
29
 "agrega.html",
30
 "index.html",
31
 "modifica.html",
32
 "errors/datosnojson.html",
33
 "errors/eliminadoincorrecto.html",
34
 "errors/errorinterno.html",
35
 "errors/idincorrecto.html",
36
 "errors/modificacionincorrecta.html",
37
 "errors/nombreincorrecto.html",
38
 "errors/resultadonojson.html",
39
 "js/Bd.js",
40
 "js/esperaUnPocoYSincroniza.js",
41
 "js/PASATIEMPO.js",
42
 "js/pasatiempoAgrega.js",
43
 "js/pasatiempoBusca.js",
44
 "js/pasatiempoConsultaNoEliminados.js",
45
 "js/pasatiempoConsultaTodos.js",
46
 "js/pasatiempoElimina.js",
47
 "js/pasatiempoModifica.js",
48
 "js/pasatiemposReemplaza.js",
49
 "js/renderiza.js",
50
 "js/sincroniza.js",
51
 "js/validaPasatiempo.js",
52
 "js/validaPasatiempos.js",
53
 "js/lib/bdConsulta.js",
54
 "js/lib/bdEjecuta.js",
55
 "js/lib/consume.js",
56
 "js/lib/creaIdCliente.js",
57
 "js/lib/enviaJsonRecibeJson.js",
58
 "js/lib/htmlentities.js",
59
 "js/lib/manejaErrores.js",
60
 "js/lib/muestraError.js",
61
 "js/lib/muestraObjeto.js",
62
 "js/lib/ProblemDetailsError.js",
63
 "js/lib/recibeTexto.js",
64
 "js/lib/recibeTextoObligatorio.js",
65
 "js/lib/registraServiceWorker.js",
66
 "js/lib/validaEntidadObligatoria.js",
67
 "/"
68
]
69
70
// Verifica si el código corre dentro de un service worker.
71
if (self instanceof ServiceWorkerGlobalScope) {
72
 // Evento al empezar a instalar el servide worker,
73
 self.addEventListener("install",
74
  (/** @type {ExtendableEvent} */ evt) => {
75
   console.log("El service worker se está instalando.")
76
   evt.waitUntil(llenaElCache())
77
  })
78
79
 // Evento al solicitar información a la red.
80
 self.addEventListener("fetch", (/** @type {FetchEvent} */ evt) => {
81
  if (evt.request.method === "GET") {
82
   evt.respondWith(buscaLaRespuestaEnElCache(evt))
83
  }
84
 })
85
86
 // Evento cuando el service worker se vuelve activo.
87
 self.addEventListener("activate",
88
  () => console.log("El service worker está activo."))
89
}
90
91
async function llenaElCache() {
92
 console.log("Intentando cargar caché:", CACHE)
93
 // Borra todos los cachés.
94
 const keys = await caches.keys()
95
 for (const key of keys) {
96
  await caches.delete(key)
97
 }
98
 // Abre el caché de este service worker.
99
 const cache = await caches.open(CACHE)
100
 // Carga el listado de ARCHIVOS.
101
 await cache.addAll(ARCHIVOS)
102
 console.log("Cache cargado:", CACHE)
103
 console.log("Versión:", VERSION)
104
}
105
106
/** @param {FetchEvent} evt */
107
async function buscaLaRespuestaEnElCache(evt) {
108
 // Abre el caché.
109
 const cache = await caches.open(CACHE)
110
 const request = evt.request
111
 /* Busca la respuesta a la solicitud en el contenido del caché, sin
112
  * tomar en cuenta la parte después del símbolo "?" en la URL. */
113
 const response = await cache.match(request, { ignoreSearch: true })
114
 if (response === undefined) {
115
  /* Si no la encuentra, empieza a descargar de la red y devuelve
116
   * la promesa. */
117
  return fetch(request)
118
 } else {
119
  // Si la encuentra, devuelve la respuesta encontrada en el caché.
120
  return response
121
 }
122
}

M. Carpeta « js »

Versión para imprimir.

A. js / Bd.js

1
export const ALMACEN_PASATIEMPO = "PASATIEMPO"
2
export const PAS_ID = "PAS_ID"
3
export const INDICE_NOMBRE = "INDICE_NOMBRE"
4
export const PAS_NOMBRE = "PAS_NOMBRE"
5
const BD_NOMBRE = "sincro"
6
const BD_VERSION = 1
7
8
/** @type { Promise<IDBDatabase> } */
9
export const Bd = new Promise((resolve, reject) => {
10
11
 /* Se solicita abrir la base de datos, indicando nombre y
12
  * número de versión. */
13
 const solicitud = indexedDB.open(BD_NOMBRE, BD_VERSION)
14
15
 // Si se presenta un error, rechaza la promesa.
16
 solicitud.onerror = () => reject(solicitud.error)
17
18
 // Si se abre con éxito, devuelve una conexión a la base de datos.
19
 solicitud.onsuccess = () => resolve(solicitud.result)
20
21
 // Si es necesario, se inicia una transacción para cambio de versión.
22
 solicitud.onupgradeneeded = () => {
23
24
  const bd = solicitud.result
25
26
  // Como hay cambio de versión, borra el almacén si es que existe.
27
  if (bd.objectStoreNames.contains(ALMACEN_PASATIEMPO)) {
28
   bd.deleteObjectStore(ALMACEN_PASATIEMPO)
29
  }
30
31
  // Crea el almacén "PASATIEMPO" con el campo llave "PAS_ID".
32
  const almacenPasatiempo =
33
   bd.createObjectStore(ALMACEN_PASATIEMPO, { keyPath: PAS_ID })
34
35
  // Crea un índice ordenado por el campo PAS_NOMBRE que no acepta duplicados.
36
  almacenPasatiempo.createIndex(INDICE_NOMBRE, PAS_NOMBRE)
37
 }
38
39
})

B. js / esperaUnPocoYSincroniza.js

1
import { sincroniza } from "./sincroniza.js"
2
3
/**
4
 * Cada 20 segundos (2000 milisegundos) después de la última
5
 * sincronización, los datos se envían al servidor para volver a
6
 * sincronizarse con los datos del servidor.
7
 */
8
const MILISEGUNDOS_PARA_VOLVER_A_SINCRONIZAR = 20000
9
10
export function esperaUnPocoYSincroniza() {
11
 setTimeout(() => sincroniza(), MILISEGUNDOS_PARA_VOLVER_A_SINCRONIZAR)
12
}

C. js / PASATIEMPO.js

1
/**
2
 * @typedef {Object} PASATIEMPO
3
 * @property {string} PAS_ID
4
 * @property {string} PAS_NOMBRE
5
 * @property {number} PAS_MODIFICACION
6
 * @property {number} PAS_ELIMINADO
7
 */

D. js / pasatiempoAgrega.js

1
import { ALMACEN_PASATIEMPO, Bd } from "./Bd.js"
2
import { bdEjecuta } from "./lib/bdEjecuta.js"
3
import { creaIdCliente } from "./lib/creaIdCliente.js"
4
import { recibeTextoObligatorio } from "./lib/recibeTextoObligatorio.js"
5
6
7
/**
8
 * @param {SubmitEvent} event
9
 */
10
export async function pasatiempoAgrega(event) {
11
12
 event.preventDefault()
13
 const target = event.target
14
15
 if (!(target instanceof HTMLFormElement))
16
  throw new Error("target no es de tipo form.")
17
18
 const formData = new FormData(target)
19
20
 const modelo = {
21
  PAS_ID: creaIdCliente(Date.now().toString()), // Genera id único en internet.
22
  PAS_NOMBRE: recibeTextoObligatorio(formData, "nombre"),
23
  PAS_MODIFICACION: Date.now(),
24
  PAS_ELIMINADO: 0,
25
 }
26
27
 await bdEjecuta(Bd, [ALMACEN_PASATIEMPO], transaccion => {
28
  const almacenPasatiempo = transaccion.objectStore(ALMACEN_PASATIEMPO)
29
  almacenPasatiempo.add(modelo)
30
 })
31
32
 location.href = "index.html"
33
34
}

E. js / pasatiempoBusca.js

1
import { bdConsulta } from "./lib/bdConsulta.js"
2
import { validaPasatiempo } from "./validaPasatiempo.js"
3
import { ALMACEN_PASATIEMPO, Bd } from "./Bd.js"
4
5
/**
6
 * @param {string} id
7
 */
8
export async function pasatiempoBusca(id) {
9
10
 return bdConsulta(Bd, [ALMACEN_PASATIEMPO],
11
  /**
12
   * @param {(resultado: import("./PASATIEMPO.js").PASATIEMPO|undefined)
13
   *                                                            => any} resolve 
14
   */
15
  (transaccion, resolve) => {
16
17
   /* Pide el primer objeto de ALMACEN_PASATIEMPO que tenga como llave
18
    * primaria el valor del parámetro id. */
19
   const consulta = transaccion.objectStore(ALMACEN_PASATIEMPO).get(id)
20
21
   // onsuccess se invoca solo una vez, devolviendo el objeto solicitado.
22
   consulta.onsuccess = () => {
23
    /* Se recupera el objeto solicitado usando
24
     *  consulta.result
25
     * Si el objeto no se encuentra se recupera undefined. */
26
    const objeto = consulta.result
27
    if (objeto !== undefined) {
28
     const modelo = validaPasatiempo(objeto)
29
     if (modelo.PAS_ELIMINADO === 0) {
30
      resolve(modelo)
31
      return
32
     }
33
    }
34
    resolve(undefined)
35
36
   }
37
38
  })
39
40
}

F. js / pasatiempoConsultaNoEliminados.js

1
import { ALMACEN_PASATIEMPO, Bd, INDICE_NOMBRE } from "./Bd.js"
2
import { bdConsulta } from "./lib/bdConsulta.js"
3
import { validaPasatiempo } from "./validaPasatiempo.js"
4
5
export async function pasatiempoConsultaNoEliminados() {
6
7
 return bdConsulta(Bd, [ALMACEN_PASATIEMPO],
8
  /**
9
   * @param {(resultado: import("./PASATIEMPO.js").PASATIEMPO[])=>void
10
   *                                                                  } resolve
11
   */
12
  (transaccion, resolve) => {
13
14
   const resultado = []
15
16
   const almacenPasatiempo = transaccion.objectStore(ALMACEN_PASATIEMPO)
17
18
   // Usa el índice INDICE_NOMBRE para recuperar los datos ordenados.
19
   const indiceNombre = almacenPasatiempo.index(INDICE_NOMBRE)
20
21
   // Pide un cursor para recorrer cada objeto que devuelve la consulta.
22
   const consulta = indiceNombre.openCursor()
23
24
   /* onsuccess se invoca por cada uno de los objetos de la consulta y una vez
25
    * cuando se acaban dichos objetos. */
26
   consulta.onsuccess = () => {
27
    /* El cursor correspondiente al objeto se recupera usando
28
     *  consulta.result */
29
    const cursor = consulta.result
30
    if (cursor === null) {
31
     /* Si el cursor vale null, ya no hay más objetos que procesar; por lo
32
      * mismo, se devuelve el resultado con los pasatiempos recuperados, usando
33
      *  resolve(resultado). */
34
     resolve(resultado)
35
    } else {
36
     /* Si el cursor no vale null y hay más objetos, el siguiente se obtiene con
37
      *  cursor.value */
38
     const modelo = validaPasatiempo(cursor.value)
39
     if (modelo.PAS_ELIMINADO === 0) {
40
      resultado.push(modelo)
41
     }
42
     /* Busca el siguiente objeto de la consulta, que se recupera la siguiente
43
      * vez que se invoque la función onsuccess. */
44
     cursor.continue()
45
    }
46
   }
47
48
  })
49
50
}

G. js / pasatiempoConsultaTodos.js

1
import { bdConsulta } from "./lib/bdConsulta.js"
2
import { validaPasatiempo } from "./validaPasatiempo.js"
3
import { ALMACEN_PASATIEMPO, Bd } from "./Bd.js"
4
5
/**
6
 * Lista todos los objetos, incluyendo los que tienen borrado lógico.
7
 */
8
export async function pasatiempoConsultaTodos() {
9
10
 return bdConsulta(Bd, [ALMACEN_PASATIEMPO],
11
  /**
12
   * @param {(resultado: import("./PASATIEMPO.js").PASATIEMPO[])=>void
13
   *                                                                  } resolve
14
   */
15
  (transaccion, resolve) => {
16
17
   const resultado = []
18
19
   // Pide un cursor para recorrer cada objeto que devuelve la consulta.
20
   const consulta = transaccion.objectStore(ALMACEN_PASATIEMPO).openCursor()
21
22
   /* onsuccess se invoca por cada uno de los objetos de la consulta y una vez
23
    * cuando se acaban dichos objetos. */
24
   consulta.onsuccess = () => {
25
    /* El cursor correspondiente al objeto se recupera usando
26
     *  consulta.result */
27
    const cursor = consulta.result
28
    if (cursor === null) {
29
     /* Si el cursor vale null, ya no hay más objetos que procesar; por lo
30
      * mismo, se devuelve el resultado con los pasatiempos recuperados, usando
31
      *  resolve(resultado). */
32
     resolve(resultado)
33
    } else {
34
     /* Si el cursor no vale null y hay más objetos, el siguiente se obtiene con
35
      *  cursor.value*/
36
     resultado.push(validaPasatiempo(cursor.value))
37
     /* Busca el siguiente objeto de la consulta, que se recupera la siguiente
38
      * vez que se invoque la función onsuccess. */
39
     cursor.continue()
40
    }
41
   }
42
43
  })
44
45
}

H. js / pasatiempoElimina.js

1
import { ALMACEN_PASATIEMPO, Bd } from "./Bd.js"
2
import { bdEjecuta } from "./lib/bdEjecuta.js"
3
import { pasatiempoBusca } from "./pasatiempoBusca.js"
4
5
/**
6
 * @param { string } id
7
 */
8
export async function pasatiempoElimina(id) {
9
10
 if (confirm('Confirma la eliminación')) {
11
12
  const modelo = await pasatiempoBusca(id)
13
14
  if (modelo !== undefined) {
15
16
   modelo.PAS_MODIFICACION = Date.now()
17
   modelo.PAS_ELIMINADO = 1
18
   await bdEjecuta(Bd, [ALMACEN_PASATIEMPO], transaccion => {
19
    const almacenPasatiempo = transaccion.objectStore(ALMACEN_PASATIEMPO)
20
    almacenPasatiempo.put(modelo)
21
   })
22
23
  }
24
25
  location.href = "index.html"
26
27
 }
28
29
}

I. js / pasatiempoModifica.js

1
import { ALMACEN_PASATIEMPO, Bd } from "./Bd.js"
2
import { bdEjecuta } from "./lib/bdEjecuta.js"
3
import { recibeTextoObligatorio } from "./lib/recibeTextoObligatorio.js"
4
import { pasatiempoBusca } from "./pasatiempoBusca.js"
5
6
/**
7
 * @param {SubmitEvent} event
8
 * @param {string} id
9
 */
10
export async function pasatiempoModifica(event, id) {
11
12
 event.preventDefault()
13
 const target = event.target
14
15
 if (!(target instanceof HTMLFormElement))
16
  throw new Error("target no es de tipo form.")
17
18
 const formData = new FormData(target)
19
20
 const nombre = recibeTextoObligatorio(formData, "nombre")
21
22
 const anterior = await pasatiempoBusca(id)
23
24
 if (anterior !== undefined) {
25
26
  anterior.PAS_NOMBRE = nombre
27
  anterior.PAS_MODIFICACION = Date.now()
28
29
  await bdEjecuta(Bd, [ALMACEN_PASATIEMPO], transaccion => {
30
   const almacenPasatiempo = transaccion.objectStore(ALMACEN_PASATIEMPO)
31
   almacenPasatiempo.put(anterior)
32
  })
33
34
 location.href = "index.html"
35
36
 }
37
38
}

J. js / pasatiemposReemplaza.js

1
import { bdEjecuta } from "./lib/bdEjecuta.js"
2
import { ALMACEN_PASATIEMPO, Bd } from "./Bd.js"
3
4
/**
5
 * Borra el contenido del almacén PASATIEMPO y guarda nuevospasatiempos.
6
 * @param {import("./PASATIEMPO.js").PASATIEMPO[]} nuevosPasatiempos
7
 */
8
export async function pasatiemposReemplaza(nuevosPasatiempos) {
9
 return bdEjecuta(Bd, [ALMACEN_PASATIEMPO], transaccion => {
10
  const almacenPasatiempo = transaccion.objectStore(ALMACEN_PASATIEMPO)
11
  almacenPasatiempo.clear()
12
  for (const objeto of nuevosPasatiempos) {
13
   almacenPasatiempo.add(objeto)
14
  }
15
 })
16
}

K. js / renderiza.js

1
import { htmlentities } from "./lib/htmlentities.js"
2
import { muestraObjeto } from "./lib/muestraObjeto.js"
3
4
/**
5
 * @param {import("./PASATIEMPO.js").PASATIEMPO[]} pasatiempos
6
 */
7
export function renderiza(pasatiempos) {
8
 let render = ""
9
 for (const modelo of pasatiempos) {
10
  const nombre = htmlentities(modelo.PAS_NOMBRE)
11
  const searchParams = new URLSearchParams([["id", modelo.PAS_ID]])
12
  const params = htmlentities(searchParams.toString())
13
  render += /* html */
14
   `<li>
15
     <p><a href="modifica.html?${params}">${nombre}</a></p>
16
    </li>`
17
 }
18
 muestraObjeto(
19
  document,
20
  {
21
   lista: { innerHTML: render }
22
  }
23
 )
24
}
25

L. js / sincroniza.js

1
import { pasatiempoConsultaTodos } from "./pasatiempoConsultaTodos.js"
2
import { pasatiemposReemplaza } from "./pasatiemposReemplaza.js"
3
import { esperaUnPocoYSincroniza } from "./esperaUnPocoYSincroniza.js"
4
import { consume } from "./lib/consume.js"
5
import { enviaJsonRecibeJson } from "./lib/enviaJsonRecibeJson.js"
6
import { muestraError } from "./lib/muestraError.js"
7
import { renderiza } from "./renderiza.js"
8
import { validaPasatiempos } from "./validaPasatiempos.js"
9
10
export async function sincroniza() {
11
12
 try {
13
14
  if (navigator.onLine) {
15
   const todos = await pasatiempoConsultaTodos()
16
   const respuesta =
17
    await consume(enviaJsonRecibeJson("php/sincroniza.php", todos))
18
   const pasatiempos = validaPasatiempos(await respuesta.json())
19
   await pasatiemposReemplaza(pasatiempos)
20
   renderiza(pasatiempos)
21
  }
22
23
 } catch (error) {
24
25
  muestraError(error)
26
27
 }
28
29
 esperaUnPocoYSincroniza()
30
31
}

M. js / validaPasatiempo.js

1
/**
2
 * @param { any } objeto
3
 * @returns {import("./PASATIEMPO.js").PASATIEMPO}
4
 */
5
export function validaPasatiempo(objeto) {
6
7
 if (typeof objeto.PAS_ELIMINADO !== "number" || isNaN(objeto.PAS_ELIMINADO))
8
  throw new Error("El campo eliminado debe ser número.")
9
10
 if (typeof objeto.PAS_ID !== "string" || objeto.PAS_ID === "")
11
  throw new Error("El id debe ser texto que no esté en blanco.")
12
13
 if (
14
  typeof objeto.PAS_MODIFICACION !== "number" || isNaN(objeto.PAS_MODIFICACION)
15
 )
16
  throw new Error("El campo modificacion debe ser número.")
17
18
 if (typeof objeto.PAS_NOMBRE !== "string" || objeto.PAS_ID === "")
19
  throw new Error("El nombre debe ser texto que no esté en blanco.")
20
21
 return objeto
22
23
}

N. js / validaPasatiempos.js

1
import { validaPasatiempo } from "./validaPasatiempo.js"
2
3
/**
4
 * @param { any } objetos
5
 * @returns {import("./PASATIEMPO.js").PASATIEMPO[]}
6
 */
7
export function validaPasatiempos(objetos) {
8
 if (!Array.isArray(objetos))
9
  throw new Error("no se recibió un arreglo.")
10
 /**
11
  * @type {import("./PASATIEMPO.js").PASATIEMPO[]}
12
  */
13
 const arreglo = []
14
 for (const objeto of objetos) {
15
  arreglo.push(validaPasatiempo(objeto))
16
 }
17
 return arreglo
18
}

O. Carpeta « js / lib »

1. js / lib / bdConsulta.js

1
/**
2
 * @template T
3
 * @param {Promise<IDBDatabase>} bd
4
 * @param {string[]} almacenes
5
 * @param {(transaccion: IDBTransaction, resolve: (resultado:T)=>void) => any
6
 *                                                                   } consulta
7
 * @returns {Promise<T>}
8
 */
9
export async function bdConsulta(bd, almacenes, consulta) {
10
11
 const base = await bd
12
13
 return new Promise((resolve, reject) => {
14
  // Inicia una transacción de solo lectura.
15
  const transaccion = base.transaction(almacenes, "readonly")
16
  // Al terminar con error ejecuta la función reject.
17
  transaccion.onerror = () => reject(transaccion.error)
18
  // Estas son las operaciones para realizar la consulta.
19
  consulta(transaccion, resolve)
20
 })
21
22
}

2. js / lib / bdEjecuta.js

1
/**
2
 * @param {Promise<IDBDatabase>} bd
3
 * @param {string[]} entidades
4
 * @param {(t:IDBTransaction) => void} operaciones
5
 */
6
export async function bdEjecuta(bd, entidades, operaciones) {
7
8
 // Espera que se abra la bd
9
 const base = await bd
10
11
 return new Promise(
12
  (resolve, reject) => {
13
   // Inicia una transacción de lectura y escritura.
14
   const transaccion = base.transaction(entidades, "readwrite")
15
   // Al terminar con éxito, ejecuta la función resolve.
16
   transaccion.oncomplete = resolve
17
   // Al terminar con error, ejecuta la función reject.
18
   transaccion.onerror = () => reject(transaccion.error)
19
   // Estas son las operaciones de la transacción.
20
   operaciones(transaccion)
21
  })
22
23
}

3. js / lib / consume.js

1
import { ProblemDetailsError } from "./ProblemDetailsError.js"
2
3
/**
4
 * Espera a que la promesa de un fetch termine. Si
5
 * hay error, lanza una excepción.
6
 * 
7
 * @param {Promise<Response> } servicio
8
 */
9
export async function consume(servicio) {
10
 const respuesta = await servicio
11
 if (respuesta.ok) {
12
  return respuesta
13
 } else {
14
  const contentType = respuesta.headers.get("Content-Type")
15
  if (
16
   contentType !== null && contentType.startsWith("application/problem+json")
17
  )
18
   throw new ProblemDetailsError(await respuesta.json())
19
  else
20
   throw new Error(respuesta.statusText)
21
 }
22
}

4. js / lib / creaIdCliente.js

1
/**
2
 * Añade caracteres al azar a una raíz, para obtener un clientId único.
3
 * @param {string} raiz 
4
 */
5
export function creaIdCliente(raiz) {
6
 const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
7
 for (var i = 0; i < 15; i++) {
8
  raiz += chars.charAt(Math.floor(Math.random() * chars.length))
9
 }
10
 return raiz
11
}

5. js / lib / enviaJsonRecibeJson.js

1
2
/**
3
 * @param { string } url
4
 * @param { Object } body
5
 * @param { "GET" | "POST"| "PUT" | "PATCH" | "DELETE" | "TRACE" | "OPTIONS"
6
 *  | "CONNECT" | "HEAD" } metodoHttp
7
 */
8
export async function enviaJsonRecibeJson(url, body, metodoHttp = "POST") {
9
 return fetch(
10
  url,
11
  {
12
   method: metodoHttp,
13
   headers: {
14
    "Content-Type": "application/json",
15
    "Accept": "application/json, application/problem+json"
16
   },
17
   body: JSON.stringify(body)
18
  }
19
 )
20
}

6. js / lib / htmlentities.js

1
/**
2
 * Codifica un texto para que cambie los caracteres
3
 * especiales y no se pueda interpretar como
4
 * etiiqueta HTML. Esta técnica evita la inyección
5
 * de código.
6
 * @param { string } texto
7
*/
8
export function htmlentities(texto) {
9
 return texto.replace(/[<>"']/g, textoDetectado => {
10
  switch (textoDetectado) {
11
   case "<": return "<"
12
   case ">": return ">"
13
   case '"': return """
14
   case "'": return "'"
15
   default: return textoDetectado
16
  }
17
 })
18
}
19

7. js / lib / manejaErrores.js

1
import { muestraError } from "./muestraError.js"
2
3
/**
4
 * Intercepta Response.prototype.json para capturar errores de parseo
5
 * y asegurar que se reporten correctamente en navegadores Chromium.
6
 */
7
{
8
 const originalJson = Response.prototype.json
9
10
 Response.prototype.json = function () {
11
  // Llamamos al método original usando el contexto (this) de la respuesta
12
  return originalJson.apply(this, arguments)
13
   .catch((/** @type {any} */ error) => {
14
    // Corrige un error de Chrome que evita el manejo correcto de errores.
15
    throw new Error(error)
16
   })
17
 }
18
}
19
20
window.onerror = function (
21
  /** @type {string} */ _message,
22
  /** @type {string} */ _url,
23
  /** @type {number} */ _line,
24
  /** @type {number} */ _column,
25
  /** @type {Error} */ errorObject
26
) {
27
 muestraError(errorObject)
28
 return true
29
}
30
31
window.addEventListener('unhandledrejection', event => {
32
 muestraError(event.reason)
33
 event.preventDefault()
34
})
35

8. js / lib / muestraError.js

1
import { ProblemDetailsError } from "./ProblemDetailsError.js"
2
3
/**
4
 * Muestra los datos de una Error en la consola y en un cuadro de alerta.
5
 * @param { ProblemDetailsError | Error | null } error descripción del error.
6
 */
7
export function muestraError(error) {
8
9
 if (error === null) {
10
11
  console.error("Error")
12
  alert("Error")
13
14
 } else if (error instanceof ProblemDetailsError) {
15
16
  const problemDetails = error.problemDetails
17
18
  let mensaje =
19
   typeof problemDetails["title"] === "string" ? problemDetails["title"] : ""
20
  if (typeof problemDetails["detail"] === "string") {
21
   if (mensaje !== "") {
22
    mensaje += "\n\n"
23
   }
24
   mensaje += problemDetails["detail"]
25
  }
26
  if (mensaje === "") {
27
   mensaje = "Error"
28
  }
29
  console.error(error, problemDetails)
30
  alert(mensaje)
31
32
 } else {
33
34
  console.error(error)
35
  alert(error.message)
36
37
 }
38
39
}

9. js / lib / muestraObjeto.js

1
/**
2
 * @param {Document | HTMLElement | ShadowRoot} raizHtml
3
 * @param { any } objeto
4
 */
5
export function muestraObjeto(raizHtml, objeto) {
6
 for (const [nombre, definiciones] of Object.entries(objeto)) {
7
  if (Array.isArray(definiciones)) {
8
   muestraArray(raizHtml, nombre, definiciones)
9
  } else if (definiciones !== undefined && definiciones !== null) {
10
   muestraElemento(raizHtml, nombre, definiciones)
11
  }
12
 }
13
}
14
15
/**
16
 * @param { string } nombre
17
 */
18
export function selectorDeNombre(nombre) {
19
 return `[id="${nombre}"],[name="${nombre}"],[data-name="${nombre}"]`
20
}
21
22
/**
23
 * @param { Document | HTMLElement | ShadowRoot } raizHtml
24
 * @param { string } propiedad
25
 * @param {any[]} valores
26
 */
27
function muestraArray(raizHtml, propiedad, valores) {
28
 const conjunto = new Set(valores)
29
 const elementos = raizHtml.querySelectorAll(selectorDeNombre(propiedad))
30
 if (elementos.length === 1 && elementos[0] instanceof HTMLSelectElement) {
31
  muestraOptions(elementos[0], conjunto)
32
 } else {
33
  muestraInputs(elementos, conjunto)
34
 }
35
36
}
37
38
/**
39
 * @param {HTMLSelectElement} select
40
 * @param {Set<any>} conjunto
41
 */
42
function muestraOptions(select, conjunto) {
43
 for (let i = 0, options = select.options, len = options.length; i < len; i++) {
44
  const option = options[i]
45
  option.selected = conjunto.has(option.value)
46
 }
47
}
48
49
/**
50
 * @param {NodeListOf<Element>} elementos
51
 * @param {Set<any>} conjunto
52
 */
53
function muestraInputs(elementos, conjunto) {
54
 for (let i = 0, len = elementos.length; i < len; i++) {
55
  const elemento = elementos[i]
56
  if (elemento instanceof HTMLInputElement) {
57
   elemento.checked = conjunto.has(elemento.value)
58
  }
59
 }
60
}
61
62
const data_ = "data-"
63
const data_Length = data_.length
64
65
/**
66
 * @param {Document | HTMLElement | ShadowRoot} raizHtml
67
 * @param {string} nombre
68
 * @param {{ [s: string]: any; } } definiciones
69
 */
70
function muestraElemento(raizHtml, nombre, definiciones) {
71
 const elemento = raizHtml.querySelector(selectorDeNombre(nombre))
72
 if (elemento !== null) {
73
  for (const [propiedad, valor] of Object.entries(definiciones)) {
74
   if (propiedad in elemento) {
75
    elemento[propiedad] = valor
76
   } else if (
77
    propiedad.length > data_Length
78
    && propiedad.startsWith(data_)
79
    && elemento instanceof HTMLElement
80
   ) {
81
    elemento.dataset[propiedad.substring(data_Length)] = valor
82
   }
83
  }
84
 }
85
}

10. js / lib / ProblemDetailsError.js

1
export class ProblemDetailsError extends Error {
2
3
 /**
4
  * Detalle de los errores devueltos por un servicio.
5
  * Crea una instancia de ProblemDetailsError.
6
  * @param {object} problemDetails Objeto con la descripcipon del error.
7
  */
8
 constructor(problemDetails) {
9
10
  super(typeof problemDetails["detail"] === "string"
11
   ? problemDetails["detail"]
12
   : (typeof problemDetails["title"] === "string"
13
    ? problemDetails["title"]
14
    : "Error"))
15
16
  this.problemDetails = problemDetails
17
18
 }
19
20
}

11. js / lib / recibeTexto.js

1
/**
2
 * @param {FormData} formData
3
 * @param {string} parametro
4
 */
5
export function recibeTexto(formData, parametro) {
6
 const valor = formData.get(parametro)
7
 if (valor !== null && typeof valor !== "string")
8
  throw new Error(`El valor de ${parametro} debe ser texto.`)
9
 return valor === null ? undefined : valor
10
}
11

12. js / lib / recibeTextoObligatorio.js

1
import { recibeTexto } from "./recibeTexto.js"
2
3
/**
4
 * @param {FormData} formData
5
 * @param {string} parametro
6
 */
7
export function recibeTextoObligatorio(formData, parametro) {
8
 const texto = recibeTexto(formData, parametro)
9
 if (texto === undefined) throw new Error(`Falta el valor de ${parametro}.`)
10
 const trimTexto = texto.trim()
11
 if (trimTexto === "") throw new Error(`Campo ${parametro} en blanco.`)
12
 return trimTexto
13
}
14

13. js / lib / registraServiceWorker.js

1
const nombreDeServiceWorker = "sw.js"
2
3
try {
4
 navigator.serviceWorker.register(nombreDeServiceWorker)
5
  .then(registro => {
6
   console.log(nombreDeServiceWorker, "registrado.")
7
   console.log(registro)
8
  })
9
  .catch(error => console.log(error))
10
} catch (error) {
11
 console.log(error)
12
}

14. js / lib / validaEntidadObligatoria.js

1
/**
2
 * @template T
3
 * @param {string} nombre
4
 * @param {T | undefined} entidad
5
 */
6
export function validaEntidadObligatoria(nombre, entidad) {
7
8
 if (entidad === undefined)
9
  throw new Error(`Registro de ${nombre} no encontrado.`)
10
11
 return entidad
12
}
13

N. Carpeta « php »

Versión para imprimir.

A. php / Bd.php

1
<?php
2
3
class Bd
4
{
5
6
 private static ?PDO $pdo = null;
7
8
 static function pdo(): PDO
9
 {
10
  if (self::$pdo === null) {
11
   self::$pdo = new PDO(
12
    // cadena de conexión
13
    "sqlite:" . __DIR__ . "/sincro.db",
14
    // usuario
15
    null,
16
    // contraseña
17
    null,
18
    // Opciones: pdos no persistentes y lanza excepciones.
19
    [PDO::ATTR_PERSISTENT => false, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
20
   );
21
22
   self::$pdo->exec(
23
    'CREATE TABLE IF NOT EXISTS PASATIEMPO (
24
      PAS_ID TEXT NOT NULL,
25
      PAS_NOMBRE TEXT NOT NULL,
26
      PAS_MODIFICACION INTEGER NOT NULL,
27
      PAS_ELIMINADO INTEGER NOT NULL,
28
      CONSTRAINT PAS_PK
29
       PRIMARY KEY(PAS_ID),
30
      CONSTRAINT PAS_ID_NV
31
       CHECK(LENGTH(PAS_ID) > 0),
32
      CONSTRAINT PAS_NOM_NV
33
       CHECK(LENGTH(PAS_NOMBRE) > 0)
34
     )'
35
   );
36
  }
37
38
  return self::$pdo;
39
 }
40
}
41

B. php / pasatiempoAgrega.php

1
<?php
2
3
require_once __DIR__ . "/Bd.php";
4
require_once __DIR__ . "/TABLA_PASATIEMPO.php";
5
6
/**
7
 * @param array{
8
 *   PAS_ID: string,
9
 *   PAS_NOMBRE: string,
10
 *   PAS_MODIFICACION: int,
11
 *   PAS_ELIMINADO: int
12
 *  } $modelo
13
 */
14
function pasatiempoAgrega(array $modelo)
15
{
16
 $bd = Bd::pdo();
17
 $stmt = $bd->prepare(
18
  "INSERT INTO PASATIEMPO (
19
    PAS_ID, PAS_NOMBRE, PAS_MODIFICACION, PAS_ELIMINADO
20
   ) values (
21
    :PAS_ID, :PAS_NOMBRE, :PAS_MODIFICACION, :PAS_ELIMINADO
22
   )"
23
 );
24
 $stmt->execute([
25
  ":PAS_ID" => $modelo[PAS_ID],
26
  ":PAS_NOMBRE" => $modelo[PAS_NOMBRE],
27
  ":PAS_MODIFICACION" => $modelo[PAS_MODIFICACION],
28
  ":PAS_ELIMINADO" => $modelo[PAS_ELIMINADO],
29
 ]);
30
}
31

C. php / pasatiempoBusca.php

1
<?php
2
3
require_once __DIR__ . "/Bd.php";
4
require_once __DIR__ . "/TABLA_PASATIEMPO.php";
5
6
/**
7
 * @return false | array{
8
 *   PAS_ID: string,
9
 *   PAS_NOMBRE: string,
10
 *   PAS_MODIFICACION: int,
11
 *   PAS_ELIMINADO: int
12
 *  }
13
 */
14
function pasatiempoBusca(string $id): false|array
15
{
16
 $bd = Bd::pdo();
17
 $stmt = $bd->prepare("SELECT * FROM PASATIEMPO WHERE PAS_ID = :PAS_ID");
18
 $stmt->execute([":PAS_ID" => $id]);
19
 $modelo = $stmt->fetch(PDO::FETCH_ASSOC);
20
 return $modelo;
21
}
22

D. php / pasatiempoConsultaNoEliminados.php

1
<?php
2
3
require_once __DIR__ . "/Bd.php";
4
require_once __DIR__ . "/TABLA_PASATIEMPO.php";
5
6
/**
7
 * @return array{
8
 *   PAS_ID: string,
9
 *   PAS_NOMBRE: string,
10
 *   PAS_MODIFICACION: int,
11
 *   PAS_ELIMINADO: int
12
 *  }[]
13
 */
14
function pasatiempoConsultaNoEliminados()
15
{
16
 $bd = Bd::pdo();
17
$stmt = $bd->query(
18
 "SELECT * FROM PASATIEMPO WHERE PAS_ELIMINADO = 0 ORDER BY PAS_NOMBRE"
19
);
20
$lista = $stmt->fetchAll(PDO::FETCH_ASSOC);
21
return $lista;
22
}
23

E. php / pasatiempoModifica.php

1
<?php
2
3
require_once __DIR__ . "/Bd.php";
4
require_once __DIR__ . "/TABLA_PASATIEMPO.php";
5
6
/**
7
 * @param array{
8
 *   PAS_ID: string,
9
 *   PAS_NOMBRE: string,
10
 *   PAS_MODIFICACION: int,
11
 *   PAS_ELIMINADO: int
12
 *  } $modelo
13
 */
14
function pasatiempoModifica(array $modelo)
15
{
16
 $bd = Bd::pdo();
17
 $stmt = $bd->prepare(
18
  "UPDATE PASATIEMPO
19
   SET
20
    PAS_NOMBRE = :PAS_NOMBRE,
21
    PAS_MODIFICACION = :PAS_MODIFICACION,
22
    PAS_ELIMINADO = :PAS_ELIMINADO
23
   WHERE
24
    PAS_ID = :PAS_ID"
25
 );
26
 $stmt->execute([
27
  ":PAS_ID" => $modelo[PAS_ID],
28
  ":PAS_NOMBRE" => $modelo[PAS_NOMBRE],
29
  ":PAS_MODIFICACION" => $modelo[PAS_MODIFICACION],
30
  ":PAS_ELIMINADO" => $modelo[PAS_ELIMINADO],
31
 ]);
32
}
33

F. php / sincroniza.php

1
<?php
2
3
require_once __DIR__ . "/lib/manejaErrores.php";
4
require_once __DIR__ . "/lib/recibeJson.php";
5
require_once __DIR__ . "/lib/devuelveJson.php";
6
require_once __DIR__ . "/TABLA_PASATIEMPO.php";
7
require_once __DIR__ . "/validaPasatiempo.php";
8
require_once __DIR__ . "/pasatiempoAgrega.php";
9
require_once __DIR__ . "/pasatiempoBusca.php";
10
require_once __DIR__ . "/pasatiempoConsultaNoEliminados.php";
11
require_once __DIR__ . "/pasatiempoModifica.php";
12
13
 $lista = recibeJson();
14
15
 if (!is_array($lista)) {
16
  $lista = [];
17
 }
18
19
 foreach ($lista as $modelo) {
20
  $modeloEnElCliente = validaPasatiempo($modelo);
21
  $modeloEnElServidor = pasatiempoBusca($modeloEnElCliente[PAS_ID]);
22
23
  if ($modeloEnElServidor === false) {
24
25
   /* CONFLICTO: El modelo no ha estado en el servidor.
26
    * AGREGARLO solamente si no está eliminado. */
27
   if ($modeloEnElCliente[PAS_ELIMINADO] === 0) {
28
    pasatiempoAgrega($modeloEnElCliente);
29
   }
30
  } elseif (
31
   $modeloEnElServidor[PAS_ELIMINADO] === 0
32
   && $modeloEnElCliente[PAS_ELIMINADO] === 1
33
  ) {
34
35
   /* CONFLICTO: El registro está en el servidor, donde no se ha eliminado, pero
36
    * ha sido eliminado en el cliente.
37
    * Gana el cliente, porque optamos por no revivir lo eliminado. */
38
   pasatiempoModifica($modeloEnElCliente);
39
  } else if (
40
   $modeloEnElCliente[PAS_ELIMINADO] === 0
41
   && $modeloEnElServidor[PAS_ELIMINADO] === 0
42
  ) {
43
44
   /* CONFLICTO: Registros en el servidor y en el cliente. Pueden ser
45
    * diferentes.
46
    * GANA FECHA MÁS GRANDE. Cuando gana el servidor, no se hace nada. */
47
   if (
48
    $modeloEnElCliente[PAS_MODIFICACION] >
49
    $modeloEnElServidor[PAS_MODIFICACION]
50
   ) {
51
    // La versión del cliente es más nueva y prevalece.
52
    pasatiempoModifica($modeloEnElCliente);
53
   }
54
  }
55
 }
56
57
 $lista = pasatiempoConsultaNoEliminados();
58
59
 devuelveJson($lista);
60

G. php / TABLA_PASATIEMPO.php

1
<?php
2
3
const PASATIEMPO = "PASATIEMPO";
4
const PAS_ID = "PAS_ID";
5
const PAS_NOMBRE = "PAS_NOMBRE";
6
const PAS_MODIFICACION = "PAS_MODIFICACION";
7
const PAS_ELIMINADO = "PAS_ELIMINADO";
8

H. php / validaPasatiempo.php

1
<?php
2
3
require_once __DIR__ . "/lib/BAD_REQUEST.php";
4
require_once __DIR__ . "/lib/ProblemDetailsException.php";
5
require_once __DIR__ . "/TABLA_PASATIEMPO.php";
6
7
function validaPasatiempo($objeto)
8
{
9
 if (!isset($objeto->PAS_ELIMINADO) || !is_int($objeto->PAS_ELIMINADO))
10
  throw new ProblemDetailsException([
11
   "status" => BAD_REQUEST,
12
   "title" => "El campo eliminado debe ser entero.",
13
   "type" => "/errors/eliminadoincorrecto.html",
14
  ]);
15
16
 if (
17
  !isset($objeto->PAS_ID)
18
  || !is_string($objeto->PAS_ID)
19
  || $objeto->PAS_ID === ""
20
 )
21
  throw new ProblemDetailsException([
22
   "status" => BAD_REQUEST,
23
   "title" => "El id debe ser texto que no esté en blanco.",
24
   "type" => "/errors/idincorrecto.html",
25
  ]);
26
27
 if (!isset($objeto->PAS_MODIFICACION) || !is_int($objeto->PAS_MODIFICACION))
28
  throw new ProblemDetailsException([
29
   "status" => BAD_REQUEST,
30
   "title" => "La modificacion debe ser número.",
31
   "type" => "/errors/modificacionincorrecta.html",
32
  ]);
33
34
 if (
35
  !isset($objeto->PAS_NOMBRE)
36
  || !is_string($objeto->PAS_NOMBRE)
37
  || $objeto->PAS_NOMBRE === ""
38
 )
39
  throw new ProblemDetailsException([
40
   "status" => BAD_REQUEST,
41
   "title" => "El nombre debe ser texto que no esté en blanco.",
42
   "type" => "/errors/nombreincorrecto.html",
43
  ]);
44
45
 return [
46
  PAS_ELIMINADO => $objeto->PAS_ELIMINADO,
47
  PAS_ID => $objeto->PAS_ID,
48
  PAS_NOMBRE => $objeto->PAS_NOMBRE,
49
  PAS_MODIFICACION => $objeto->PAS_MODIFICACION,
50
 ];
51
}
52

I. Carpeta « php / lib »

1. php / lib / BAD_REQUEST.php

1
<?php
2
3
const BAD_REQUEST = 400;
4

2. php / lib / devuelveJson.php

1
<?php
2
3
require_once __DIR__ . "/devuelveResultadoNoJson.php";
4
5
function devuelveJson($resultado)
6
{
7
 $json = json_encode($resultado);
8
 if ($json === false) {
9
  devuelveResultadoNoJson();
10
 } else {
11
  header("Content-Type: application/json; charset=utf-8");
12
  echo $json;
13
 }
14
 exit();
15
}
16

3. php / lib / devuelveResultadoNoJson.php

1
<?php
2
3
require_once __DIR__ . "/INTERNAL_SERVER_ERROR.php";
4
5
function devuelveResultadoNoJson()
6
{
7
 http_response_code(INTERNAL_SERVER_ERROR);
8
 header("Content-Type: application/problem+json; charset=utf-8");
9
10
 echo '{' .
11
  "status: " . INTERNAL_SERVER_ERROR .
12
  '"title": "El resultado no puede representarse como JSON."' .
13
  '"type": "/errors/resultadonojson.html"' .
14
  '}';
15
}
16

4. php / lib / INTERNAL_SERVER_ERROR.php

1
<?php
2
3
const INTERNAL_SERVER_ERROR = 500;

5. php / lib / manejaErrores.php

1
<?php
2
3
require_once __DIR__ . "/INTERNAL_SERVER_ERROR.php";
4
require_once __DIR__ . "/ProblemDetailsException.php";
5
6
// Hace que se lance una excepción automáticamente cuando se genere un error.
7
set_error_handler(function ($severity, $message, $file, $line) {
8
 throw new ErrorException($message, 0, $severity, $file, $line);
9
});
10
11
// Código cuando una excepción no es atrapada.
12
set_exception_handler(function (Throwable $excepcion) {
13
 if ($excepcion instanceof ProblemDetailsException) {
14
  devuelveProblemDetails($excepcion->problemDetails);
15
 } else {
16
  devuelveProblemDetails([
17
   "status" => INTERNAL_SERVER_ERROR,
18
   "title" => "Error interno del servidor",
19
   "detail" => $excepcion->getMessage(),
20
   "type" => "/errors/errorinterno.html",
21
  ]);
22
 }
23
 exit();
24
});
25
26
function devuelveProblemDetails(array $array)
27
{
28
 $json = json_encode($array);
29
 if ($json === false) {
30
  devuelveResultadoNoJson();
31
 } else {
32
  http_response_code(isset($array["status"]) ? $array["status"] : 500);
33
  header("Content-Type: application/problem+json; charset=utf-8");
34
  echo $json;
35
 }
36
}
37

6. php / lib / ProblemDetailsException.php

1
<?php
2
3
require_once __DIR__ . "/INTERNAL_SERVER_ERROR.php";
4
5
/**
6
 * Detalle de los errores devueltos por un servicio.
7
 */
8
class ProblemDetailsException extends Exception
9
{
10
11
 public array $problemDetails;
12
13
 public function __construct(
14
  array $problemDetails,
15
 ) {
16
  
17
  parent::__construct(
18
   isset($problemDetails["detail"])
19
    ? $problemDetails["detail"]
20
    : (isset($problemDetails["title"])
21
     ? $problemDetails["title"]
22
     : "Error"),
23
   $problemDetails["status"]
24
    ? $problemDetails["status"]
25
    : INTERNAL_SERVER_ERROR
26
  );
27
28
  $this->problemDetails = $problemDetails;
29
 }
30
}
31

7. php / lib / recibeJson.php

1
<?php
2
3
require_once __DIR__ . "/BAD_REQUEST.php";
4
5
function recibeJson()
6
{
7
 $json = json_decode(file_get_contents("php://input"));
8
9
 if ($json === null) {
10
11
  http_response_code(BAD_REQUEST);
12
  header("Content-Type: application/problem+json; charset=utf-8");
13
14
  echo '{' .
15
   "status: " . BAD_REQUEST .
16
   '"title": "Los datos recibidos no están en formato JSON."' .
17
   '"type": "/errors/datosnojson.html"' .
18
   '}';
19
20
  exit();
21
 }
22
23
 return $json;
24
}
25

O. Carpeta « errors »

Versión para imprimir.

A. errors / datosnojson.html

1
<!DOCTYPE html>
2
<html lang="es">
3
4
<head>
5
6
 <meta charset="UTF-8">
7
 <meta name="viewport" content="width=device-width">
8
9
 <title>Los datos recibidos no están en formato JSON</title>
10
11
</head>
12
13
<body>
14
15
 <h1>Los datos recibidos no están en formato JSON</h1>
16
17
</body>
18
19
</html>

B. errors / eliminadoincorrecto.html

1
<!DOCTYPE html>
2
<html lang="es">
3
4
<head>
5
6
 <meta charset="UTF-8">
7
 <meta name="viewport" content="width=device-width">
8
9
 <title>El campo eliminado debe ser entero</title>
10
11
<body>
12
13
 <h1>El campo eliminado debe ser entero</h1>
14
15
</body>
16
17
</html>

C. errors / errorinterno.html

1
<!DOCTYPE html>
2
<html lang="es">
3
4
<head>
5
6
 <meta charset="UTF-8">
7
 <meta name="viewport" content="width=device-width">
8
9
 <title>Error interno del servidor</title>
10
11
</head>
12
13
<body>
14
15
 <h1>Error interno del servidor</h1>
16
17
 <p>Se presentó de forma inesperada un error interno del servidor.</p>
18
19
</body>
20
21
</html>

D. errors / idincorrecto.html

1
<!DOCTYPE html>
2
<html lang="es">
3
4
<head>
5
6
 <meta charset="UTF-8">
7
 <meta name="viewport" content="width=device-width">
8
9
 <title>El id debe ser texto que no esté en blanco</title>
10
11
<body>
12
13
 <h1>El id debe ser texto que no esté en blanco</h1>
14
15
</body>
16
17
</html>

E. errors / modificacionincorrecta.html

1
<!DOCTYPE html>
2
<html lang="es">
3
4
<head>
5
6
 <meta charset="UTF-8">
7
 <meta name="viewport" content="width=device-width">
8
9
 <title>La modificacion debe ser número</title>
10
11
<body>
12
13
 <h1>La modificacion debe ser número</h1>
14
15
</body>
16
17
</html>

F. errors / nombreincorrecto.html

1
<!DOCTYPE html>
2
<html lang="es">
3
4
<head>
5
6
 <meta charset="UTF-8">
7
 <meta name="viewport" content="width=device-width">
8
9
 <title>El nombre debe ser texto que no esté en blanco</title>
10
11
<body>
12
13
 <h1>El nombre debe ser texto que no esté en blanco</h1>
14
15
</body>
16
17
</html>

G. errors / resultadonojson.html

1
<!DOCTYPE html>
2
<html lang="es">
3
4
<head>
5
6
 <meta charset="UTF-8">
7
 <meta name="viewport" content="width=device-width">
8
9
 <title>El resultado no puede representarse como JSON</title>
10
11
</head>
12
13
<body>
14
15
 <h1>El resultado no puede representarse como JSON</h1>
16
17
 <p>
18
  Debido a un error interno del servidor, el resultado generado, no se puede
19
  recuperar.
20
 </p>
21
22
</body>
23
24
</html>

P. .htaccess

1
AddType application/manifest+json .webmanifest
2
3
ExpiresActive On
4
5
Header set Cache-Control "max-age=1, must-revalidate"
6

Q. jsconfig.json

1
{
2
 "compilerOptions": {
3
  "checkJs": true,
4
  "strictNullChecks": true,
5
  "target": "ES6",
6
  "module": "Node16",
7
  "moduleResolution": "Node16",
8
  "lib": [
9
   "ES2017",
10
   "WebWorker",
11
   "DOM"
12
  ]
18
}

R. Resumen