Vuelca el catálogo a PostgreSQL, y lo comprueba

Los 48 archivos entran en la base: 18 canciones con sus 5 versiones como
filas hermanas, 4 discos, 4 estilos, 8 géneros, 14 capítulos, 9 personas,
53 etiquetas, 2 entradas, 5 legales y una página. La web sigue leyendo de
los archivos; esto solo llena el sitio donde caben.

Reutiliza los analizadores del sitio en vez de leer el frontmatter otra
vez. Es la decisión que sostiene todo lo demás: si un valor por defecto o
una normalización cambian, cambian para los dos. Un segundo analizador
«parecido» es lo que garantiza que un día la base y la web cuenten cosas
distintas sin que nadie lo note.

Todo en una transacción, y al final comprueba lo escrito contra lo leído
—la letra de cada tema carácter a carácter— porque «no dio error» no es lo
mismo que «está bien». Si no cuadra, deshace y dice qué falló. Y es
idempotente: se puede lanzar las veces que haga falta sin duplicar nada,
sin tocar cuentas, pedidos ni compras.

Para poder reutilizar ese código desde Node hizo falta un gancho de
resolución —`$lib`, imports sin extensión y un sustituto de `$app/paths`—
y quitar una propiedad de parámetro de `ErrorDeContenido`: es sintaxis
exclusiva de TypeScript y ningún cargador que solo borre tipos puede con
ella.

Y una corrección: el cuerpo del Markdown de una canción NO era un
comentario, es la letra. Lo dije al revés en el documento del modelo y lo
repetí varias veces. El campo `letra:` del frontmatter —que aparece en un
solo archivo— son los créditos de quién la firma. Corregido en el
documento, en el esquema, y quitada la columna `nota` que sobraba.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 4 weeks ago
parent 1a8c904ae8
commit 54df820056

@ -13,9 +13,17 @@ para leerse. Cuando se implemente, la verdad pasa a ser
## Por qué
Lo que hay hoy en `src/content/**` son registros disfrazados de documento: una
canción tiene veinte campos estructurados en YAML —`registro`, `duracion`,
`interpretes`, `versiones`, `videos`, `etiquetas`— y la letra entera también va
en el frontmatter. El cuerpo en Markdown son cuatro líneas de comentario.
canción tiene **veintiún campos estructurados en YAML** —`registro`, `duracion`,
`interpretes`, `versiones`, `videos`, `etiquetas`…— y el archivo entero existe
para sostenerlos.
> **Corrección.** Una versión anterior de este documento decía que la letra
> también iba en el frontmatter y que el cuerpo del Markdown eran cuatro líneas
> de comentario. **Es al revés**: el cuerpo del Markdown es la letra, y el campo
> `letra:` —que aparece en un solo archivo— son los créditos de quién la firma.
> Se vio al escribir el volcado. No cambia la conclusión, pero sí el reparto: la
> letra es prosa legítima y se queda como texto; lo que no debía estar en YAML
> son los otros veintiún campos.
Dos hallazgos concretos, no cuestión de gusto:

@ -0,0 +1 @@
ALTER TABLE "cancion" DROP COLUMN "nota";

File diff suppressed because it is too large Load Diff

@ -29,6 +29,13 @@
"when": 1788878096657,
"tag": "0003_valores_por_defecto",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1788879010816,
"tag": "0004_letra_es_el_cuerpo",
"breakpoints": true
}
]
}

10
package-lock.json generated

@ -18,6 +18,7 @@
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22",
"@types/pg": "^8.23.1",
"@vitest/browser-playwright": "^4.1.8",
@ -2197,6 +2198,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -6248,7 +6256,7 @@
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"

@ -23,7 +23,8 @@
"predev": "node scripts/medios.mjs",
"prebuild": "node scripts/medios.mjs",
"db:guardas": "node --env-file=.env scripts/db/comprobar-guardas.mjs",
"db:probar": "node --env-file=.env scripts/db/probar-migracion.mjs"
"db:probar": "node --env-file=.env scripts/db/probar-migracion.mjs",
"db:volcar": "node --experimental-strip-types --import ./scripts/db/resolver-lib.mjs --env-file=.env scripts/db/volcar.mjs"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@ -31,6 +32,7 @@
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22",
"@types/pg": "^8.23.1",
"@vitest/browser-playwright": "^4.1.8",

@ -0,0 +1,12 @@
/**
* Sustituto de `$app/paths` para los scripts.
*
* `site.ts` lo usa para componer las direcciones de la navegación, y el volcado
* solo necesita de ese archivo el nombre del artista —la autoría por defecto de
* cada canción—. Sin base configurada, `resolve` es la identidad, que es
* exactamente lo que hace SvelteKit cuando el sitio vive en la raíz de su
* dominio, como este.
*/
export const resolve = (ruta) => ruta;
export const base = '';
export const assets = '';

@ -0,0 +1,57 @@
/**
* El gancho de resolución para poder cargar código del sitio desde un script.
*
* Hace dos cosas que Vite hace y Node no:
*
* - `$lib/x` pasa a ser `src/lib/x`.
* - Los imports sin extensión —`./fields`, `$lib/utils/date`— encuentran su
* `.ts`. En ESM, Node exige la ruta completa; el código del proyecto se
* escribe como se escribe con un empaquetador delante.
*
* Existe para que el volcado pueda reutilizar los analizadores del sitio en
* lugar de copiarlos. Una copia «parecida» es lo que garantiza que un día la
* base y la web cuenten cosas distintas y nadie lo note.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const RAIZ_LIB = path.resolve('src/lib');
/**
* Los módulos que SvelteKit inyecta en tiempo de compilación y fuera de la
* aplicación no existen. Se sustituyen por lo mínimo que necesita el código que
* el volcado reutiliza; `$app/types` no aparece porque solo se importa como
* tipo y el borrado de tipos se lo lleva por delante.
*/
const SUSTITUTOS = {
'$app/paths': path.resolve('scripts/db/app-paths.mjs')
};
/** Prueba el archivo tal cual, con extensión, y como carpeta con índice. */
function primeroQueExista(base) {
const candidatos = [base, `${base}.ts`, `${base}.js`, `${base}.mjs`, path.join(base, 'index.ts')];
return candidatos.find((c) => fs.existsSync(c) && fs.statSync(c).isFile());
}
export function resolve(especificador, contexto, siguiente) {
if (SUSTITUTOS[especificador]) {
return { url: pathToFileURL(SUSTITUTOS[especificador]).href, shortCircuit: true };
}
if (especificador.startsWith('$lib/')) {
const encontrado = primeroQueExista(path.join(RAIZ_LIB, especificador.slice(5)));
if (!encontrado) throw new Error(`No encuentro ${especificador}`);
return { url: pathToFileURL(encontrado).href, shortCircuit: true };
}
// Relativo y sin extensión: se completa igual que haría el empaquetador.
if (especificador.startsWith('.') && !path.extname(especificador) && contexto.parentURL) {
const desde = path.dirname(fileURLToPath(contexto.parentURL));
const encontrado = primeroQueExista(path.resolve(desde, especificador));
if (encontrado) return { url: pathToFileURL(encontrado).href, shortCircuit: true };
}
return siguiente(especificador, contexto);
}

@ -0,0 +1,16 @@
/**
* Enseña a Node lo que significa `$lib/…`.
*
* Los analizadores de contenido son código del sitio y usan el alias que
* resuelve Vite. El volcado los reutiliza a propósito —para que lo escrito en
* la base no pueda diferir de lo que enseña la web—, así que hay que darle a
* Node la misma equivalencia en vez de copiar los analizadores, que es lo que
* garantizaría que un día dejen de coincidir.
*
* Se engancha con `--import ./scripts/db/resolver-lib.mjs`.
*/
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
register('./gancho-lib.mjs', pathToFileURL('./scripts/db/'));

@ -0,0 +1,643 @@
/**
* Vuelca el contenido de `src/content/**` a PostgreSQL.
*
* npm run db:volcar # vuelca y comprueba
* npm run db:volcar -- --seco # solo dice qué haría
*
* ## Por qué reutiliza los analizadores del sitio
*
* En vez de volver a leer el frontmatter a mi manera, este script pasa cada
* archivo por `src/lib/content/schema.ts`, que es exactamente lo que hace la
* web hoy. Así lo volcado no puede diferir de lo que se ve: si un valor por
* defecto o una normalización cambian, cambian para los dos. Escribir un
* segundo analizador «parecido» es la forma más fácil de que la base y la web
* cuenten cosas distintas y nadie lo note.
*
* ## Es idempotente
*
* Se puede lanzar las veces que haga falta. Borra lo volcado antes de volver a
* escribirlo, y no toca nada de lo que generan las personas —cuentas, pedidos,
* compras—: esas tablas ni se miran.
*
* ## Todo en una transacción
*
* Si algo falla a mitad, no queda medio catálogo dentro. Y al final comprueba
* que lo escrito coincide con lo leído, porque «no dio error» no es lo mismo
* que «está bien».
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
// Con nombre y no por defecto: js-yaml 4 es CommonJS y su interoperabilidad
// con modulos no expone un `default`.
import { load as leerYaml } from 'js-yaml';
import pg from 'pg';
import {
parsearAlbum,
parsearCancion,
parsearCapitulo,
parsearEntrada,
parsearEstilo,
parsearLegal,
parsearPagina
} from '../../src/lib/content/schema.ts';
import { extraerLineas } from '../../src/lib/content/extracto.ts';
const SECO = process.argv.includes('--seco');
const RAIZ = 'src/content';
/* -------------------------------------------------------------------------
* Leer los archivos
* ---------------------------------------------------------------------- */
/** Separa el frontmatter del cuerpo. */
function partir(texto) {
const lineas = texto.split(/\r?\n/);
if (lineas[0]?.trim() !== '---') return { datos: {}, cuerpo: texto.trim() };
const cierre = lineas.indexOf('---', 1);
if (cierre === -1) return { datos: {}, cuerpo: texto.trim() };
return {
datos: leerYaml(lineas.slice(1, cierre).join('\n')) ?? {},
cuerpo: lineas
.slice(cierre + 1)
.join('\n')
.trim()
};
}
function leerCarpeta(carpeta, parser) {
const dir = path.join(RAIZ, carpeta);
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((n) => n.endsWith('.md'))
.sort()
.map((nombre) => {
const ruta = path.join(dir, nombre);
const { datos, cuerpo } = partir(fs.readFileSync(ruta, 'utf8'));
const slug = nombre.replace(/\.md$/, '');
return { slug, cuerpo, ruta, datos: parser(datos, slug, ruta) };
});
}
/* -------------------------------------------------------------------------
* Escribir
* ---------------------------------------------------------------------- */
const nuevoId = () => crypto.randomUUID();
/** Normaliza una etiqueta a slug, como hace el sitio. */
const slugificar = (nombre) =>
nombre
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
async function volcar(c, contenido, informe) {
const { estilos, albumes, canciones, capitulos, entradas, legales, paginas } = contenido;
/* --- Se borra lo volcado antes de reescribirlo -----------------------
*
* En orden inverso a las dependencias. Las tablas de cuentas, pedidos y
* compras no aparecen aquí ni por asomo: esas las llenan las personas.
*/
const aVaciar = [
'capitulo_acepcion',
'genero_tema',
'cancion_genero',
'genero_seccion',
'genero_alias',
'genero_pais',
'genero_origen',
'genero_metro',
'genero_compas',
'genero_instrumento',
'genero_persona',
'obra_referencia_persona',
'obra_referencia',
'capitulo',
'genero',
'entrada_cancion',
'entrada_etiqueta',
'entrada',
'version_legal',
'documento_legal',
'pagina',
'autoria',
'registro_obra',
'cancion_persona',
'cancion_enlace',
'cancion_etiqueta',
'traduccion',
'video',
'cancion',
'album_enlace',
'credito_album',
'album',
'etiqueta',
'estilo',
'persona'
];
for (const tabla of aVaciar) await c.query(`DELETE FROM "${tabla}"`);
/* --- Personas ---------------------------------------------------------
*
* Salen de los créditos, que hoy son cadenas sueltas repetidas en cada
* archivo. Aquí se unifican por nombre: es justo lo que la tabla existe
* para arreglar.
*/
const personas = new Map();
const persona = async (nombre) => {
const limpio = nombre.trim();
if (personas.has(limpio)) return personas.get(limpio);
const id = nuevoId();
await c.query('INSERT INTO persona (id, slug, nombre, estado) VALUES ($1, $2, $3, $4)', [
id,
slugificar(limpio),
limpio,
'publicado'
]);
personas.set(limpio, id);
return id;
};
/* --- Etiquetas ------------------------------------------------------- */
const etiquetas = new Map();
const etiqueta = async (nombre) => {
const slug = slugificar(nombre);
if (etiquetas.has(slug)) return etiquetas.get(slug);
const id = nuevoId();
await c.query('INSERT INTO etiqueta (id, slug, nombre) VALUES ($1, $2, $3)', [
id,
slug,
nombre.trim()
]);
etiquetas.set(slug, id);
return id;
};
/* --- Estilos ---------------------------------------------------------- */
const idEstilo = new Map();
for (const { datos } of estilos) {
const id = nuevoId();
await c.query(
`INSERT INTO estilo (id, slug, nombre, resumen, orden, principal, estado)
VALUES ($1, $2, $3, $4, $5, $6, 'publicado')`,
[id, datos.slug, datos.nombre, datos.resumen, datos.orden, datos.principal]
);
idEstilo.set(datos.slug, id);
}
informe.estilo = estilos.length;
/* --- Álbumes ----------------------------------------------------------- */
const idAlbum = new Map();
for (const { datos } of albumes) {
const id = nuevoId();
await c.query(
`INSERT INTO album (id, slug, titulo, estilo_id, tipo, fecha, sello, resumen,
destacado, numero_catalogo, formatos, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'publicado')`,
[
id,
datos.slug,
datos.titulo,
idEstilo.get(datos.estilo),
datos.tipo,
datos.fecha,
datos.sello ?? null,
datos.resumen,
datos.destacado,
datos.edicion?.numeroCatalogo ?? null,
datos.edicion?.formatos?.length ? datos.edicion.formatos : null
]
);
idAlbum.set(datos.slug, id);
for (const [plataforma, url] of Object.entries(datos.enlaces ?? {})) {
await c.query('INSERT INTO album_enlace (album_id, plataforma, url) VALUES ($1,$2,$3)', [
id,
plataforma,
url
]);
}
for (const [i, credito] of (datos.edicion?.creditos ?? []).entries()) {
await c.query(
`INSERT INTO credito_album (id, album_id, persona_id, nombre, rol, orden)
VALUES ($1,$2,$3,$4,$5,$6)`,
[nuevoId(), id, await persona(credito.nombre), credito.nombre, credito.rol, i]
);
}
}
informe.album = albumes.length;
/* --- Canciones, y sus versiones como filas hermanas -------------------- */
let versiones = 0;
for (const { datos, cuerpo } of canciones) {
const id = nuevoId();
await c.query(
`INSERT INTO cancion (id, slug, titulo, estilo_id, album_id, numero, duracion_s, ano,
letra, idioma, inspiracion, instrumental, destacada, ala_venta, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'publicado')`,
[
id,
datos.slug,
datos.titulo,
idEstilo.get(datos.estilo),
datos.album ? (idAlbum.get(datos.album) ?? null) : null,
datos.numero,
datos.duracion ?? null,
datos.ano ?? null,
cuerpo || null,
datos.idioma,
datos.inspiracion?.length ? datos.inspiracion.join('\n\n') : null,
datos.instrumental,
datos.destacada,
datos.alaVenta
]
);
/* Créditos: letra, música e intérpretes. */
for (const [rol, nombres] of [
['letra', datos.letra],
['musica', datos.musica],
['interprete', datos.interpretes]
]) {
for (const [i, nombre] of (nombres ?? []).entries()) {
await c.query(
`INSERT INTO cancion_persona (cancion_id, persona_id, rol, orden)
VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`,
[id, await persona(nombre), rol, i]
);
}
}
for (const nombre of datos.etiquetas ?? []) {
await c.query(
`INSERT INTO cancion_etiqueta (cancion_id, etiqueta_id) VALUES ($1,$2)
ON CONFLICT DO NOTHING`,
[id, await etiqueta(nombre)]
);
}
for (const [plataforma, url] of Object.entries(datos.enlaces ?? {})) {
await c.query('INSERT INTO cancion_enlace (cancion_id, plataforma, url) VALUES ($1,$2,$3)', [
id,
plataforma,
url
]);
}
for (const [i, v] of (datos.videos ?? []).entries()) {
await c.query(
'INSERT INTO video (id, cancion_id, youtube_id, titulo, orden) VALUES ($1,$2,$3,$4,$5)',
[nuevoId(), id, v.youtube, v.titulo, i]
);
}
if (datos.traduccion) {
await c.query('INSERT INTO traduccion (cancion_id, idioma, texto) VALUES ($1,$2,$3)', [
id,
'es',
datos.traduccion
]);
}
const r = datos.registro ?? {};
if (r.iswc || r.isrc || r.entidad || r.numero || r.editorial) {
await c.query(
`INSERT INTO registro_obra (cancion_id, iswc, isrc, entidad, numero, editorial)
VALUES ($1,$2,$3,$4,$5,$6)`,
[
id,
r.iswc ?? null,
r.isrc ?? null,
r.entidad ?? null,
r.numero ?? null,
r.editorial ?? null
]
);
}
for (const parte of r.reparto ?? []) {
await c.query(
`INSERT INTO autoria (cancion_id, persona_id, rol, porcentaje) VALUES ($1,$2,$3,$4)
ON CONFLICT DO NOTHING`,
[id, await persona(parte.nombre), parte.rol, parte.porcentaje]
);
}
/*
* Las versiones pasan a ser filas hermanas, con los campos COPIADOS de la
* principal. No se heredan al leer: un nulo heredado nunca se sabe si es
* «no lo sé» o «lo mismo de arriba».
*/
for (const [i, v] of (datos.versiones ?? []).entries()) {
await c.query(
`INSERT INTO cancion (id, slug, titulo, estilo_id, version_de_id, version_nombre,
version_orden, duracion_s, ano, letra, idioma, instrumental, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'publicado')`,
[
nuevoId(),
`${datos.slug}-${v.id}`,
datos.titulo,
idEstilo.get(datos.estilo),
id,
v.nombre,
i,
v.duracion ?? null,
datos.ano ?? null,
cuerpo || null,
datos.idioma,
datos.instrumental
]
);
versiones++;
}
}
informe.cancion = canciones.length;
informe.version = versiones;
/* --- Taller: géneros y capítulos ---------------------------------------
*
* Cada ficha del taller da un `genero` y un `capitulo` que lo describe. Los
* campos comparables se quedan de momento en las notas en prosa: pasarlos a
* `metro` y `compas` exige decidir a mano a qué fila corresponde cada frase,
* y eso no lo puede adivinar un volcado.
*/
const idGenero = new Map();
for (const { datos } of capitulos.filter((c) => c.datos.tipo === 'ficha')) {
const id = nuevoId();
const f = datos.ficha;
await c.query(
`INSERT INTO genero (id, slug, nombre, resumen, origen_lugar, tempo, rima,
metrica_nota, compas_nota, rasgos, estado, origen)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'publicado','manual')`,
[
id,
datos.slug,
datos.titulo,
datos.resumen,
f.origen ?? null,
f.tempo ?? null,
f.rima ?? null,
f.metrica ?? null,
f.compas ?? null,
f.rasgos?.length ? f.rasgos : null
]
);
idGenero.set(datos.slug, id);
if (f.estructura) {
await c.query(
`INSERT INTO genero_seccion (id, genero_id, clave, titulo, cuerpo, orden)
VALUES ($1,$2,'estructura','Estructura de las canciones',$3,0)`,
[nuevoId(), id, f.estructura]
);
}
for (const [i, tema] of (f.temas ?? []).entries()) {
await c.query(
`INSERT INTO genero_tema (genero_id, etiqueta_id, orden) VALUES ($1,$2,$3)
ON CONFLICT DO NOTHING`,
[id, await etiqueta(tema), i]
);
}
}
informe.genero = idGenero.size;
void personas;
for (const { datos, cuerpo } of capitulos) {
await c.query(
`INSERT INTO capitulo (id, slug, titulo, tipo, genero_id, orden, resumen, cuerpo, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'publicado')`,
[
nuevoId(),
datos.slug,
datos.titulo,
datos.tipo,
idGenero.get(datos.slug) ?? null,
datos.orden,
datos.resumen,
cuerpo
]
);
}
informe.capitulo = capitulos.length;
/* --- Blog --------------------------------------------------------------- */
for (const { datos, cuerpo } of entradas) {
const id = nuevoId();
await c.query(
`INSERT INTO entrada (id, slug, titulo, fecha, resumen, cuerpo, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[
id,
datos.slug,
datos.titulo,
datos.fecha,
datos.resumen,
cuerpo,
datos.borrador ? 'borrador' : 'publicado'
]
);
for (const nombre of datos.etiquetas ?? []) {
await c.query(
`INSERT INTO entrada_etiqueta (entrada_id, etiqueta_id) VALUES ($1,$2)
ON CONFLICT DO NOTHING`,
[id, await etiqueta(nombre)]
);
}
for (const slug of datos.canciones ?? []) {
const { rows } = await c.query('SELECT id FROM cancion WHERE slug = $1', [slug]);
if (rows[0]) {
await c.query(
`INSERT INTO entrada_cancion (entrada_id, cancion_id) VALUES ($1,$2)
ON CONFLICT DO NOTHING`,
[id, rows[0].id]
);
} else {
informe.avisos.push(`la entrada «${datos.slug}» cita un tema que no existe: ${slug}`);
}
}
}
informe.entrada = entradas.length;
/* --- Legales, con su primera versión ------------------------------------ */
for (const { datos, cuerpo } of legales) {
const id = nuevoId();
await c.query(
`INSERT INTO documento_legal (id, slug, titulo, resumen, orden) VALUES ($1,$2,$3,$4,$5)`,
[id, datos.slug, datos.titulo, datos.resumen, datos.orden]
);
const version = nuevoId();
await c.query(
`INSERT INTO version_legal (id, documento_id, cuerpo, vigente_desde, resumen_cambios)
VALUES ($1,$2,$3,$4,$5)`,
[version, id, cuerpo, new Date(datos.actualizada), 'Primera versión, volcada del repositorio']
);
await c.query('UPDATE documento_legal SET version_vigente_id = $1 WHERE id = $2', [
version,
id
]);
}
informe.legal = legales.length;
/* --- Páginas sueltas ------------------------------------------------------ */
for (const { datos, cuerpo } of paginas) {
await c.query(
`INSERT INTO pagina (id, slug, titulo, descripcion, cuerpo, estado)
VALUES ($1,$2,$3,$4,$5,'publicado')`,
[nuevoId(), datos.slug, datos.titulo, datos.descripcion, cuerpo]
);
}
informe.pagina = paginas.length;
/*
* Personas y etiquetas se cuentan AL FINAL y desde la base. Apuntar
* `etiquetas.size` justo después de las canciones daba 18 cuando acaban
* siendo 53: después se crean más con los temas de los géneros y con las del
* blog. Un informe con un número que no es el de la tabla es peor que sin
* número, porque parece comprobado.
*/
for (const tabla of ['persona', 'etiqueta']) {
const { rows } = await c.query(`SELECT count(*)::int n FROM "${tabla}"`);
informe[tabla] = rows[0].n;
}
}
/* -------------------------------------------------------------------------
* Comprobar
* ---------------------------------------------------------------------- */
/**
* Comprueba que lo escrito coincide con lo leído.
*
* «No dio error» no es lo mismo que «está bien»: un `INSERT` puede meter un
* nulo donde había un texto sin quejarse.
*/
async function comprobar(c, contenido, informe) {
const fallos = [];
const cuenta = async (tabla, esperado, condicion = '') => {
const { rows } = await c.query(`SELECT count(*)::int n FROM "${tabla}" ${condicion}`);
if (rows[0].n !== esperado) {
fallos.push(`${tabla}: hay ${rows[0].n} y se esperaban ${esperado}`);
}
return rows[0].n;
};
await cuenta('estilo', contenido.estilos.length);
await cuenta('album', contenido.albumes.length);
await cuenta('cancion', contenido.canciones.length, 'WHERE version_de_id IS NULL');
await cuenta('cancion_principal', contenido.canciones.length);
await cuenta('capitulo', contenido.capitulos.length);
await cuenta('entrada', contenido.entradas.length);
await cuenta('documento_legal', contenido.legales.length);
await cuenta('pagina', contenido.paginas.length);
/* La letra de cada tema, carácter a carácter. */
for (const { datos, cuerpo } of contenido.canciones) {
const { rows } = await c.query(
'SELECT titulo, letra, duracion_s, ano FROM cancion WHERE slug = $1',
[datos.slug]
);
const fila = rows[0];
if (!fila) {
fallos.push(`falta la canción ${datos.slug}`);
continue;
}
if (fila.titulo !== datos.titulo) fallos.push(`${datos.slug}: el título no coincide`);
if ((fila.letra ?? '') !== (cuerpo || '')) {
fallos.push(`${datos.slug}: la letra no coincide`);
}
if ((fila.duracion_s ?? null) !== (datos.duracion ?? null)) {
fallos.push(`${datos.slug}: la duración no coincide`);
}
if ((fila.ano ?? null) !== (datos.ano ?? null)) {
fallos.push(`${datos.slug}: el año no coincide`);
}
}
/* Que el extracto siga saliendo igual de la letra guardada. */
const conLetra = contenido.canciones.find((c) => c.cuerpo);
if (conLetra) {
const { rows } = await c.query('SELECT letra FROM cancion WHERE slug = $1', [conLetra.slug]);
const deLaBase = extraerLineas(rows[0].letra ?? '');
const delArchivo = extraerLineas(conLetra.cuerpo);
if (deLaBase.join('|') !== delArchivo.join('|')) {
fallos.push('el extracto de la letra no sale igual desde la base');
}
}
informe.fallos = fallos;
return fallos.length === 0;
}
/* -------------------------------------------------------------------------
* Adelante
* ---------------------------------------------------------------------- */
const contenido = {
estilos: leerCarpeta('estilos', parsearEstilo),
albumes: leerCarpeta('albumes', parsearAlbum),
canciones: leerCarpeta('canciones', parsearCancion),
capitulos: leerCarpeta('taller', parsearCapitulo),
entradas: leerCarpeta('blog', parsearEntrada),
legales: leerCarpeta('legal', parsearLegal),
paginas: leerCarpeta('paginas', parsearPagina)
};
const total = Object.values(contenido).reduce((n, l) => n + l.length, 0);
console.log(`Leídos ${total} archivos de ${RAIZ}\n`);
if (SECO) {
for (const [nombre, lista] of Object.entries(contenido)) {
console.log(` ${nombre.padEnd(12)} ${lista.length}`);
}
console.log('\n(en seco: no se ha tocado la base)');
process.exit(0);
}
const cliente = new pg.Client({
connectionString: process.env.DATABASE_URL,
query_timeout: 60_000
});
await cliente.connect();
await cliente.query('BEGIN');
const informe = { avisos: [], fallos: [] };
let bien = false;
try {
await volcar(cliente, contenido, informe);
bien = await comprobar(cliente, contenido, informe);
await cliente.query(bien ? 'COMMIT' : 'ROLLBACK');
} catch (fallo) {
await cliente.query('ROLLBACK').catch(() => {});
console.error('\nFalló el volcado, no se ha escrito nada:\n');
console.error(' ' + fallo.message);
await cliente.end();
process.exit(1);
}
await cliente.end();
for (const [tabla, n] of Object.entries(informe)) {
if (typeof n === 'number') console.log(` ${tabla.padEnd(12)} ${n}`);
}
for (const aviso of informe.avisos) console.log(` aviso: ${aviso}`);
if (bien) {
console.log('\nVolcado y comprobado.');
} else {
console.log('\nNo cuadra, así que se ha deshecho:');
for (const f of informe.fallos) console.log(` ✗ ${f}`);
process.exit(1);
}
void createHash;

@ -13,13 +13,22 @@ import { esFechaISO } from '$lib/utils/date';
import { duracionASegundos } from '$lib/utils/format';
export class ErrorDeContenido extends Error {
constructor(
readonly archivo: string,
readonly campo: string,
mensaje: string
) {
readonly archivo: string;
readonly campo: string;
/*
* Los campos se declaran y se asignan a mano en vez de usar propiedades de
* parametro —`constructor(readonly archivo: string)`—. Aquello es sintaxis
* exclusiva de TypeScript: no se puede borrar el tipo y quedarse con
* JavaScript valido, asi que ningun cargador que solo quite tipos puede con
* ella. Este modulo lo usa tambien el volcado a la base, que corre en Node a
* secas, y por tres lineas de mas no vale la pena cerrarle la puerta.
*/
constructor(archivo: string, campo: string, mensaje: string) {
super(`Contenido invalido en ${archivo} -> "${campo}": ${mensaje}`);
this.name = 'ErrorDeContenido';
this.archivo = archivo;
this.campo = campo;
}
}

@ -1,10 +1,11 @@
/**
* El catálogo: estilos, discos, canciones y todo lo que cuelga de una canción.
*
* Hasta ahora esto vivía en `src/content/**` como Markdown con frontmatter. No
* eran documentos: una canción tenía veinte campos estructurados en YAML y la
* letra entera también, y el cuerpo del Markdown eran cuatro líneas de
* comentario. Eran registros disfrazados de documento.
* Hasta ahora esto vivía en `src/content/**` como Markdown con frontmatter, y
* una canción llevaba veintiún campos estructurados en YAML: `registro`,
* `duracion`, `interpretes`, `versiones`, `videos`, `etiquetas`… El cuerpo del
* Markdown sí era contenido de verdad —los versos—, pero todo lo demás eran
* datos con forma de tabla metidos en un archivo de texto.
*/
import { sql } from 'drizzle-orm';
@ -149,12 +150,18 @@ export const cancion = pgTable(
duracionS: integer('duracion_s'),
ano: integer('ano'),
/** Con sus saltos de línea: es verso, no prosa. */
/**
* Los versos, con sus saltos de línea: es verso, no prosa, y una letra que
* pierde el corte deja de ser la letra.
*
* Es el CUERPO del Markdown de hoy, no un campo del frontmatter. El campo
* `letra:` que aparece en un archivo es otra cosa: quién firma la letra, y
* eso va a `cancion_persona`.
*/
letra: text('letra'),
/** ISO 639. Decide si la ficha enseña una columna o dos. */
idioma: text('idioma').notNull().default('es'),
/** El cuerpo del Markdown de antes. */
nota: text('nota'),
/** De dónde sale el tema. Del frontmatter, en párrafos. */
inspiracion: text('inspiracion'),
instrumental: boolean('instrumental').notNull().default(false),

Loading…
Cancel
Save

Powered by TurnKey Linux.