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
parent
1a8c904ae8
commit
54df820056
@ -0,0 +1 @@
|
||||
ALTER TABLE "cancion" DROP COLUMN "nota";
|
||||
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
Loading…
Reference in new issue