feat: descrição arquivística NOBRADE multinível com exportação EAD 3, EAC-CPF e AtoM

- pipeline/nobrade: normalizadores de datas (ISO 8601-2), séries, autoridades,
  espécie/suporte, direitos, lugar e assuntos; montagem dos níveis 0–5 com
  código de referência BR RJXYZ.BF.<seção>.<série>.<handle>.<imagem>
- validador dos elementos obrigatórios: 0 pendências em 26.454 unidades
- EAD 3 e 457 EAC-CPF validados contra os XSD
- mural: cartão com código, data normalizada, série e reprodução; filtro por série
This commit is contained in:
2026-09-30 21:22:00 +02:00
parent e92df83a78
commit 23b56e8c87
46 changed files with 19562 additions and 68 deletions
File diff suppressed because one or more lines are too long
+38 -12
View File
@@ -426,6 +426,8 @@
#card-footer a { color: #d8b04a; text-decoration: none; }
#card-footer a:hover { text-decoration: underline; }
#card-rights { margin-top: 6px; font-size: .68rem; color: #777; }
#card-ref { font: .7rem/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; color: #8a8a82; margin-top: 4px; }
#card-ref .serie { font-family: system-ui, -apple-system, sans-serif; letter-spacing: .02em; }
/* stepping between pieces without a keyboard */
#card-nav { display: flex; gap: 9px; margin-top: 16px; }
@@ -635,6 +637,10 @@
<label for="f-colecao">Coleção</label>
<select id="f-colecao"><option value="">todas</option></select>
</div>
<div class="f-row">
<label for="f-serie">Série (arranjo NOBRADE)</label>
<select id="f-serie"><option value="">todas</option></select>
</div>
<div class="f-row">
<label for="f-pessoa">Pessoa</label>
<select id="f-pessoa"><option value="">todas</option></select>
@@ -660,6 +666,7 @@
<div id="card-img-wrap"><img id="card-img" alt=""></div>
<div id="card-title">—</div>
<div id="card-date">—</div>
<div id="card-ref"></div>
<div id="card-contrib"></div>
<div id="card-tags"></div>
<div id="card-related" hidden></div>
@@ -1063,6 +1070,7 @@ renderer.domElement.addEventListener('pointermove', e => {
// (the "hole" always opens in the middle, wherever you clicked).
let atlasSources = []; // { source, size } per atlas page — used to crop the tile into the card canvas
let atlasEntries = null, TPR = 1;
let seriesTitles = []; // títulos das séries NOBRADE (nível 3); meta.si indexa aqui
let selected = null;
let selectedAt = 0;
@@ -1393,11 +1401,20 @@ function tileThumbUrl(handle, px) {
function showCard(data) {
const meta = data.meta || {};
document.getElementById('card-type').textContent = meta.type || 'Fotografia';
document.getElementById('card-type').textContent = meta.sup || meta.type || 'Fotografia';
document.getElementById('card-community').textContent = meta.community || '—';
document.getElementById('card-date-tag').textContent = prettyDate(meta.date) || 's.d.';
document.getElementById('card-date-tag').textContent = meta.dt || prettyDate(meta.date) || 's.d.';
document.getElementById('card-title').textContent = meta.title || '(sem título)';
document.getElementById('card-date').textContent = prettyDate(meta.date);
document.getElementById('card-date').textContent = meta.dt || prettyDate(meta.date);
// código de referência NOBRADE e a série (nível 3) a que a peça pertence
const refEl = document.getElementById('card-ref');
refEl.textContent = meta.ref || '';
if (meta.si != null && seriesTitles[meta.si]) {
const sr = document.createElement('div');
sr.className = 'serie';
sr.textContent = seriesTitles[meta.si];
refEl.appendChild(sr);
}
document.getElementById('card-contrib').textContent = meta.contributor ? meta.contributor : '';
// this piece's dc.subject, one chip each (accent/case duplicates folded)
const tagsEl = document.getElementById('card-tags');
@@ -1406,7 +1423,8 @@ function showCard(data) {
renderRelated(data);
document.getElementById('card-handle').textContent = data.handle || '';
document.getElementById('card-link').href = data.handle ? sourceUrl(data.handle) : '#';
document.getElementById('card-rights').textContent = meta.rights ? `Direitos: ${meta.rights}` : '';
document.getElementById('card-rights').textContent =
[meta.rep && meta.rep[0].toUpperCase() + meta.rep.slice(1), meta.rights && `Direitos: ${meta.rights}`].filter(Boolean).join(' · ');
// crop this tile out of the atlas image for a (still small, but framed) preview
const entry = atlasEntries[data.handle];
@@ -1493,6 +1511,7 @@ function flattenMeta(value) {
}
function yearOf(meta) {
if (meta && Number.isInteger(meta.ano)) return meta.ano;
const m = String((meta || {}).date || '').match(/(1[5-9]\d{2}|20\d{2})/);
return m ? parseInt(m[1], 10) : null;
}
@@ -1961,6 +1980,7 @@ function setupFilters(mapData) {
const a = cleanAuthor(meta.contributor);
m.userData._author = a ? authorKey(a) : '';
m.userData._type = cleanType(meta.type);
m.userData._serie = meta.si != null ? seriesTitles[meta.si] || '' : '';
Object.assign(m.userData, (({ st, ct, bh, tp, col, pes }) => ({ _st: st, _ct: ct, _bh: bh, _tp: tp, _col: col, _pes: pes }))(subjectFacets(meta)));
// one haystack per piece: every metadata field, not a chosen few
// the handle is the map's key, not a field of meta — it still has to be
@@ -1992,6 +2012,7 @@ function setupFilters(mapData) {
const selTopico = document.getElementById('f-topico');
const selColecao = document.getElementById('f-colecao');
const selPessoa = document.getElementById('f-pessoa');
const selSerie = document.getElementById('f-serie');
const fillSelect = (sel, rows, first) => {
sel.replaceChildren(Object.assign(document.createElement('option'), { value: '', textContent: first }));
rows.forEach(([value, n]) => {
@@ -2036,7 +2057,7 @@ function setupFilters(mapData) {
sel.value = value;
}
const state = { search: '', institution: '', yearLo: decadeMin, yearHi: decadeMax, yearTouched: false, author: '', type: '', estado: '', cidade: '', bairro: '', topico: '', colecao: '', pessoa: '' };
const state = { search: '', institution: '', yearLo: decadeMin, yearHi: decadeMax, yearTouched: false, author: '', type: '', estado: '', cidade: '', bairro: '', topico: '', colecao: '', pessoa: '', serie: '' };
function resetYear() {
state.yearTouched = false;
@@ -2064,6 +2085,7 @@ function setupFilters(mapData) {
(!state.topico || d._tp.has(topicPrefix() + state.topico)) &&
(!state.colecao || d._col.has(state.colecao)) &&
(!state.pessoa || d._pes.has(state.pessoa)) &&
(!state.serie || d._serie === state.serie) &&
terms.every(t => d._hay.includes(t));
if (okRest && d._year != null) {
const i = Math.floor((d._year - decadeMin) / 10);
@@ -2131,6 +2153,7 @@ function setupFilters(mapData) {
if (state.topico) out.push({ key: 'topico', text: state.topico });
if (state.colecao) out.push({ key: 'colecao', text: state.colecao });
if (state.pessoa) out.push({ key: 'pessoa', text: state.pessoa });
if (state.serie) out.push({ key: 'serie', text: state.serie });
if (state.type) out.push({ key: 'type', text: state.type });
return out;
}
@@ -2145,6 +2168,7 @@ function setupFilters(mapData) {
if (key === 'topico') { state.topico = ''; selTopico.value = ''; }
if (key === 'colecao') { state.colecao = ''; selColecao.value = ''; }
if (key === 'pessoa') { state.pessoa = ''; selPessoa.value = ''; }
if (key === 'serie') { state.serie = ''; selSerie.value = ''; }
if (key === 'author') { state.author = ''; selAuthor.value = ''; }
if (key === 'year') resetYear();
apply();
@@ -2192,6 +2216,7 @@ function setupFilters(mapData) {
selTopico.addEventListener('change', () => { state.topico = selTopico.value; apply(); });
selColecao.addEventListener('change', () => { state.colecao = selColecao.value; apply(); });
selPessoa.addEventListener('change', () => { state.pessoa = selPessoa.value; apply(); });
selSerie.addEventListener('change', () => { state.serie = selSerie.value; apply(); });
selAuthor.addEventListener('change', () => { state.author = selAuthor.value; apply(); });
function onYear(e) {
@@ -2211,8 +2236,8 @@ function setupFilters(mapData) {
clearBtn.addEventListener('click', () => {
clearTimeout(searchTimer);
state.search = ''; state.institution = ''; state.type = ''; state.author = ''; state.estado = state.cidade = state.bairro = state.topico = state.colecao = state.pessoa = '';
searchInput.value = ''; selInst.value = ''; selType.value = ''; selAuthor.value = ''; selEstado.value = ''; selColecao.value = ''; selPessoa.value = ''; rebuildPlace();
state.search = ''; state.institution = ''; state.type = ''; state.author = ''; state.estado = state.cidade = state.bairro = state.topico = state.colecao = state.pessoa = state.serie = '';
searchInput.value = ''; selInst.value = ''; selType.value = ''; selAuthor.value = ''; selEstado.value = ''; selColecao.value = ''; selPessoa.value = ''; selSerie.value = ''; rebuildPlace();
resetYear();
apply();
});
@@ -2220,13 +2245,14 @@ function setupFilters(mapData) {
fillSelect(selEstado, tally('', d => d._st, 1), 'todos');
fillSelect(selColecao, tally('', d => d._col, 1), 'todas');
fillSelect(selPessoa, tally('', d => d._pes, 5), 'todas');
fillSelect(selSerie, tally('', d => d._serie ? [d._serie] : [], 1), 'todas');
// a tag on the card applies itself as a filter; the URL carries the filters
// (?estado=…&topico=…) so a filtered wall can be shared and survives a reload
const URL_KEYS = { q: 'search', inst: 'institution', autor: 'author', tipo: 'type', estado: 'estado', cidade: 'cidade',
bairro: 'bairro', topico: 'topico', colecao: 'colecao', pessoa: 'pessoa' };
bairro: 'bairro', topico: 'topico', colecao: 'colecao', pessoa: 'pessoa', serie: 'serie' };
const SELECTS = { institution: selInst, author: selAuthor, type: selType, estado: selEstado, cidade: selCidade,
bairro: selBairro, topico: selTopico, colecao: selColecao, pessoa: selPessoa };
bairro: selBairro, topico: selTopico, colecao: selColecao, pessoa: selPessoa, serie: selSerie };
function syncUrl() {
const p = new URLSearchParams();
Object.entries(URL_KEYS).forEach(([k, f]) => { if (state[f]) p.set(k, state[f]); });
@@ -2326,6 +2352,7 @@ async function main() {
const mapData = JSON.parse(await catalogue.blob.text());
TPR = mapData.size / mapData.tile;
atlasEntries = mapData.map;
seriesTitles = mapData.series || [];
const handles = Object.keys(mapData.map);
// the note only earns its space in placeholder mode — the piece count already
@@ -2342,9 +2369,8 @@ async function main() {
for (const entry of Object.values(mapData.map)) {
const meta = entry.meta || {};
if (meta.community) institutions.add(meta.community);
const m = String(meta.date || '').match(/(1[5-9]\d{2}|20\d{2})/);
if (m) {
const y = parseInt(m[1], 10);
const y = yearOf(meta);
if (y != null) {
if (y < minYear) minYear = y;
if (y > maxYear) maxYear = y;
}