En esta lección se muestra un ejemplo de sincronización de bases de datos.
Puedes probar la app desde varios navegadores y dispositivos en https://replit.com/@GilbertoPachec2/sincronizacion?v=1. Hazle fork al proyecto y córrelo.
Las modificaciones que realices en dispositivo o navegador se verán reflejados en los otros dispositivos, en un máximo de 20 segundos.
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.
Comercialmente hay algunos productos como:
Revisa el proyecto en Replit con la URl https://replit.com/@GilbertoPachec2/sincronizacion?v=1. Hazle fork al proyecto y córrelo. En el ambiente de desarrollo tienes la opción de descargar el proyecto en un zip.
Crea un proyecto PHP Web Server en Replit y edita o sube los archivos de este proyecto.
Haz clic en los triángulos para expandir las carpetas
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>Sincronizacion</title> |
10 | |
11 | <script type="module" src="js/configura.js"></script> |
12 | |
13 | </head> |
14 | |
15 | <body> |
16 | |
17 | <h1>Sincronizacion</h1> |
18 | |
19 | <p><a href="agrega.html">Agregar</a></p> |
20 | |
21 | <ul id="lista"> |
22 | <li><progress max="100">Cargando…</progress></li> |
23 | </ul> |
24 | |
25 | <script type="module"> |
26 | |
27 | import { muestraError } from "./lib/js/muestraError.js" |
28 | import { enviaJson } from "./lib/js/enviaJson.js" |
29 | import { Pasatiempo } from "./js/modelo/Pasatiempo.js" |
30 | import { |
31 | validaQueTengaLasPropiedadesDePasatiempo |
32 | } from "./js/modelo/validaQueTengaLasPropiedadesDePasatiempo.js" |
33 | import { |
34 | pasatiempoConsultaNoEliminados |
35 | } from "./js/bd/pasatiempoConsultaNoEliminados.js" |
36 | import { pasatiempoConsultaTodos } from "./js/bd/pasatiempoConsultaTodos.js" |
37 | import { pasatiemposReemplaza } from "./js/bd/pasatiemposReemplaza.js" |
38 | |
39 | /** |
40 | * Cada 20 segundos (2000 milisegundos) después de la última |
41 | * sincronización, los datos se envían al servidor para volver a |
42 | * sincronizarse con los datos del servidor. |
43 | */ |
44 | const MILISEGUNDOS_PARA_VOLVER_A_SINCRONIZAR = 20000 |
45 | let noHaSincronizado = true |
46 | |
47 | // Crea y pone en funcionamiento el worker del archivo "render.js". |
48 | const worker = new Worker("render.js", { type: "module" }) |
49 | |
50 | // Se invoca cuando el worker envía un mensaje a la página. |
51 | worker.onmessage = async event => { |
52 | lista.innerHTML = event.data |
53 | if (noHaSincronizado) { |
54 | noHaSincronizado = false |
55 | await sincroniza() |
56 | } else { |
57 | esperaUnPocoYLanzaSincronizacion() |
58 | } |
59 | } |
60 | |
61 | pasatiempoConsultaNoEliminados() |
62 | .then(renderEnWebWorker) |
63 | .catch(muestraError) |
64 | |
65 | function renderEnWebWorker(datos) { |
66 | if (!Array.isArray(datos)) |
67 | throw new Error("Los datos no están en un arreglo.") |
68 | for (const dato of datos) { |
69 | validaQueTengaLasPropiedadesDePasatiempo(dato) |
70 | } |
71 | // Manda los datos al worker para renderizar. |
72 | worker.postMessage(datos) |
73 | } |
74 | |
75 | async function sincroniza() { |
76 | try { |
77 | if (navigator.onLine) { |
78 | const todos = await pasatiempoConsultaTodos() |
79 | const respuesta = await enviaJson("srv/srvSincro.php", todos) |
80 | const datosNuevos = respuesta.body |
81 | renderEnWebWorker(datosNuevos) |
82 | await pasatiemposReemplaza(datosNuevos) |
83 | } else { |
84 | esperaUnPocoYLanzaSincronizacion() |
85 | } |
86 | } catch (error) { |
87 | muestraError(error) |
88 | esperaUnPocoYLanzaSincronizacion() |
89 | } |
90 | } |
91 | |
92 | function esperaUnPocoYLanzaSincronizacion() { |
93 | setTimeout(sincroniza, MILISEGUNDOS_PARA_VOLVER_A_SINCRONIZAR) |
94 | } |
95 | |
96 | </script> |
97 | |
98 | </body> |
99 | |
100 | </html> |
1 | /* Ejemplo de render en el cliente. No se usa import |
2 | * porque Firefox no lo soporta en los web workers. */ |
3 | |
4 | // Verifica si el código corre dentro de un web worker. |
5 | if (self instanceof WorkerGlobalScope) { |
6 | |
7 | // Se invoca al recibir un mensaje de la página. |
8 | onmessage = event => { |
9 | |
10 | /** |
11 | * @type { { |
12 | * uuid: string, |
13 | * nombre: string, |
14 | * modificacion: number, |
15 | * eliminado: boolean, |
16 | * } [] } |
17 | */ |
18 | const pasatiempos = event.data |
19 | |
20 | let render = "" |
21 | for (const modelo of pasatiempos) { |
22 | const nombre = htmlentities(modelo.nombre) |
23 | const searchParams = new URLSearchParams([["uuid", modelo.uuid]]) |
24 | const params = htmlentities(searchParams.toString()) |
25 | render += /* html */ |
26 | `<li> |
27 | <p><a href="modifica.html?${params}">${nombre}</a></p> |
28 | </li>` |
29 | } |
30 | |
31 | // Envía el render a la página que invocó este web worker. |
32 | self.postMessage(render) |
33 | |
34 | } |
35 | |
36 | } |
37 | |
38 | /** |
39 | * Codifica un texto para que cambie los caracteres |
40 | * especiales y no se pueda interpretar como |
41 | * etiiqueta HTML. Esta técnica evita la inyección |
42 | * de código. |
43 | * @param { string } texto |
44 | * @returns { string } un texto que no puede |
45 | * interpretarse como HTML. */ |
46 | export function htmlentities(texto) { |
47 | return texto.replace(/[<>"']/g, textoDetectado => { |
48 | switch (textoDetectado) { |
49 | case "<": return "<" |
50 | case ">": return ">" |
51 | case '"': return """ |
52 | case "'": return "'" |
53 | default: return textoDetectado |
54 | } |
55 | }) |
56 | } |
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/configura.js"></script> |
12 | <script type="module" src="lib/js/muestraError.js"></script> |
13 | <script type="module" src="js/modelo/Pasatiempo.js"></script> |
14 | <script type="module" src="js/bd/pasatiempoAgrega.js"></script> |
15 | |
16 | </head> |
17 | |
18 | <body> |
19 | |
20 | <form id="forma" onsubmit=" |
21 | event.preventDefault() |
22 | // Lee el nombre, quitándole los espacios al inicio y al final. |
23 | const nombre = forma.nombre.value.trim() |
24 | const modelo = new Pasatiempo({ nombre }) |
25 | pasatiempoAgrega(modelo) |
26 | .then(json => location.href = 'index.html') |
27 | .catch(muestraError)"> |
28 | |
29 | <h1>Agregar</h1> |
30 | |
31 | <p><a href="index.html">Cancelar</a></p> |
32 | |
33 | <p> |
34 | <label> |
35 | Nombre * |
36 | <input name="nombre"> |
37 | </label> |
38 | </p> |
39 | <p>* Obligatorio</p> |
40 | <p><button type="submit">Agregar</button></p> |
41 | |
42 | </form> |
43 | |
44 | </body> |
45 | |
46 | </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/configura.js"></script> |
12 | <script type="module" src="lib/js/muestraError.js"></script> |
13 | <script type="module" src="lib/js/muestraObjeto.js"></script> |
14 | <script type="module" src="lib/js/confirmaEliminar.js"></script> |
15 | <script type="module" src="js/modelo/Pasatiempo.js"></script> |
16 | <script type="module" src="js/bd/pasatiempoBusca.js"></script> |
17 | <script type="module" src="js/bd/pasatiempoElimina.js"></script> |
18 | <script type="module" src="js/bd/pasatiempoModifica.js"></script> |
19 | |
20 | <script> |
21 | |
22 | // Obtiene los parámetros de la página. |
23 | const parametros = new URL(location.href).searchParams |
24 | |
25 | const uuid = parametros.get("uuid") |
26 | |
27 | </script> |
28 | |
29 | </head> |
30 | |
31 | <body onload="if (uuid !== null) { |
32 | pasatiempoBusca(uuid) |
33 | .then(async pasatiempo => { |
34 | if (pasatiempo === undefined) throw new Error('Pasatiempo no encontrado.') |
35 | await muestraObjeto(forma, { nombre: { value: pasatiempo.nombre } }) |
36 | }) |
37 | .catch(muestraError) |
38 | }"> |
39 | |
40 | <form id="forma" onsubmit=" |
41 | event.preventDefault() |
42 | if (uuid !== null) { |
43 | // Lee el nombre, quitándole los espacios al inicio y al final. |
44 | const nombre = forma.nombre.value.trim() |
45 | const modelo = new Pasatiempo({ nombre, uuid }) |
46 | pasatiempoModifica(modelo) |
47 | .then(json => location.href = 'index.html') |
48 | .catch(muestraError) |
49 | }"> |
50 | |
51 | <h1>Modificar</h1> |
52 | |
53 | <p><a href="index.html">Cancelar</a></p> |
54 | |
55 | <p> |
56 | <label> |
57 | Nombre * |
58 | <input name="nombre" value="Cargando…"> |
59 | </label> |
60 | </p> |
61 | |
62 | <p>* Obligatorio</p> |
63 | |
64 | <p> |
65 | |
66 | <button type="submit">Guardar</button> |
67 | |
68 | <button type="button" onclick="if (uuid !== null && confirmaEliminar()) { |
69 | pasatiempoElimina(uuid) |
70 | .then(() => location.href = 'index.html') |
71 | .catch(muestraError) |
72 | }"> |
73 | Eliminar |
74 | </button> |
75 | |
76 | </p> |
77 | |
78 | </form> |
79 | |
80 | </body> |
81 | |
82 | </html> |
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 | * el archivo .vscode/settings.json, |
16 | * el archivo archivos.txt, |
17 | * este archivo (instruccionesListadoSw.txt), |
18 | * el archivo jsconfig.json, |
19 | * el archivo sw.js, |
20 | * el archivo de la base de datos, que termina en ".bd" y |
21 | está en la carpeta srv, |
22 | * todos los archivos de php y |
23 | * las líneas en blanco del final |
24 | |
25 | 5. Cambia los \ por / desde Visual Studio Code con las siguientes |
26 | combinaciones de teclas: |
27 | |
28 | Ctrl+H En el diálogo que aparece introduce lo siguiente: |
29 | Find:\ |
30 | Replace:/ |
31 | |
32 | Clic en el icono Reemplaza todo o Replace All y luego teclea ESC |
33 | |
34 | 6. Coloca las comillas y coma del final de cada línea desde Visual |
35 | Studio Code con las siguientes combinaciones de teclas: |
36 | |
37 | Ctrl+H En el diálogo que aparece, selecciona el botón |
38 | ".*" |
39 | e introduce lo siguiente: |
40 | Find:\s*$ |
41 | Replace:", |
42 | |
43 | Clic en el icono Reemplaza todo o Replace All y luego teclea ESC |
44 | |
45 | 7. Marca la carpeta inicial, presiona la combinación de teclas: |
46 | |
47 | Shift+Ctrl+L |
48 | |
49 | borra la selección, teclea " y luego ESC |
50 | |
51 | 8. Cambia las secuencias de espacios por / con las siguientes |
52 | combinaciones de teclas: |
53 | |
54 | Ctrl+H En el diálogo que aparece, selecciona el botón |
55 | ".*" |
56 | e introduce lo siguiente: |
57 | Find:\s+ |
58 | Replace:/ |
59 | |
60 | Clic en el icono Reemplaza todo o Replace All y luego teclea ESC |
61 | |
62 | 9. Cambia las "/ por " con las siguientes combinaciones de teclas: |
63 | |
64 | Ctrl+H En el diálogo que aparece, quita la selección del botón |
65 | ".*" |
66 | e introduce lo siguiente: |
67 | Find:"/ |
68 | Replace:" |
69 | |
70 | Clic en el icono Reemplaza todo o Replace All y luego teclea ESC |
71 | |
72 | 10. Copia el texto al archivo |
73 | sw.js |
74 | en el contenido del arreglo llamado ARCHIVOS, pero recuerda |
75 | mantener el último elemento, que dice: |
76 | "/" |
1 | "agrega.html", |
2 | "index.html", |
3 | "modifica.html", |
4 | "render.js", |
5 | "error/eliminadoincorrecto.html", |
6 | "error/errorinterno.html", |
7 | "error/modificacionincorrecta.html", |
8 | "error/nojson.html", |
9 | "error/nombreincorrecto.html", |
10 | "error/uuidincorrecto.html", |
11 | "js/configura.js", |
12 | "js/bd/Bd.js", |
13 | "js/bd/pasatiempoAgrega.js", |
14 | "js/bd/pasatiempoBusca.js", |
15 | "js/bd/pasatiempoConsultaNoEliminados.js", |
16 | "js/bd/pasatiempoConsultaTodos.js", |
17 | "js/bd/pasatiempoElimina.js", |
18 | "js/bd/pasatiempoModifica.js", |
19 | "js/bd/pasatiemposReemplaza.js", |
20 | "js/modelo/leePasatiempo.js", |
21 | "js/modelo/Pasatiempo.js", |
22 | "js/modelo/validaQueTengaLasPropiedadesDePasatiempo.js", |
23 | "lib/js/bdConsulta.js", |
24 | "lib/js/bdEjecuta.js", |
25 | "lib/js/confirmaEliminar.js", |
26 | "lib/js/enviaJson.js", |
27 | "lib/js/invocaServicio.js", |
28 | "lib/js/JsonResponse.js", |
29 | "lib/js/muestraError.js", |
30 | "lib/js/muestraObjeto.js", |
31 | "lib/js/ProblemDetails.js", |
32 | "lib/js/registraServiceWorkerSiEsSoportado.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 | "render.js", |
33 | "error/eliminadoincorrecto.html", |
34 | "error/errorinterno.html", |
35 | "error/modificacionincorrecta.html", |
36 | "error/nojson.html", |
37 | "error/nombreincorrecto.html", |
38 | "error/uuidincorrecto.html", |
39 | "js/configura.js", |
40 | "js/bd/Bd.js", |
41 | "js/bd/pasatiempoAgrega.js", |
42 | "js/bd/pasatiempoBusca.js", |
43 | "js/bd/pasatiempoConsultaNoEliminados.js", |
44 | "js/bd/pasatiempoConsultaTodos.js", |
45 | "js/bd/pasatiempoElimina.js", |
46 | "js/bd/pasatiempoModifica.js", |
47 | "js/bd/pasatiemposReemplaza.js", |
48 | "js/modelo/leePasatiempo.js", |
49 | "js/modelo/Pasatiempo.js", |
50 | "js/modelo/validaQueTengaLasPropiedadesDePasatiempo.js", |
51 | "lib/js/bdConsulta.js", |
52 | "lib/js/bdEjecuta.js", |
53 | "lib/js/confirmaEliminar.js", |
54 | "lib/js/enviaJson.js", |
55 | "lib/js/invocaServicio.js", |
56 | "lib/js/JsonResponse.js", |
57 | "lib/js/muestraError.js", |
58 | "lib/js/muestraObjeto.js", |
59 | "lib/js/ProblemDetails.js", |
60 | "lib/js/registraServiceWorkerSiEsSoportado.js", |
61 | "/" |
62 | ] |
63 | |
64 | // Verifica si el código corre dentro de un service worker. |
65 | if (self instanceof ServiceWorkerGlobalScope) { |
66 | // Evento al empezar a instalar el servide worker, |
67 | self.addEventListener("install", |
68 | (/** @type {ExtendableEvent} */ evt) => { |
69 | console.log("El service worker se está instalando.") |
70 | evt.waitUntil(llenaElCache()) |
71 | }) |
72 | |
73 | // Evento al solicitar información a la red. |
74 | self.addEventListener("fetch", (/** @type {FetchEvent} */ evt) => { |
75 | if (evt.request.method === "GET") { |
76 | evt.respondWith(buscaLaRespuestaEnElCache(evt)) |
77 | } |
78 | }) |
79 | |
80 | // Evento cuando el service worker se vuelve activo. |
81 | self.addEventListener("activate", |
82 | () => console.log("El service worker está activo.")) |
83 | } |
84 | |
85 | async function llenaElCache() { |
86 | console.log("Intentando cargar caché:", CACHE, ".") |
87 | // Borra todos los cachés. |
88 | const keys = await caches.keys() |
89 | for (const key of keys) { |
90 | await caches.delete(key) |
91 | } |
92 | // Abre el caché de este service worker. |
93 | const cache = await caches.open(CACHE) |
94 | // Carga el listado de ARCHIVOS. |
95 | await cache.addAll(ARCHIVOS) |
96 | console.log("Cache cargado:", CACHE, ".") |
97 | console.log("Versión:", VERSION, ".") |
98 | } |
99 | |
100 | /** @param {FetchEvent} evt */ |
101 | async function buscaLaRespuestaEnElCache(evt) { |
102 | // Abre el caché. |
103 | const cache = await caches.open(CACHE) |
104 | const request = evt.request |
105 | /* Busca la respuesta a la solicitud en el contenido del caché, sin |
106 | * tomar en cuenta la parte después del símbolo "?" en la URL. */ |
107 | const response = await cache.match(request, { ignoreSearch: true }) |
108 | if (response === undefined) { |
109 | /* Si no la encuentra, empieza a descargar de la red y devuelve |
110 | * la promesa. */ |
111 | return fetch(request) |
112 | } else { |
113 | // Si la encuentra, devuelve la respuesta encontrada en el caché. |
114 | return response |
115 | } |
116 | } |
1 | import { |
2 | registraServiceWorkerSiEsSoportado |
3 | } from "../lib/js/registraServiceWorkerSiEsSoportado.js" |
4 | |
5 | registraServiceWorkerSiEsSoportado("sw.js") |
1 | export const NOMBRE_DEL_ALMACEN_PASATIEMPO = "Pasatiempo" |
2 | export const NOMBRE_DEL_INDICE_NOMBRE = "nombre" |
3 | const BD_NOMBRE = "sincronizacion" |
4 | const BD_VERSION = 1 |
5 | |
6 | /** @type { Promise<IDBDatabase> } */ |
7 | export const Bd = new Promise((resolve, reject) => { |
8 | |
9 | /* Se solicita abrir la base de datos, indicando nombre y |
10 | * número de versión. */ |
11 | const solicitud = indexedDB.open(BD_NOMBRE, BD_VERSION) |
12 | |
13 | // Si se presenta un error, rechaza la promesa. |
14 | solicitud.onerror = () => reject(solicitud.error) |
15 | |
16 | // Si se abre con éxito, devuelve una conexión a la base de datos. |
17 | solicitud.onsuccess = () => resolve(solicitud.result) |
18 | |
19 | // Si es necesario, se inicia una transacción para cambio de versión. |
20 | solicitud.onupgradeneeded = () => { |
21 | |
22 | const bd = solicitud.result |
23 | |
24 | // Como hay cambio de versión, borra el almacén si es que existe. |
25 | if (bd.objectStoreNames.contains(NOMBRE_DEL_ALMACEN_PASATIEMPO)) { |
26 | bd.deleteObjectStore(NOMBRE_DEL_ALMACEN_PASATIEMPO) |
27 | } |
28 | |
29 | // Crea el almacén "Pasatiempo" con el campo llave "uuid". |
30 | const almacenPasatiempo = |
31 | bd.createObjectStore(NOMBRE_DEL_ALMACEN_PASATIEMPO, { keyPath: "uuid" }) |
32 | |
33 | // Crea un índice ordenado por el campo "nombre" que no acepta duplicados. |
34 | almacenPasatiempo.createIndex(NOMBRE_DEL_INDICE_NOMBRE, "nombre") |
35 | } |
36 | |
37 | }) |
1 | import { bdEjecuta } from "../../lib/js/bdEjecuta.js" |
2 | import { Pasatiempo } from "../modelo/Pasatiempo.js" |
3 | import { Bd, NOMBRE_DEL_ALMACEN_PASATIEMPO } from "./Bd.js" |
4 | |
5 | let secuencia = 0 |
6 | |
7 | /** |
8 | * @param { Pasatiempo } modelo |
9 | */ |
10 | export async function pasatiempoAgrega(modelo) { |
11 | modelo.validaNuevo() |
12 | modelo.modificacion = Date.now() |
13 | modelo.eliminado = false |
14 | |
15 | // Genera uuid único en internet. |
16 | modelo.uuid = Date.now().toString() + Math.random() + secuencia |
17 | secuencia++ |
18 | |
19 | return bdEjecuta(Bd, [NOMBRE_DEL_ALMACEN_PASATIEMPO], |
20 | transaccion => { |
21 | const almacenPasatiempo = |
22 | transaccion.objectStore(NOMBRE_DEL_ALMACEN_PASATIEMPO) |
23 | almacenPasatiempo.add(modelo) |
24 | }) |
25 | } |
26 | |
27 | // Permite que los eventos de html usen la función. |
28 | window["pasatiempoAgrega"] = pasatiempoAgrega |
1 | import { bdConsulta } from "../../lib/js/bdConsulta.js" |
2 | import { leePasatiempo } from "../modelo/leePasatiempo.js" |
3 | import { Pasatiempo } from "../modelo/Pasatiempo.js" |
4 | import { Bd, NOMBRE_DEL_ALMACEN_PASATIEMPO } from "./Bd.js" |
5 | |
6 | /** |
7 | * @param { string | null } uuid |
8 | */ |
9 | export async function pasatiempoBusca(uuid) { |
10 | |
11 | if (uuid === null) |
12 | throw new Error("Falta el uuid") |
13 | |
14 | return bdConsulta(Bd, [NOMBRE_DEL_ALMACEN_PASATIEMPO], |
15 | /** |
16 | * @param { IDBTransaction } transaccion |
17 | * @param { (resultado: Pasatiempo|undefined) => any } resolve |
18 | */ |
19 | (transaccion, resolve) => { |
20 | |
21 | /* Pide el primer registro de almacén pasatiempo que tenga como |
22 | * llave primaria el valor del parámetro uuid. */ |
23 | const consulta = |
24 | transaccion.objectStore(NOMBRE_DEL_ALMACEN_PASATIEMPO).get(uuid) |
25 | |
26 | /* onsuccess se invoca solo una vez, devolviendo el registro |
27 | * solicitado. */ |
28 | consulta.onsuccess = () => { |
29 | |
30 | /* Se recupera el registro solicitado usando |
31 | * consulta.result |
32 | * Si el registro no se encuentra se recupera undefined. */ |
33 | const objeto = consulta.result |
34 | |
35 | if (objeto !== undefined) { |
36 | const modelo = leePasatiempo(objeto) |
37 | if (!modelo.eliminado) { |
38 | resolve(modelo) |
39 | } |
40 | } |
41 | |
42 | resolve(undefined) |
43 | |
44 | } |
45 | |
46 | }) |
47 | |
48 | } |
49 | |
50 | // Permite que los eventos de html usen la función. |
51 | window["pasatiempoBusca"] = pasatiempoBusca |
1 | import { bdConsulta } from "../../lib/js/bdConsulta.js" |
2 | import { |
3 | Bd, |
4 | NOMBRE_DEL_ALMACEN_PASATIEMPO, NOMBRE_DEL_INDICE_NOMBRE |
5 | } from "./Bd.js" |
6 | |
7 | export async function pasatiempoConsultaNoEliminados() { |
8 | |
9 | return bdConsulta(Bd, [NOMBRE_DEL_ALMACEN_PASATIEMPO], |
10 | /** |
11 | * @param { IDBTransaction } transaccion |
12 | * @param { (resultado: any[]) => any } resolve |
13 | */ |
14 | (transaccion, resolve) => { |
15 | |
16 | const resultado = [] |
17 | |
18 | const almacenPasatiempo = |
19 | transaccion.objectStore(NOMBRE_DEL_ALMACEN_PASATIEMPO) |
20 | |
21 | // Usa el índice "nombre" para reculeprar los datos ordenadors. |
22 | const indiceNombre = almacenPasatiempo.index(NOMBRE_DEL_INDICE_NOMBRE) |
23 | |
24 | /* Pide un cursor para recorrer cada uno de los registristros |
25 | * que devuelve la consulta. */ |
26 | const consulta = indiceNombre.openCursor() |
27 | |
28 | /* onsuccess se invoca por cada uno de los registros de la |
29 | * consulta y una vez cuando se acaban dichos registros. */ |
30 | consulta.onsuccess = () => { |
31 | |
32 | /* El cursor que corresponde al registro se recupera usando |
33 | * consulta.result */ |
34 | const cursor = consulta.result |
35 | |
36 | if (cursor === null) { |
37 | |
38 | /* Si el cursor es igual a null, ya no hay más registros que |
39 | * procesar; por lo mismo, se devuelve el resultado con los |
40 | * pasatiempos recuperados, usando |
41 | * resolve(resultado). */ |
42 | resolve(resultado) |
43 | |
44 | } else { |
45 | |
46 | /* Si el cursor es diferente a null, si hay más registros. |
47 | * El registro que sigue se obtiene con |
48 | * cursor.value */ |
49 | const modelo = cursor.value |
50 | if (!modelo.eliminado) { |
51 | resultado.push(modelo) |
52 | } |
53 | |
54 | /* Busca el siguiente registro de la consulta, que se obtiene |
55 | * la siguiente vez que se invoque la función |
56 | * onsuccess. */ |
57 | cursor.continue() |
58 | |
59 | } |
60 | |
61 | } |
62 | |
63 | }) |
64 | |
65 | } |
66 | |
67 | // Permite que los eventos de html usen la función. |
68 | window["pasatiempoConsultaNoEliminados"] = pasatiempoConsultaNoEliminados |
1 | import { bdConsulta } from "../../lib/js/bdConsulta.js" |
2 | import { NOMBRE_DEL_ALMACEN_PASATIEMPO, Bd } from "./Bd.js" |
3 | |
4 | /** |
5 | * Lista todos los objetos, incluyendo los que tienen |
6 | * borrado lógico. |
7 | */ |
8 | export async function pasatiempoConsultaTodos() { |
9 | |
10 | return bdConsulta(Bd, [NOMBRE_DEL_ALMACEN_PASATIEMPO], |
11 | /** |
12 | * @param { IDBTransaction } transaccion |
13 | * @param { (result: any[]) => any } resolve |
14 | */ |
15 | (transaccion, resolve) => { |
16 | |
17 | const resultado = [] |
18 | |
19 | /* Pide un cursor para recorrer cada uno de los |
20 | * registristros que devuelve la consulta. */ |
21 | const consulta = |
22 | transaccion.objectStore(NOMBRE_DEL_ALMACEN_PASATIEMPO).openCursor() |
23 | |
24 | /* onsuccess se invoca por cada uno de los registros de la |
25 | * consulta y una vez cuando se acaban dichos registros. */ |
26 | consulta.onsuccess = () => { |
27 | |
28 | /* El cursor que corresponde al registro se recupera usando |
29 | * consulta.result */ |
30 | const cursor = consulta.result |
31 | |
32 | if (cursor === null) { |
33 | |
34 | /* Si el cursor es igual a null, ya no hay más registros que |
35 | * procesar; por lo mismo, se devuelve el resultado con los |
36 | * pasatiempos recuperados, usando |
37 | * resolve(resultado). */ |
38 | resolve(resultado) |
39 | |
40 | } else { |
41 | |
42 | /* Si el cursor es diferente a null, si hay más registros. |
43 | * El registro que sigue se obtiene con |
44 | * cursor.value*/ |
45 | resultado.push(cursor.value) |
46 | |
47 | /* Busca el siguiente registro de la consulta, que se obtiene |
48 | * la siguiente vez que se invoque la función |
49 | * onsuccess. */ |
50 | cursor.continue() |
51 | |
52 | } |
53 | |
54 | } |
55 | |
56 | }) |
57 | |
58 | } |
1 | import { bdEjecuta } from "../../lib/js/bdEjecuta.js" |
2 | import { NOMBRE_DEL_ALMACEN_PASATIEMPO, Bd } from "./Bd.js" |
3 | import { pasatiempoBusca } from "./pasatiempoBusca.js" |
4 | |
5 | /** |
6 | * @param { string } uuid |
7 | */ |
8 | export async function pasatiempoElimina(uuid) { |
9 | const modelo = await pasatiempoBusca(uuid) |
10 | if (modelo !== undefined) { |
11 | modelo.modificacion = Date.now() |
12 | modelo.eliminado = true |
13 | return bdEjecuta(Bd, [NOMBRE_DEL_ALMACEN_PASATIEMPO], |
14 | transaccion => { |
15 | const almacenPasatiempo = |
16 | transaccion.objectStore(NOMBRE_DEL_ALMACEN_PASATIEMPO) |
17 | almacenPasatiempo.put(modelo) |
18 | }) |
19 | } |
20 | } |
21 | |
22 | // Permite que los eventos de html usen la función. |
23 | window["pasatiempoElimina"] = pasatiempoElimina |
1 | import { bdEjecuta } from "../../lib/js/bdEjecuta.js" |
2 | import { Pasatiempo } from "../modelo/Pasatiempo.js" |
3 | import { Bd, NOMBRE_DEL_ALMACEN_PASATIEMPO } from "./Bd.js" |
4 | import { pasatiempoBusca } from "./pasatiempoBusca.js" |
5 | |
6 | /** |
7 | * @param { Pasatiempo } modelo |
8 | */ |
9 | export async function pasatiempoModifica(modelo) { |
10 | modelo.valida() |
11 | const anterior = await pasatiempoBusca(modelo.uuid) |
12 | if (anterior === undefined) { |
13 | return undefined |
14 | } else { |
15 | modelo.modificacion = Date.now() |
16 | modelo.eliminado = false |
17 | return bdEjecuta(Bd, [NOMBRE_DEL_ALMACEN_PASATIEMPO], |
18 | transaccion => { |
19 | const almacenPasatiempo = |
20 | transaccion.objectStore(NOMBRE_DEL_ALMACEN_PASATIEMPO) |
21 | almacenPasatiempo.put(modelo) |
22 | }) |
23 | } |
24 | } |
25 | |
26 | // Permite que los eventos de html usen la función. |
27 | window["pasatiempoModifica"] = pasatiempoModifica |
1 | import { bdEjecuta } from "../../lib/js/bdEjecuta.js" |
2 | import { Bd, NOMBRE_DEL_ALMACEN_PASATIEMPO } from "./Bd.js" |
3 | |
4 | /** |
5 | * Borra el contenido del almacén pasatiempo y guarda el |
6 | * contenido del listado. |
7 | * @param {any[]} datosNuevos |
8 | */ |
9 | export async function pasatiemposReemplaza(datosNuevos) { |
10 | return bdEjecuta(Bd, [NOMBRE_DEL_ALMACEN_PASATIEMPO], |
11 | transaccion => { |
12 | const almacenPasatiempo = |
13 | transaccion.objectStore(NOMBRE_DEL_ALMACEN_PASATIEMPO) |
14 | almacenPasatiempo.clear() |
15 | for (const objeto of datosNuevos) { |
16 | almacenPasatiempo.add(objeto) |
17 | } |
18 | }) |
19 | } |
1 | import { Pasatiempo } from "./Pasatiempo.js" |
2 | import { |
3 | validaQueTengaLasPropiedadesDePasatiempo |
4 | } from "./validaQueTengaLasPropiedadesDePasatiempo.js" |
5 | |
6 | /** @param { any } objeto */ |
7 | export function leePasatiempo(objeto) { |
8 | const validado = validaQueTengaLasPropiedadesDePasatiempo(objeto) |
9 | return new Pasatiempo({ |
10 | uuid: validado.uuid, |
11 | nombre: validado.nombre, |
12 | modificacion: validado.modificacion, |
13 | eliminado: validado.eliminado, |
14 | }) |
15 | } |
1 | export class Pasatiempo { |
2 | |
3 | /** |
4 | * @param { { |
5 | * uuid?: string, |
6 | * nombre?: string, |
7 | * modificacion?: number, |
8 | * eliminado?: boolean, |
9 | * } } modelo |
10 | */ |
11 | constructor(modelo) { |
12 | this.nombre = modelo.nombre === undefined |
13 | ? "" |
14 | : modelo.nombre |
15 | this.uuid = modelo.uuid === undefined |
16 | ? "" |
17 | : modelo.uuid |
18 | this.modificacion = modelo.modificacion === undefined |
19 | ? NaN |
20 | : modelo.modificacion |
21 | this.eliminado = modelo.eliminado === undefined |
22 | ? true |
23 | : modelo.eliminado |
24 | } |
25 | |
26 | validaNuevo() { |
27 | if (this.nombre === "") |
28 | throw new Error("Falta el nombre.") |
29 | } |
30 | |
31 | valida() { |
32 | if (this.uuid === "") |
33 | throw new Error("Falta el uuid.") |
34 | if (this.nombre === "") |
35 | throw new Error("Falta el nombre.") |
36 | } |
37 | |
38 | } |
39 | |
40 | // Permite que los eventos de html usen la clase. |
41 | window["Pasatiempo"] = Pasatiempo |
1 | /** |
2 | * @param { any } objeto |
3 | * @returns { { |
4 | * uuid: string, |
5 | * nombre: string, |
6 | * modificacion: number, |
7 | * eliminado: boolean, |
8 | * } } |
9 | */ |
10 | export function validaQueTengaLasPropiedadesDePasatiempo(objeto) { |
11 | |
12 | if (typeof objeto.uuid !== "string") |
13 | throw new Error("El uuid debe ser texto.") |
14 | |
15 | if (typeof objeto.nombre !== "string") |
16 | throw new Error("El nombre debe ser texto.") |
17 | |
18 | if (typeof objeto.modificacion !== "number") |
19 | throw new Error("El campo modificacion debe ser número.") |
20 | |
21 | if (typeof objeto.eliminado !== "boolean") |
22 | throw new Error("El campo eliminado debe ser booleano.") |
23 | |
24 | return objeto |
25 | |
26 | } |
1 | <?php |
2 | |
3 | require_once __DIR__ . "/../lib/php/ejecutaServicio.php"; |
4 | require_once __DIR__ . "/../lib/php/leeJson.php"; |
5 | require_once __DIR__ . "/modelo/Pasatiempo.php"; |
6 | require_once __DIR__ . "/modelo/leePasatiempo.php"; |
7 | require_once __DIR__ . "/bd/pasatiempoAgrega.php"; |
8 | require_once __DIR__ . "/bd/pasatiempoBusca.php"; |
9 | require_once __DIR__ . "/bd/pasatiempoConsultaNoEliminados.php"; |
10 | require_once __DIR__ . "/bd/pasatiempoModifica.php"; |
11 | |
12 | ejecutaServicio(function () { |
13 | $lista = leeJson(); |
14 | if (!is_array($lista)) { |
15 | $lista = []; |
16 | } |
17 | foreach ($lista as $objeto) { |
18 | $modeloEnElCliente = leePasatiempo($objeto); |
19 | $modeloEnElServidor = pasatiempoBusca($modeloEnElCliente->uuid); |
20 | if ($modeloEnElServidor === false) { |
21 | /* CONFLICTO. El objeto no ha estado en el servidor. |
22 | * AGREGARLO solamente si no está eliminado. */ |
23 | if (!$modeloEnElCliente->eliminado) { |
24 | pasatiempoAgrega($modeloEnElCliente); |
25 | } |
26 | } elseif ( |
27 | !$modeloEnElServidor->eliminado && $modeloEnElCliente->eliminado |
28 | ) { |
29 | /* CONFLICTO. El registro está en el servidor, donde no se ha |
30 | * eliminado, pero ha sido eliminado en el cliente. |
31 | * Gana el cliente, porque optamos por no revivir lo que se ha |
32 | * borrado. */ |
33 | pasatiempoModifica($modeloEnElCliente); |
34 | } else if ( |
35 | !$modeloEnElCliente->eliminado && !$modeloEnElServidor->eliminado |
36 | ) { |
37 | /* CONFLICTO. El registro está tanto en el servidor como en el |
38 | * cliente. Los datos pueden ser diferentes. |
39 | * PREVALECE LA FECHA MÁS GRANDE. Cuando gana el servidor no se |
40 | * hace nada.*/ |
41 | if ( |
42 | $modeloEnElCliente->modificacion > $modeloEnElServidor->modificacion |
43 | ) { |
44 | // La versión del cliente es más nueva y prevalece. |
45 | pasatiempoModifica($modeloEnElCliente); |
46 | } |
47 | } |
48 | } |
49 | $lista = pasatiempoConsultaNoEliminados(); |
50 | return $lista; |
51 | }); |
52 |
1 | <?php |
2 | |
3 | require_once __DIR__ . "/../modelo/Pasatiempo.php"; |
4 | require_once __DIR__ . "/bdCrea.php"; |
5 | |
6 | class Bd |
7 | { |
8 | |
9 | private static ?PDO $conexion = null; |
10 | |
11 | static function getConexion(): PDO |
12 | { |
13 | if (self::$conexion === null) { |
14 | self::$conexion = new PDO( |
15 | // cadena de conexión |
16 | "sqlite:sincronizacion.db", |
17 | // usuario |
18 | null, |
19 | // contraseña |
20 | null, |
21 | // Opciones: conexiones persistentes y lanza excepciones. |
22 | [PDO::ATTR_PERSISTENT => true, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] |
23 | ); |
24 | |
25 | bdCrea(self::$conexion); |
26 | } |
27 | |
28 | return self::$conexion; |
29 | } |
30 | } |
31 |
1 | <?php |
2 | |
3 | function bdCrea(PDO $con) |
4 | { |
5 | $con->exec( |
6 | 'CREATE TABLE IF NOT EXISTS PASATIEMPO ( |
7 | PAS_UUID TEXT NOT NULL, |
8 | PAS_NOMBRE TEXT NOT NULL, |
9 | PAS_MODIFICACION INTEGER NOT NULL, |
10 | PAS_ELIMINADO INTEGER NOT NULL, |
11 | CONSTRAINT PAS_PK |
12 | PRIMARY KEY(PAS_UUID) |
13 | )' |
14 | ); |
15 | } |
16 |
1 | <?php |
2 | |
3 | require_once __DIR__ . "/../modelo/Pasatiempo.php"; |
4 | require_once __DIR__ . "/Bd.php"; |
5 | |
6 | function pasatiempoAgrega(Pasatiempo $modelo) { |
7 | $modelo->valida(); |
8 | $con = Bd::getConexion(); |
9 | $stmt = $con->prepare( |
10 | "INSERT INTO PASATIEMPO |
11 | (PAS_UUID, PAS_NOMBRE, PAS_MODIFICACION, PAS_ELIMINADO) |
12 | VALUES |
13 | (:uuid, :nombre, :modificacion, :eliminado)" |
14 | ); |
15 | $stmt->execute([ |
16 | ":uuid" => $modelo->uuid, |
17 | ":nombre" => $modelo->nombre, |
18 | ":modificacion" => $modelo->modificacion, |
19 | ":eliminado" => $modelo->eliminado ? 1 : 0 |
20 | ]); |
21 | } |
22 |
1 | <?php |
2 | |
3 | require_once __DIR__ . "/../modelo/Pasatiempo.php"; |
4 | require_once __DIR__ . "/Bd.php"; |
5 | |
6 | function pasatiempoBusca(string $uuid): false|Pasatiempo |
7 | { |
8 | $con = Bd::getConexion(); |
9 | $stmt = $con->prepare( |
10 | "SELECT |
11 | PAS_UUID AS uuid, |
12 | PAS_NOMBRE AS nombre, |
13 | PAS_MODIFICACION AS modificacion, |
14 | PAS_ELIMINADO AS eliminado |
15 | FROM PASATIEMPO |
16 | WHERE PAS_UUID = :uuid" |
17 | ); |
18 | $stmt->execute([":uuid" => $uuid]); |
19 | $stmt->setFetchMode( |
20 | PDO::FETCH_CLASS | PDO::FETCH_PROPS_LATE, |
21 | Pasatiempo::class |
22 | ); |
23 | return $stmt->fetch(); |
24 | } |
25 |
1 | <?php |
2 | |
3 | require_once __DIR__ . "/../modelo/Pasatiempo.php"; |
4 | require_once __DIR__ . "/Bd.php"; |
5 | require_once __DIR__ . "/../../lib/php/recibeFetchAll.php"; |
6 | |
7 | /** @return Pasatiempo[] */ |
8 | function pasatiempoConsultaNoEliminados() |
9 | { |
10 | $con = Bd::getConexion(); |
11 | $stmt = $con->query( |
12 | "SELECT |
13 | PAS_UUID AS uuid, |
14 | PAS_NOMBRE AS nombre, |
15 | PAS_MODIFICACION AS modificacion, |
16 | PAS_ELIMINADO AS eliminado |
17 | FROM PASATIEMPO |
18 | WHERE PAS_ELIMINADO = 0 |
19 | ORDER BY PAS_NOMBRE" |
20 | ); |
21 | $resultado = $stmt->fetchAll( |
22 | PDO::FETCH_CLASS | PDO::FETCH_PROPS_LATE, |
23 | Pasatiempo::class |
24 | ); |
25 | return recibeFetchAll($resultado); |
26 | } |
27 |
1 | <?php |
2 | |
3 | require_once __DIR__ . "/../modelo/Pasatiempo.php"; |
4 | require_once __DIR__ . "/Bd.php"; |
5 | |
6 | function pasatiempoModifica(Pasatiempo $modelo) |
7 | { |
8 | $modelo->valida(); |
9 | $con = Bd::getConexion(); |
10 | $stmt = $con->prepare( |
11 | "UPDATE PASATIEMPO |
12 | SET |
13 | PAS_NOMBRE = :nombre, |
14 | PAS_MODIFICACION = :modificacion, |
15 | PAS_ELIMINADO = :eliminado |
16 | WHERE PAS_UUID = :uuid" |
17 | ); |
18 | $stmt->execute([ |
19 | ":uuid" => $modelo->uuid, |
20 | ":nombre" => $modelo->nombre, |
21 | ":modificacion" => $modelo->modificacion, |
22 | ":eliminado" => $modelo->eliminado ? 1 : 0 |
23 | ]); |
24 | } |
25 |
1 | <?php |
2 | |
3 | require_once __DIR__ . "/Pasatiempo.php"; |
4 | |
5 | function leePasatiempo($objeto) |
6 | { |
7 | |
8 | if (!isset($objeto->nombre) || !is_string($objeto->nombre)) |
9 | throw new ProblemDetails( |
10 | status: ProblemDetails::BadRequest, |
11 | type: "/error/nombreincorrecto.html", |
12 | title: "El nombre debe ser texto.", |
13 | ); |
14 | |
15 | if (!isset($objeto->uuid) || !is_string($objeto->uuid)) |
16 | throw new ProblemDetails( |
17 | status: ProblemDetails::BadRequest, |
18 | type: "/error/uuidincorrecto.html", |
19 | title: "El uuid debe ser texto.", |
20 | ); |
21 | |
22 | if (!isset($objeto->eliminado) || !is_bool($objeto->eliminado)) |
23 | throw new ProblemDetails( |
24 | status: ProblemDetails::BadRequest, |
25 | type: "/error/eliminadoincorrecto.html", |
26 | title: "El campo eliminado debe ser booleano.", |
27 | ); |
28 | |
29 | if (!isset($objeto->modificacion) || !is_int($objeto->modificacion)) |
30 | throw new ProblemDetails( |
31 | status: ProblemDetails::BadRequest, |
32 | type: "/error/modificacionincorrecta.html", |
33 | title: "La modificacion debe ser número.", |
34 | ); |
35 | |
36 | return new Pasatiempo( |
37 | uuid: $objeto->uuid, |
38 | nombre: $objeto->nombre, |
39 | modificacion: $objeto->modificacion, |
40 | eliminado: $objeto->eliminado |
41 | ); |
42 | } |
43 |
1 | <?php |
2 | |
3 | class Pasatiempo |
4 | { |
5 | public string $uuid; |
6 | public string $nombre; |
7 | public int $modificacion; |
8 | public bool $eliminado; |
9 | |
10 | public function __construct( |
11 | string $nombre = "", |
12 | string $uuid = "", |
13 | int $modificacion = 0, |
14 | bool $eliminado = true |
15 | ) { |
16 | $this->nombre = $nombre; |
17 | $this->uuid = $uuid; |
18 | $this->modificacion = $modificacion; |
19 | $this->eliminado = $eliminado; |
20 | } |
21 | |
22 | public function valida() |
23 | { |
24 | if ($this->uuid === "") |
25 | throw new Exception("Falta el uuid."); |
26 | if ($this->nombre === "") |
27 | throw new Exception("Falta el nombre."); |
28 | } |
29 | } |
30 |
1 | /** |
2 | * @template T |
3 | * @param { Promise<IDBDatabase> } bd |
4 | * @param { string[] } entidades |
5 | * @param { ( |
6 | * transaccion: IDBTransaction, |
7 | * resolve: (resultado:T)=>void |
8 | * ) => void } consulta |
9 | * @returns {Promise<T>} |
10 | */ |
11 | export async function bdConsulta(bd, entidades, consulta) { |
12 | |
13 | const base = await bd |
14 | |
15 | return new Promise((resolve, reject) => { |
16 | // Inicia una transacción de solo lectura. |
17 | const transaccion = base.transaction(entidades, "readonly") |
18 | // Al terminar con error ejecuta la función reject. |
19 | transaccion.onerror = () => reject(transaccion.error) |
20 | // Estas son las operaciones para realizar la consulta. |
21 | consulta(transaccion, resolve) |
22 | }) |
23 | |
24 | } |
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 | } |
1 | export function confirmaEliminar() { |
2 | return confirm("Confirma la eliminación") |
3 | } |
4 | |
5 | // Permite que los eventos de html usen la función. |
6 | window["confirmaEliminar"] = confirmaEliminar |
1 | import { invocaServicio } from "./invocaServicio.js" |
2 | |
3 | /** |
4 | * @param { string } url |
5 | * @param { Object } body |
6 | * @param { "GET" | "POST"| "PUT" | "PATCH" | "DELETE" | "TRACE" | "OPTIONS" |
7 | * | "CONNECT" | "HEAD" } metodoHttp |
8 | */ |
9 | export async function enviaJson(url, body, metodoHttp = "POST") { |
10 | return await invocaServicio(fetch(url, { |
11 | method: metodoHttp, |
12 | headers: { |
13 | "Content-Type": "application/json", |
14 | "Accept": "application/json, application/problem+json" |
15 | }, |
16 | body: JSON.stringify(body) |
17 | })) |
18 | } |
19 | |
20 | // Permite que los eventos de html usen la función. |
21 | window["enviaJson"] = enviaJson |
1 | import { |
2 | JsonResponse, JsonResponse_Created, JsonResponse_NoContent, JsonResponse_OK |
3 | } from "./JsonResponse.js" |
4 | import { |
5 | ProblemDetails, ProblemDetails_InternalServerError |
6 | } from "./ProblemDetails.js" |
7 | |
8 | /** |
9 | * Espera a que la promesa de un fetch termine. Si |
10 | * hay error, lanza una excepción. Si no hay error, |
11 | * interpreta la respuesta del servidor como JSON y |
12 | * la convierte en una literal de objeto. |
13 | * @param { string | Promise<Response> } servicio |
14 | */ |
15 | export async function invocaServicio(servicio) { |
16 | let f = servicio |
17 | if (typeof servicio === "string") { |
18 | f = fetch(servicio, { |
19 | headers: { "Accept": "application/json, application/problem+json" } |
20 | }) |
21 | } else if (!(f instanceof Promise)) { |
22 | throw new Error("Servicio de tipo incorrecto.") |
23 | } |
24 | const respuesta = await f |
25 | if (respuesta.ok) { |
26 | if (respuesta.status === JsonResponse_NoContent) { |
27 | return new JsonResponse(JsonResponse_NoContent) |
28 | } |
29 | const texto = await respuesta.text() |
30 | try { |
31 | const body = JSON.parse(texto) |
32 | if (respuesta.status === JsonResponse_Created) { |
33 | const location = respuesta.headers.get("location") |
34 | return new JsonResponse(JsonResponse_Created, body, |
35 | location === null ? undefined : location) |
36 | } else { |
37 | return new JsonResponse(JsonResponse_OK, body) |
38 | } |
39 | } catch (error) { |
40 | // El contenido no es JSON. Probablemente sea texto. |
41 | throw new ProblemDetails(ProblemDetails_InternalServerError, |
42 | "Problema interno en el servidor.", texto) |
43 | } |
44 | } else { |
45 | const texto = await respuesta.text() |
46 | try { |
47 | const { type, title, detail } = JSON.parse(texto) |
48 | throw new ProblemDetails(respuesta.status, |
49 | typeof title === "string" ? title : "", |
50 | typeof detail === "string" ? detail : undefined, |
51 | typeof type === "string" ? type : undefined) |
52 | } catch (error) { |
53 | if (error instanceof ProblemDetails) { |
54 | throw error |
55 | } else { |
56 | // El contenido no es JSON. Probablemente sea texto. |
57 | throw new ProblemDetails(respuesta.status, respuesta.statusText, texto) |
58 | } |
59 | } |
60 | } |
61 | } |
62 | |
63 | // Permite que los eventos de html usen la función. |
64 | window["invocaServicio"] = invocaServicio |
1 | export const JsonResponse_OK = 200 |
2 | export const JsonResponse_Created = 201 |
3 | export const JsonResponse_NoContent = 204 |
4 | |
5 | export class JsonResponse { |
6 | |
7 | /** |
8 | * @param {number} status |
9 | * @param {any} [body] |
10 | * @param {string} [location] |
11 | */ |
12 | constructor(status, body, location) { |
13 | /** @readonly */ |
14 | this.status = status |
15 | /** @readonly */ |
16 | this.body = body |
17 | /** @readonly */ |
18 | this.location = location |
19 | } |
20 | |
21 | } |
1 | import { ProblemDetails } from "./ProblemDetails.js" |
2 | |
3 | /** |
4 | * Muestra un error en la consola y en un cuadro de |
5 | * alerta el mensaje de una excepción. |
6 | * @param { ProblemDetails | Error | null } error descripción del error. |
7 | */ |
8 | export function muestraError(error) { |
9 | if (error === null) { |
10 | console.log("Error") |
11 | alert("Error") |
12 | } else if (error instanceof ProblemDetails) { |
13 | let mensaje = error.title |
14 | if (error.detail) { |
15 | mensaje += `\n\n${error.detail}` |
16 | } |
17 | mensaje += `\n\nCódigo: ${error.status}` |
18 | if (error.type) { |
19 | mensaje += ` ${error.type}` |
20 | } |
21 | console.error(mensaje) |
22 | console.error(error) |
23 | alert(mensaje) |
24 | } else { |
25 | console.error(error) |
26 | alert(error.message) |
27 | } |
28 | } |
29 | |
30 | // Permite que los eventos de html usen la función. |
31 | window["muestraError"] = muestraError |
1 | /** |
2 | * @param { Document | HTMLElement } raizHtml |
3 | * @param { any } objeto |
4 | */ |
5 | export async 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 | const elementoHtml = buscaElementoHtml(raizHtml, nombre) |
11 | if (elementoHtml instanceof HTMLImageElement) { |
12 | await muestraImagen(raizHtml, elementoHtml, definiciones) |
13 | } else if (elementoHtml !== null) { |
14 | for (const [atributo, valor] of Object.entries(definiciones)) { |
15 | if (atributo in elementoHtml) { |
16 | elementoHtml[atributo] = valor |
17 | } |
18 | } |
19 | } |
20 | } |
21 | } |
22 | } |
23 | // Permite que los eventos de html usen la función. |
24 | window["muestraObjeto"] = muestraObjeto |
25 | |
26 | /** |
27 | * @param { Document | HTMLElement } raizHtml |
28 | * @param { string } nombre |
29 | */ |
30 | export function buscaElementoHtml(raizHtml, nombre) { |
31 | return raizHtml.querySelector( |
32 | `#${nombre},[name="${nombre}"],[data-name="${nombre}"]`) |
33 | } |
34 | |
35 | /** |
36 | * @param { Document | HTMLElement } raizHtml |
37 | * @param { string } propiedad |
38 | * @param {any[]} valores |
39 | */ |
40 | function muestraArray(raizHtml, propiedad, valores) { |
41 | const conjunto = new Set(valores) |
42 | const elementos = |
43 | raizHtml.querySelectorAll(`[name="${propiedad}"],[data-name="${propiedad}"]`) |
44 | if (elementos.length === 1) { |
45 | const elemento = elementos[0] |
46 | if (elemento instanceof HTMLSelectElement) { |
47 | const options = elemento.options |
48 | for (let i = 0, len = options.length; i < len; i++) { |
49 | const option = options[i] |
50 | option.selected = conjunto.has(option.value) |
51 | } |
52 | return |
53 | } |
54 | } |
55 | for (let i = 0, len = elementos.length; i < len; i++) { |
56 | const elemento = elementos[i] |
57 | if (elemento instanceof HTMLInputElement) { |
58 | elemento.checked = conjunto.has(elemento.value) |
59 | } |
60 | } |
61 | } |
62 | |
63 | /** |
64 | * @param { Document | HTMLElement } raizHtml |
65 | * @param { HTMLImageElement } img |
66 | * @param { any } definiciones |
67 | */ |
68 | async function muestraImagen(raizHtml, img, definiciones) { |
69 | const input = getInputParaElementoHtml(raizHtml, img) |
70 | const src = definiciones.src |
71 | if (src !== undefined) { |
72 | img.dataset.inicial = src |
73 | if (input === null) { |
74 | img.src = src |
75 | if (src === "") { |
76 | img.hidden = true |
77 | } else { |
78 | img.hidden = false |
79 | } |
80 | } else { |
81 | const dataUrl = await getDataUrlDeSeleccion(input) |
82 | if (dataUrl !== "") { |
83 | img.hidden = false |
84 | img.src = dataUrl |
85 | } else if (src === "") { |
86 | img.src = "" |
87 | img.hidden = true |
88 | } else { |
89 | img.src = src |
90 | img.hidden = false |
91 | } |
92 | } |
93 | } |
94 | for (const [atributo, valor] of Object.entries(definiciones)) { |
95 | if (atributo !== "src" && atributo in img) { |
96 | img[atributo] = valor |
97 | } |
98 | } |
99 | } |
100 | |
101 | /** |
102 | * @param { HTMLInputElement } input |
103 | */ |
104 | export function getArchivoSeleccionado(input) { |
105 | const seleccion = input.files |
106 | if (seleccion === null || seleccion.length === 0) { |
107 | return null |
108 | } else { |
109 | return seleccion.item(0) |
110 | } |
111 | } |
112 | // Permite que los eventos de html usen la función. |
113 | window["getArchivoSeleccionado"] = getArchivoSeleccionado |
114 | |
115 | |
116 | /** |
117 | * @param {HTMLInputElement} input |
118 | * @returns {Promise<string>} |
119 | */ |
120 | export function getDataUrlDeSeleccion(input) { |
121 | return new Promise((resolve, reject) => { |
122 | try { |
123 | const seleccion = getArchivoSeleccionado(input) |
124 | if (seleccion === null) { |
125 | resolve("") |
126 | } else { |
127 | const reader = new FileReader() |
128 | reader.onload = () => { |
129 | const dataUrl = reader.result |
130 | if (typeof dataUrl === "string") { |
131 | resolve(dataUrl) |
132 | } else { |
133 | resolve("") |
134 | } |
135 | } |
136 | reader.onerror = () => reject(reader.error) |
137 | reader.readAsDataURL(seleccion) |
138 | } |
139 | } catch (error) { |
140 | resolve(error) |
141 | } |
142 | }) |
143 | } |
144 | // Permite que los eventos de html usen la función. |
145 | window["getDataUrlDeSeleccion"] = getDataUrlDeSeleccion |
146 | |
147 | /** |
148 | * @param { Document | HTMLElement } raizHtml |
149 | * @param { HTMLElement } elementoHtml |
150 | */ |
151 | export function getInputParaElementoHtml(raizHtml, elementoHtml) { |
152 | const inputId = elementoHtml.getAttribute("data-input") |
153 | if (inputId === null) { |
154 | return null |
155 | } else { |
156 | const input = buscaElementoHtml(raizHtml, inputId) |
157 | if (input instanceof HTMLInputElement) { |
158 | return input |
159 | } else { |
160 | return null |
161 | } |
162 | } |
163 | } |
1 | export const ProblemDetails_BadRequest = 400 |
2 | export const ProblemDetails_NotFound = 404 |
3 | export const ProblemDetails_InternalServerError = 500 |
4 | |
5 | export class ProblemDetails extends Error { |
6 | |
7 | /** |
8 | * @param {number} status |
9 | * @param {string} title |
10 | * @param {string} [detail] |
11 | * @param {string} [type] |
12 | */ |
13 | constructor(status, title, detail, type) { |
14 | super(title) |
15 | /** @readonly */ |
16 | this.status = status |
17 | /** @readonly */ |
18 | this.type = type |
19 | /** @readonly */ |
20 | this.title = title |
21 | /** @readonly */ |
22 | this.detail = detail |
23 | } |
24 | |
25 | } |
1 | import { muestraError } from "./muestraError.js" |
2 | |
3 | /** |
4 | * @param { string | URL } urlDeServiceWorker |
5 | */ |
6 | export async function registraServiceWorkerSiEsSoportado(urlDeServiceWorker) { |
7 | try { |
8 | if ("serviceWorker" in navigator) { |
9 | const registro = await navigator.serviceWorker.register(urlDeServiceWorker) |
10 | console.log(urlDeServiceWorker, "registrado.") |
11 | console.log(registro) |
12 | } |
13 | } catch (error) { |
14 | muestraError(error) |
15 | } |
16 | } |
1 | <?php |
2 | |
3 | require_once __DIR__ . "/JsonResponse.php"; |
4 | require_once __DIR__ . "/ProblemDetails.php"; |
5 | |
6 | /** |
7 | * Ejecuta una funcion que implementa un servicio. |
8 | */ |
9 | function ejecutaServicio($servicio) |
10 | { |
11 | try { |
12 | $resultado = $servicio(); |
13 | if (!($resultado instanceof JsonResponse)) { |
14 | $resultado = JsonResponse::ok($resultado); |
15 | } |
16 | procesa_json_response($resultado); |
17 | } catch (ProblemDetails $details) { |
18 | procesa_problem_details($details); |
19 | } catch (Throwable $throwable) { |
20 | procesa_problem_details(new ProblemDetails( |
21 | status: ProblemDetails::InternalServerError, |
22 | type: "/error/errorinterno.html", |
23 | title: "Error interno del servidor.", |
24 | detail: $throwable->getMessage() |
25 | )); |
26 | } |
27 | } |
28 | |
29 | function procesa_json_response(JsonResponse $response) |
30 | { |
31 | $json = ""; |
32 | $body = $response->body; |
33 | if ($response->status !== JsonResponse_NoContent) { |
34 | $json = json_encode($body); |
35 | if ($json === false) { |
36 | no_puede_generar_json(); |
37 | return; |
38 | } |
39 | } |
40 | http_response_code($response->status); |
41 | if ($response->location !== null) { |
42 | header("Location: {$response->location}"); |
43 | } |
44 | if ($response->status !== JsonResponse_NoContent) { |
45 | header("Content-Type: application/json"); |
46 | echo $json; |
47 | } |
48 | } |
49 | |
50 | function procesa_problem_details(ProblemDetails $details) |
51 | { |
52 | $body = ["title" => $details->title]; |
53 | if ($details->type !== null) { |
54 | $body["type"] = $details->type; |
55 | } |
56 | if ($details->detail !== null) { |
57 | $body["detail"] = $details->detail; |
58 | } |
59 | $json = json_encode($body); |
60 | if ($json === false) { |
61 | no_puede_generar_json(); |
62 | } else { |
63 | http_response_code($details->status); |
64 | header("Content-Type: application/problem+json"); |
65 | echo $json; |
66 | } |
67 | } |
68 | |
69 | function no_puede_generar_json() |
70 | { |
71 | http_response_code(ProblemDetails::InternalServerError); |
72 | header("Content-Type: application/problem+json"); |
73 | echo '{"type":"/error/nojson.html"' |
74 | . ',"title":"El valor devuelto no puede representarse como JSON."}'; |
75 | } |
76 |
1 | <?php |
2 | |
3 | const JsonResponse_OK = 200; |
4 | const JsonResponse_Created = 201; |
5 | const JsonResponse_NoContent = 204; |
6 | |
7 | class JsonResponse |
8 | { |
9 | |
10 | public int $status; |
11 | public $body; |
12 | public ?string $location; |
13 | |
14 | public function __construct( |
15 | int $status = JsonResponse_OK, |
16 | $body = null, |
17 | ?string $location = null |
18 | ) { |
19 | $this->status = $status; |
20 | $this->body = $body; |
21 | $this->location = $location; |
22 | } |
23 | |
24 | public static function ok($body) |
25 | { |
26 | return new JsonResponse(body: $body); |
27 | } |
28 | |
29 | public static function created(string $location, $body) |
30 | { |
31 | return new JsonResponse(JsonResponse_Created, $body, $location); |
32 | } |
33 | |
34 | public static function noContent() |
35 | { |
36 | return new JsonResponse(JsonResponse_NoContent, null); |
37 | } |
38 | } |
39 |
1 | <?php |
2 | |
3 | function leeJson() |
4 | { |
5 | return json_decode(file_get_contents("php://input")); |
6 | } |
7 |
1 | <?php |
2 | |
3 | class ProblemDetails extends Exception |
4 | { |
5 | |
6 | public const BadRequest = 400; |
7 | public const NotFound = 404; |
8 | public const InternalServerError = 500; |
9 | |
10 | public int $status; |
11 | public string $title; |
12 | public ?string $type; |
13 | public ?string $detail; |
14 | |
15 | public function __construct( |
16 | int $status, |
17 | string $title, |
18 | ?string $type = null, |
19 | ?string $detail = null, |
20 | Throwable $previous = null |
21 | ) { |
22 | parent::__construct($title, $status, $previous); |
23 | $this->status = $status; |
24 | $this->type = $type; |
25 | $this->title = $title; |
26 | $this->detail = $detail; |
27 | } |
28 | } |
29 |
1 | <?php |
2 | |
3 | function recibeFetchAll(false|array $resultado): array |
4 | { |
5 | if ($resultado === false) { |
6 | return []; |
7 | } else { |
8 | return $resultado; |
9 | } |
10 | } |
11 |
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 booleano</title> |
10 | |
11 | <body> |
12 | |
13 | <h1>El campo eliminado debe ser booleano</h1> |
14 | |
15 | </body> |
16 | |
17 | </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> |
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> |
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 valor devuelto no puede representarse como JSON</title> |
10 | |
11 | </head> |
12 | |
13 | <body> |
14 | |
15 | <h1>El valor devuelto no puede representarse como JSON</h1> |
16 | |
17 | <p> |
18 | Debido a un error interno del servidor, la respuesta generada, no se puede |
19 | recuperar. |
20 | </p> |
21 | |
22 | </body> |
23 | |
24 | </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</title> |
10 | |
11 | <body> |
12 | |
13 | <h1>El nombre debe ser texto</h1> |
14 | |
15 | </body> |
16 | |
17 | </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 uuid debe ser texto</title> |
10 | |
11 | <body> |
12 | |
13 | <h1>El uuid debe ser texto</h1> |
14 | |
15 | </body> |
16 | |
17 | </html> |
Este archivo ayuda a detectar errores en los archivos del proyecto.
Lo utiliza principalmente Visual Studio Code.
No se explica aquí su estructura, pero puede encontrarse la explicación de todo en la documentación del sitio de Visual Studio Code.
1 | { |
2 | "compilerOptions": { |
3 | "checkJs": true, |
4 | "strictNullChecks": true, |
5 | "target": "ES6", |
6 | "module": "ES6", |
7 | "moduleResolution": "classic", |
8 | "lib": [ |
9 | "ES2017", |
10 | "WebWorker", |
11 | "DOM" |
12 | ] |
13 | } |
14 | } |
En esta lección se muestra un ejemplo de sincronización de bases de datos.