El día de hoy te enseñaré una aplicación simple, gratuita y open source para gestionar notas y listas (To Do app). Está hecha con JavaScript usando Svelte, utiliza SQLite3 para el almacenamiento, Tailwind para el diseño y se agrega encriptación con la web crypto API.
Con esta app de notas podrás agregar notas y listas de pendientes, además de poder encriptarlas usando AES en modo CBC. Cada nota y cada lista puede ser encriptada de manera independiente y con una contraseña distinta, generado el vector de inicialización y derivando la clave a partir de una contraseña.
Puedes agregar etiquetas a cada lista y nota, para que puedas filtrarlas más adelante.
Lo mejor de esto es que la app puede ser instalada como una aplicación nativa gracias al poder de las PWA, así que puedes usarla en móviles y dispositivos de escritprio.
Me emocioné tanto con la llegada de SQLite3 a la web con OPFS que hice esta app para aprender Svelte y Tailwind, pero terminé haciendo también la app de cumpleaños y luego experimenté un poco con WASM.
Veamos entonces esta webapp de tareas pendientes totalmente open source.
Mostrar notas y listas
En la base de datos, cada lista y nota es independiente. Además, los elementos de las listas se guardan en otra tabla. Entonces para empezar hay 3 tablas: las notas, las listas y los elementos de las listas.
Para mostrar las listas y notas combinadas primero se obtienen por separado, después se combinan con concat
y finalmente se ordenan por la fecha de creación.
const mostrarTodasLasListasYNotas = async () => {
listas = await notasService.obtenerListas(busqueda);
notas = await notasService.obtenerNotas(busqueda);
notasYListasCombinadas = listas.concat(notas);
notasYListasCombinadas.sort((notaOLista, otraNotaOLista) => {
return otraNotaOLista.fechaCreacion - notaOLista.fechaCreacion;
});
cargando = false;
};
Una vez que tenemos la lista combinada como un Array, las mostramos con Svelte:
{#each notasYListasCombinadas as notaOLista}
{#if notaOLista.contenido}
<VistaPreviaDeNota on:editarNota={editarNota} nota={notaOLista} />
{:else}
<VistaPreviaDeLista
on:editarLista={editarLista}
lista={notaOLista}
/>
{/if}
{/each}
Para diferenciar si es una nota o una lista se comprueba la propiedad contenido
; las notas tienen esa propiedad, las listas no. Estoy utilizando componentes de Svelte para mostrar las vistas previas. Por ejemplo, el de la lista se ve así:
<script>
import { resolverFondo } from "../fondos.js";
export let lista;
import { createEventDispatcher } from "svelte";
import ListarEtiquetas from "./ListarEtiquetas.svelte";
import { Icon, LockClosed } from "svelte-hero-icons";
const dispatch = createEventDispatcher();
const editarLista = (lista) => {
dispatch("editarLista", lista);
};
</script>
<div
on:click={() => {
editarLista(lista);
}}
class="relative w-full items-center overflow-hidden p-4 shadow-sm rounded-lg"
>
<div
style="background-image: url('{resolverFondo(
lista.fondo
)}'); z-index: -1; height: 100%; position: absolute; top: 0; left:0; width: 100%; opacity: 0.1;"
/>
<h1 class="text-lg font-bold my-4 text-amber-950 fuente-titulo">
{lista.titulo}
</h1>
<div class="space-y-3 mb-2">
{#each lista.elementos as elemento, indice}
<div class="flex items-center">
<input
disabled
name="comments"
type="checkbox"
checked={elemento.terminado}
class="rounded border-gray-300 text-pink-400 focus:ring-pink-400 h-4 w-4"
class:invisible={lista.encriptada}
/>
<div
id={"elemento_" + indice}
class:tachado={elemento.terminado}
class="flex-1 input-elemento-lista text-sm ml-2 text-amber-950"
class:desenfocado={lista.encriptada}
>
{elemento.contenido}
</div>
</div>
{/each}
</div>
{#if lista.encriptada}
<Icon src={LockClosed} class="w-4 h-4 text-green-500" />
{/if}
<ListarEtiquetas etiquetas={lista.etiquetas} />
</div>
De hecho hay varios componentes para esta app de pendientes, pues también existe el componente que muestra las etiquetas, pero eso lo podrás ver en los vídeos del desarrollo de la app o en GitHub.
La encriptación
En esta aplicación open source con JavaScript podemos encriptar el contenido de las notas y listas usando la Web Crypto API y el algoritmo AES con CBC, derivando la clave a partir de una contraseña y generando una sal; todo muy seguro.
Ya te había mostrado cómo encriptar información con JavaScript en el lado del cliente en otro post, así que esto no es nuevo. Lo nuevo es encriptar el contenido cada vez que se guarda una nota o lista:
async desencriptar(contraseña, encriptadoEnBase64) {
const decoder = new TextDecoder();
const datosEncriptados = encriptacionService.base64ABuffer(encriptadoEnBase64);
const sal = datosEncriptados.slice(0, LONGITUD_SAL);
const vectorInicializacion = datosEncriptados.slice(0 + LONGITUD_SAL, LONGITUD_SAL + LONGITUD_VECTOR_INICIALIZACION);
const clave = await encriptacionService.derivacionDeClaveBasadaEnContraseña(contraseña, sal, ITERACIONES, LONGITUD_CLAVE, 'SHA-256');
const datosDesencriptadosComoBuffer = await window.crypto.subtle.decrypt(
{ name: "AES-CBC", iv: vectorInicializacion },
clave,
datosEncriptados.slice(LONGITUD_SAL + LONGITUD_VECTOR_INICIALIZACION)
);
return decoder.decode(datosDesencriptadosComoBuffer);
},
async encriptar(contraseña, textoPlano) {
const encoder = new TextEncoder();
const sal = window.crypto.getRandomValues(new Uint8Array(LONGITUD_SAL));
const vectorInicializacion = window.crypto.getRandomValues(new Uint8Array(LONGITUD_VECTOR_INICIALIZACION));
const bufferTextoPlano = encoder.encode(textoPlano);
const clave = await encriptacionService.derivacionDeClaveBasadaEnContraseña(contraseña, sal, ITERACIONES, LONGITUD_CLAVE, 'SHA-256');
const encriptado = await window.crypto.subtle.encrypt(
{ name: "AES-CBC", iv: vectorInicializacion },
clave,
bufferTextoPlano
);
return encriptacionService.bufferABase64([
...sal,
...vectorInicializacion,
...new Uint8Array(encriptado)
]);
},
Te he mostrado lo más importante, obviamente en el código fuente completo podrás ver todas las funciones.
Después, en la vista previa solo agrego la clase “desenfocado” si la nota está encriptada. Esto es solo para que se vea “seguro”, pero realmente el contenido es una cadena en base64 que contiene la información encriptada y que nadie puede desencriptar al menos que tenga la contraseña.
El estilo CSS es el siguiente:
.desenfocado {
color: transparent;
text-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
}
Menú
Cuando tocas el botón para agregar una cosa, puedes elegir entre agregar una nota, lista, nota encriptada o lista encriptada. Ese menú está hecho con Svelte y se ve así:
<script>
let mostrarMenu = false;
import { link } from "svelte-spa-router";
import { Icon, PlusCircle } from "svelte-hero-icons";
import { fly } from "svelte/transition";
</script>
<div class="fixed right-0 bottom-0 origin-bottom-right text-right z-10">
{#if mostrarMenu}
<div
class="mr-2 w0 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
role="menu"
aria-orientation="vertical"
aria-labelledby="menu-button"
transition:fly
tabindex="-1"
>
<div class="py-1" role="none">
<a
href="/agregar-lista/normal"
use:link
class="text-gray-700 block px-4 py-2 text-sm"
role="menuitem"
tabindex="-1">Lista</a
>
<a
href="/agregar-nota/normal"
use:link
class="text-gray-700 block px-4 py-2 text-sm"
role="menuitem"
tabindex="-1">Nota</a
>
<a
href="/agregar-nota/encriptada"
use:link
class="text-gray-700 block px-4 py-2 text-sm"
role="menuitem"
tabindex="-1">Nota protegida</a
>
<a
href="/agregar-lista/encriptada"
use:link
class="text-gray-700 block px-4 py-2 text-sm"
role="menuitem"
tabindex="-1">Lista protegida</a
>
</div>
</div>
{/if}
<button
on:click={() => {
mostrarMenu = !mostrarMenu;
}}
class="right-0 w-12 h-12 text-pink-400"
>
<Icon src={PlusCircle} solid />
</button>
</div>
Los elementos del menú son simples enlaces que llevan a otro componente de la app, ya que estoy usando un enrutador para Svelte. Lo demás está hecho con animaciones propias de Svelte así como iconos y HTML.
Base de datos
Como lo dije anteriormente, esta aplicación web de elementos pendientes usa SQLite3 para almacenar todos los datos. Utiliza relaciones entre las tablas y consultas SQL.
Debido a que solo se puede interactuar con OPFS desde un Web Worker, he expuesto el funcionamiento con Comlink. Al final queda así:
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
import * as Comlink from "comlink"
const NOMBRE_BASE_DE_DATOS = "notas.sqlite3";
const log = (...args) => { console.log(...args) };
const error = (...args) => { console.log(...args) };
class EnvolturaDeBaseDeDatos {
db = null;
async iniciar() {
const sqlite3 = await sqlite3InitModule({
print: log,
printErr: error,
});
if ('opfs' in sqlite3) {
this.db = new sqlite3.oo1.OpfsDb(NOMBRE_BASE_DE_DATOS);
log('OPFS is available, created persisted database at', this.db.filename);
} else {
this.db = new sqlite3.oo1.DB(NOMBRE_BASE_DE_DATOS, 'ct');
log('OPFS is not available, created transient database', this.db.filename);
}
this.db.exec(`CREATE TABLE IF NOT EXISTS listas(
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
fechaCreacion INTEGER NOT NULL,
ultimaModificacion INTEGER NOT NULL,
encriptada INTEGER NOT NULL DEFAULT 0
);`);
this.db.exec(`CREATE TABLE IF NOT EXISTS elementos_listas(
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_lista INTEGER,
contenido TEXT NOT NULL,
terminado INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (id_lista) REFERENCES listas(id) ON DELETE CASCADE ON UPDATE CASCADE
);`);
this.db.exec(`CREATE TABLE IF NOT EXISTS fondos_listas(
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_lista INTEGER,
nombre TEXT NOT NULL,
FOREIGN KEY (id_lista) REFERENCES listas(id) ON DELETE CASCADE ON UPDATE CASCADE
);`);
this.db.exec(`CREATE TABLE IF NOT EXISTS etiquetas(
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL
);`);
this.db.exec(`CREATE TABLE IF NOT EXISTS etiquetas_listas(
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_lista INTEGER,
id_etiqueta INTEGER,
FOREIGN KEY (id_lista) REFERENCES listas(id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (id_etiqueta) REFERENCES etiquetas(id) ON DELETE CASCADE ON UPDATE CASCADE
);`);
this.db.exec(`CREATE TABLE IF NOT EXISTS notas(
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
contenido TEXT NOT NULL,
fechaCreacion INTEGER NOT NULL,
ultimaModificacion INTEGER NOT NULL,
encriptada INTEGER NOT NULL DEFAULT 0
);`);
this.db.exec(`CREATE TABLE IF NOT EXISTS fondos_notas(
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_nota INTEGER,
nombre TEXT NOT NULL,
FOREIGN KEY (id_nota) REFERENCES notas(id) ON DELETE CASCADE ON UPDATE CASCADE
);`);
this.db.exec(`CREATE TABLE IF NOT EXISTS etiquetas_notas(
id INTEGER PRIMARY KEY AUTOINCREMENT,
id_nota INTEGER,
id_etiqueta INTEGER,
FOREIGN KEY (id_nota) REFERENCES notas(id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (id_etiqueta) REFERENCES etiquetas(id) ON DELETE CASCADE ON UPDATE CASCADE
);`);
this.exponerFuncionesDeDB();
}
constructor() {
}
exponerFuncionesDeDB() {
for (const key in this.db) {
if (typeof this.db[key] === 'function') {
this[key] = this.db[key].bind(this.db);
}
}
}
}
Comlink.expose(EnvolturaDeBaseDeDatos);
Para que la aplicación tenga un rendimiento aceptable he colocado la base de datos en un singleton usando las stores de Svelte, así que ahora en donde sea que quiera interactuar con la BD primero hago un servicio:
export class EtiquetasService {
baseDeDatos = null;
constructor(baseDeDatos) {
this.baseDeDatos = baseDeDatos;
}
async quitarEtiquetaDeNota(idNota, idEtiqueta) {
return new Promise(async (resolve, reject) => {
try {
await this.baseDeDatos.exec({
sql: "DELETE FROM etiquetas_notas WHERE id_nota = ? AND id_etiqueta = ?",
bind: [idNota, idEtiqueta],
rowMode: "object",
});
resolve(true);
} catch (error) {
reject(error);
}
});
}
async agregarEtiquetaANota(idNota, idEtiqueta) {
return new Promise(async (resolve, reject) => {
try {
const etiquetas = await this.baseDeDatos.exec({
sql: `INSERT INTO etiquetas_notas (id_nota, id_etiqueta)
VALUES
(?, ?)`,
bind: [idNota, idEtiqueta],
rowMode: "object",
});
resolve(etiquetas);
} catch (error) {
reject(error);
}
});
}
async quitarEtiquetaDeLista(idLista, idEtiqueta) {
return new Promise(async (resolve, reject) => {
try {
await this.baseDeDatos.exec({
sql: "DELETE FROM etiquetas_listas WHERE id_lista = ? AND id_etiqueta = ?",
bind: [idLista, idEtiqueta],
rowMode: "object",
});
resolve(true);
} catch (error) {
reject(error);
}
});
}
async agregarEtiquetaALista(idLista, idEtiqueta) {
return new Promise(async (resolve, reject) => {
try {
const etiquetas = await this.baseDeDatos.exec({
sql: `INSERT INTO etiquetas_listas (id_lista, id_etiqueta)
VALUES
(?, ?)`,
bind: [idLista, idEtiqueta],
rowMode: "object",
});
resolve(etiquetas);
} catch (error) {
reject(error);
}
});
}
async obtenerEtiquetasDeNota(idNota) {
return new Promise(async (resolve, reject) => {
try {
const etiquetas = await this.baseDeDatos.exec({
sql: `SELECT etiquetas.id, nombre FROM etiquetas
INNER JOIN
etiquetas_notas ON etiquetas_notas.id_etiqueta = etiquetas.id
WHERE etiquetas_notas.id_nota = ?`,
bind: [idNota],
rowMode: "object",
});
resolve(etiquetas);
} catch (error) {
reject(error);
}
});
}
async obtenerEtiquetasDeLista(idLista) {
return new Promise(async (resolve, reject) => {
try {
const etiquetas = await this.baseDeDatos.exec({
sql: `SELECT etiquetas.id, nombre FROM etiquetas
INNER JOIN
etiquetas_listas ON etiquetas_listas.id_etiqueta = etiquetas.id
WHERE etiquetas_listas.id_lista = ?`,
bind: [idLista],
rowMode: "object",
});
resolve(etiquetas);
} catch (error) {
reject(error);
}
});
}
async guardarEtiqueta(nombre) {
return new Promise(async (resolve, reject) => {
try {
const filasInsertadas = await this.baseDeDatos.exec({
sql: "INSERT INTO etiquetas(nombre) VALUES (?) RETURNING id, nombre",
bind: [nombre,],
rowMode: "object",
returnValue: "resultRows"
});
if (filasInsertadas.length <= 0) {
reject("Error insertando");
}
const etiquetaRecienInsertada = filasInsertadas[0];
resolve(etiquetaRecienInsertada);
} catch (error) {
reject(error);
}
});
}
async buscarEtiquetas(busqueda) {
return new Promise(async (resolve, reject) => {
try {
const etiquetas = await this.baseDeDatos.exec({
sql: `SELECT id, nombre FROM etiquetas WHERE nombre LIKE ?`,
bind: [`%${busqueda}%`],
rowMode: "object",
});
resolve(etiquetas);
} catch (error) {
reject(error);
}
});
}
async obtenerEtiquetas() {
return new Promise(async (resolve, reject) => {
try {
const etiquetas = await this.baseDeDatos.exec({
sql: `SELECT id, nombre FROM etiquetas`,
rowMode: "object",
});
resolve(etiquetas);
} catch (error) {
reject(error);
}
});
}
}
Y en el constructor del servicio espero recibir la instancia de la base de datos. Ahora dentro de los componentes simplemente lo invoco usando el store previamente mencionado. Por ejemplo, en el onMount
:
onMount(async () => {
singleton.subscribe(async (bd) => {
etiquetasService = new EtiquetasService(bd);
notasService = new NotasService(bd);
const notaOLista = esLista()
? await notasService.obtenerListaPorId(params.id)
: await notasService.obtenerNotaPorId(params.id);
estaEncriptada = notaOLista.encriptada;
await obtenerEtiquetasDeListaONota();
buscarEtiquetas();
});
});
De este modo solo tengo una instancia de la base de datos; las consultas (DELETE, SELECT, UPDATE e INSERT) las hago en los servicios. Con ello puedo invocar a los servicios desde cualquier lugar pasándoles la base de datos gracias al singleton.
Poniendo todo junto
Te he explicado los detalles más importantes, pero si quieres puedes estudiar el código fuente completo en GitHub: https://github.com/parzibyte/notas_sqlite3_svelte/
También he grabado casi todo el proceso de creación de esta webapp, ya que aunque parezca sencillo no lo es. Puedes revisar las mejoras aplicadas y las complicaciones encontradas en la siguiente lista de reproducción: https://www.youtube.com/playlist?list=PLat1rFhO_zZiYdjPRpCGkAYSFWSDsoR9e
Hice esta app para probar el rendimiento de SQLite3 en el lado del cliente, y al parecer todo funciona de maravilla, así que seguramente escribiré más programas usando estas tecnologías. También me gustó la facilidad de las stores de Svelte y los estilos de Tailwind.
Para terminar te dejo con más tutoriales de JavaScript y Svelte en mi blog.
Excelente trabajo. muy util y super sencillo.
Muchas Gracias!