Herramientas en PowerShell para el catálogo de la BAC.
<#
============================================================================
Catalog.ps1
----------------------------------------------------------------------------
Tipo de archivo : Script de Windows PowerShell (.ps1)
Requiere : Windows PowerShell 5.1 o superior (tambien funciona
en PowerShell 7+). No requiere modulos externos ni
conexion a internet: usa unicamente tipos incluidos
en .NET (System.Text, hashtables, etc).
Proyecto : Biblioteca Alonso Cossio (BAC)
Licencia : MIT. Se permite uso, copia, modificacion y distribucion
conservando este aviso de copyright. Texto completo en
https://bibliotecaalonsocossio.com/scripts.html
Copyright : (c) 2026 Alonso Cossio Vazquez
Proposito : CATALOGACION. Genera registros bibliograficos nuevos
en formato MARC 21, guiando al usuario pregunta por
pregunta desde la consola.
QUE HACE
Pregunta, campo por campo, los datos de un libro, un articulo de
revista o un fasciculo de revista (titulo, autor(es), editorial,
clasificacion, materias, notas, etc.), arma con esos datos un registro
MARC 21 completo -lider, campos de control (001/005/008) y campos de
datos con sus subcampos ($a, $b, $c...)- y lo guarda como texto plano
en formato "MARCMaker" (extension .mrk, un renglon por campo, con el
signo "=" al inicio de cada uno). Ese es el mismo formato que despues
lee Search.ps1 para armar el catalogo consultable.
COMO SE EJECUTA
powershell -File .\Catalog.ps1
(o, en el Explorador de Windows, clic derecho sobre el archivo ->
"Ejecutar con PowerShell")
PARAMETRO
-OutputFolder Carpeta donde se guarda cada archivo .mrk generado.
Si no se indica, usa la subcarpeta "MARC_OUT" junto
al propio script.
SALIDA
Un archivo "<numero_de_acceso>.mrk" por cada registro que se guarda,
codificado en UTF-8 sin BOM (marca de orden de bytes).
ESTRUCTURA DEL SCRIPT (de arriba a abajo)
1. Funciones de preguntas interactivas (Ask, AskList, AskRelator,
AskAuthorType, AskMaterialType) - la "capa de entrada" con el
usuario.
2. Utilerias de bajo nivel para construir MARC (MakeSubfield,
MakeField, Remove-Diacritics, Get-MARCCountry).
3. BuildMARC - la funcion que arma el registro MARC completo a partir
de las respuestas capturadas.
4. CollectRecord / ShowPreview / EditField - el flujo interactivo:
capturar datos, mostrar vista previa numerada, permitir editar
cualquier campo antes de guardar.
5. Bloque final - el bucle principal del programa (menu de guardar /
editar / descartar, y la pregunta de si capturar otro registro).
AVISO
Esta copia se publica en el sitio unicamente para lectura y copia
(transparencia del proyecto: asi se generan los registros MARC de la
BAC). No cataloga nada por si sola al leerla aqui: hay que descargarla
y ejecutarla localmente, apuntando a la propia carpeta de datos.
============================================================================
#>
param(
[string]$OutputFolder = (Join-Path $PSScriptRoot "MARC_OUT")
)
# Linea separadora reutilizada por Show-SectionHeader para dibujar
# los encabezados de cada seccion en la consola.
$script:Sep = "─" * 56
# Imprime un titulo de seccion en cian, seguido de la linea separadora.
# Es puramente cosmetico: organiza visualmente cada bloque de preguntas.
function Show-SectionHeader([string]$title) {
Write-Host " $title" -ForegroundColor Cyan
Write-Host " $($script:Sep)" -ForegroundColor DarkGray
}
# Pregunta generica de una sola linea. Muestra el texto del prompt,
# opcionalmente un valor por defecto entre corchetes (se usa si el
# usuario deja la respuesta en blanco) y un asterisco si el campo es
# obligatorio (-Required). Si es obligatorio y llega vacio, vuelve a
# preguntar en vez de continuar.
function Ask {
param([string]$Prompt, [string]$Default = "", [switch]$Required)
while ($true) {
$hint = if ($Default) { " [$Default]" } else { "" }
$req = if ($Required) { " *" } else { "" }
Write-Host "${Prompt}${req}${hint}: " -NoNewline -ForegroundColor Cyan
$raw = Read-Host
$val = if ($raw -eq "" -and $Default -ne "") { $Default } else { $raw.Trim() }
if ($Required -and $val -eq "") {
Write-Host " (campo obligatorio)" -ForegroundColor Yellow
continue
}
return $val
}
}
# Pregunta que acepta VARIAS lineas de respuesta (por ejemplo, la lista
# de materias). Sigue pidiendo lineas hasta que el usuario responde con
# una linea vacia; regresa un arreglo con todo lo capturado.
function AskList {
param([string]$Prompt)
Write-Host "${Prompt} (una por línea, línea en blanco para terminar):" -ForegroundColor Cyan
$items = @()
while ($true) {
Write-Host " > " -NoNewline -ForegroundColor DarkGray
$line = Read-Host
if ($line.Trim() -eq "") { break }
$items += $line.Trim()
}
return $items
}
# Menu para elegir el ROL del autor dentro de la obra (autor, editor,
# compilador, traductor, prologuista, coordinador). Ese valor se
# guarda luego en el subcampo $e (relator) del campo de autor MARC
# (100/110/700/710).
function AskRelator {
param([string]$AuthorName)
$options = @(
[pscustomobject]@{ Key="1"; Label="autor" ; Term="autor" }
[pscustomobject]@{ Key="2"; Label="editor" ; Term="editor" }
[pscustomobject]@{ Key="3"; Label="compilador" ; Term="compilador" }
[pscustomobject]@{ Key="4"; Label="traductor" ; Term="traductor" }
[pscustomobject]@{ Key="5"; Label="prologuista" ; Term="prologuista" }
[pscustomobject]@{ Key="6"; Label="coordinador" ; Term="coordinador" }
)
Write-Host " Relator para '$AuthorName':" -ForegroundColor Cyan
foreach ($o in $options) {
Write-Host (" " + $o.Key + " " + $o.Label) -ForegroundColor DarkGray
}
Write-Host " Opción [1]: " -NoNewline -ForegroundColor Cyan
$raw = Read-Host
$raw = $raw.Trim()
if ($raw -eq "") { $raw = "1" }
$match = $options | Where-Object { $_.Key -eq $raw }
if ($match) { return $match.Term }
Write-Host " (no reconocido - usando autor por defecto)" -ForegroundColor Yellow
return "autor"
}
# Menu para elegir el TIPO de autor: 1 = persona (campo 100/700),
# 2 = entidad/institucion (campo 110/710), 3 = titulo uniforme
# (campo 130, para obras clasicas/anonimas conocidas por su titulo).
function AskAuthorType {
Write-Host " Tipo de autor (1 = Personal [defecto], 2 = Corporativo, 3 = Título uniforme): " -NoNewline -ForegroundColor Cyan
$raw = (Read-Host).Trim()
if ($raw -eq "2") { return 2 }
if ($raw -eq "3") { return 3 }
return 1
}
# Menu de tipo de material (libro, carpeta, DVD, mapa, CD de audio,
# fotografia...). Cada opcion trae ya predefinidos los tres pares de
# subcampos RDA que exige el estandar MARC 21 moderno:
# 336 tipo de contenido (CT), 337 tipo de medio (MT),
# 338 tipo de soporte/carrier (CA).
# Asi el usuario no tiene que saber de memoria el vocabulario RDA.
function AskMaterialType {
$options = @(
[pscustomobject]@{
Key="1"; Label="Libro / Monografia"
CT_a="texto"; CT_b="txt"
MT_a="sin mediación"; MT_b="n"
CA_a="volumen"; CA_b="nc"
}
[pscustomobject]@{
Key="2"; Label="Carpeta / Documento"
CT_a="texto"; CT_b="txt"
MT_a="sin mediación"; MT_b="n"
CA_a="carpeta"; CA_b="nn"
}
[pscustomobject]@{
Key="3"; Label="DVD / Video"
CT_a="imagen en movimiento"; CT_b="tdi"
MT_a="video"; MT_b="v"
CA_a="videodisc"; CA_b="vd"
}
[pscustomobject]@{
Key="4"; Label="Mapa"
CT_a="imagen cartografica"; CT_b="cri"
MT_a="sin mediación"; MT_b="n"
CA_a="hoja"; CA_b="nb"
}
[pscustomobject]@{
Key="5"; Label="CD de audio"
CT_a="musica interpretada"; CT_b="prm"
MT_a="audio"; MT_b="s"
CA_a="disco de audio"; CA_b="sd"
}
[pscustomobject]@{
Key="6"; Label="Fotografia / Lamina"
CT_a="imagen fija"; CT_b="sti"
MT_a="sin mediación"; MT_b="n"
CA_a="hoja"; CA_b="nb"
}
)
Write-Host " Tipo de material:" -ForegroundColor Cyan
foreach ($o in $options) {
Write-Host (" " + $o.Key + " " + $o.Label) -ForegroundColor DarkGray
}
Write-Host " Seleccion [1]: " -NoNewline -ForegroundColor Cyan
$raw = (Read-Host).Trim()
if ($raw -eq "") { $raw = "1" }
$match = $options | Where-Object { $_.Key -eq $raw }
if ($match) { return $match }
Write-Host " (no reconocido - usando Libro / Monografia)" -ForegroundColor Yellow
return $options[0]
}
# Caracteres de control propios del formato MARC binario:
# FT (Field Terminator) = separador de fin de campo
# RT (Record Terminator) = separador de fin de registro
# US (Unit/Subfield Separator) = separador entre subcampos ($a, $b...)
# En esta implementacion solo se usa US de forma activa: al exportar a
# texto plano (MARCMaker) ese caracter se reemplaza por el signo '$'
# que separa subcampos de forma legible (ver BuildMARC, mas abajo).
$FT = [char]0x1E
$RT = [char]0x1D
$US = [char]0x1F
# Arma un subcampo individual: '<separador>codigo valor'.
# Si el valor viene vacio, regresa cadena vacia (el subcampo
# simplemente no se agrega al campo).
function MakeSubfield {
param([string]$Code, [string]$Value)
if ($Value -eq "") { return "" }
return "${US}${Code}${Value}"
}
# Empaqueta un campo MARC completo (etiqueta, dos indicadores y los
# subcampos ya armados) en un objeto que BuildMARC junta, ordena y
# convierte a texto al final.
function MakeField {
param([string]$Tag, [string]$Ind1, [string]$Ind2, [string]$Data)
return @{ Tag=$Tag; Ind1=$Ind1; Ind2=$Ind2; Data=$Data; Control=$false }
}
# Quita acentos/diacriticos de un texto (Mexico -> Mexico, sin tilde).
# Se usa para poder comparar nombres de lugar sin importar si el
# usuario escribio o no los acentos (ver Get-MARCCountry).
function Remove-Diacritics {
param([string]$Text)
$normalized = $Text.Normalize([System.Text.NormalizationForm]::FormD)
$sb = [System.Text.StringBuilder]::new()
foreach ($c in $normalized.ToCharArray()) {
if ([System.Globalization.CharUnicodeInfo]::GetUnicodeCategory($c) -ne `
[System.Globalization.UnicodeCategory]::NonSpacingMark) {
[void]$sb.Append($c)
}
}
return $sb.ToString().Normalize([System.Text.NormalizationForm]::FormC)
}
# Traduce un lugar de publicacion (ciudad o pais, en espanol o ingles,
# con o sin acentos) al codigo de pais MARC de 3 caracteres que exige
# el campo 008 (posiciones 15-17), por ejemplo 'Ciudad de Mexico' o
# 'CDMX' -> 'mx '. El diccionario $map cubre los paises/ciudades mas
# comunes agrupados por region (Mexico, Estados Unidos, Sudamerica,
# Europa, Asia, Africa, Oceania...). Primero intenta coincidencia
# exacta; si no la halla, busca la clave mas especifica (mas larga)
# que este contenida en el texto (asi 'Nueva York, NY' igual resuelve
# a Estados Unidos). Si no reconoce nada, regresa 'xx ' (pais
# desconocido/no especificado, segun MARC 21).
function Get-MARCCountry {
param([string]$Place)
$p = (Remove-Diacritics $Place).ToLower().Trim()
$map = @{
# ── MEXICO ──────────────────────────────────────────────────────────
"mexico" = "mx "; "mexico city" = "mx "; "ciudad de mexico" = "mx "
"cdmx" = "mx "; "df" = "mx "; "distrito federal" = "mx "
"guadalajara" = "mx "; "monterrey" = "mx "; "puebla" = "mx "
"tijuana" = "mx "; "leon" = "mx "; "juarez" = "mx "; "ciudad juarez" = "mx "
"torreon" = "mx "; "san luis potosi" = "mx "; "merida" = "mx "
"aguascalientes" = "mx "; "queretaro" = "mx "; "chihuahua" = "mx "
"morelia" = "mx "; "oaxaca" = "mx "; "toluca" = "mx "
"hermosillo" = "mx "; "saltillo" = "mx "; "mexicali" = "mx "
"culiacan" = "mx "; "durango" = "mx "; "zacatecas" = "mx "
"cancun" = "mx "; "veracruz" = "mx "; "xalapa" = "mx "
"tepic" = "mx "; "cuernavaca" = "mx "; "colima" = "mx "
"villahermosa" = "mx "; "tuxtla gutierrez" = "mx "; "campeche" = "mx "
"chetumal" = "mx "; "la paz" = "mx "; "los cabos" = "mx "
"pachuca" = "mx "; "tlaxcala" = "mx "; "chilpancingo" = "mx "
# ── ESTADOS UNIDOS / UNITED STATES ──────────────────────────────────
"united states" = "xxu"; "estados unidos" = "xxu"; "usa" = "xxu"; "us" = "xxu"
"new york" = "xxu"; "nueva york" = "xxu"; "los angeles" = "xxu"
"chicago" = "xxu"; "houston" = "xxu"; "phoenix" = "xxu"
"philadelphia" = "xxu"; "filadelfia" = "xxu"; "san antonio" = "xxu"
"san diego" = "xxu"; "dallas" = "xxu"; "san jose" = "xxu"
"austin" = "xxu"; "jacksonville" = "xxu"; "fort worth" = "xxu"
"columbus" = "xxu"; "charlotte" = "xxu"; "indianapolis" = "xxu"
"san francisco" = "xxu"; "seattle" = "xxu"; "denver" = "xxu"
"washington" = "xxu"; "washington dc" = "xxu"; "nashville" = "xxu"
"oklahoma city" = "xxu"; "el paso" = "xxu"; "boston" = "xxu"
"portland" = "xxu"; "las vegas" = "xxu"; "memphis" = "xxu"
"louisville" = "xxu"; "baltimore" = "xxu"; "milwaukee" = "xxu"
"albuquerque" = "xxu"; "tucson" = "xxu"; "fresno" = "xxu"
"sacramento" = "xxu"; "mesa" = "xxu"; "atlanta" = "xxu"
"miami" = "xxu"; "minneapolis" = "xxu"; "cleveland" = "xxu"
"new orleans" = "xxu"; "nueva orleans" = "xxu"; "detroit" = "xxu"
"pittsburgh" = "xxu"; "cincinnati" = "xxu"; "st. louis" = "xxu"
"salt lake city" = "xxu"; "kansas city" = "xxu"
"cambridge" = "xxu"; "berkeley" = "xxu"; "ann arbor" = "xxu"
"chapel hill" = "xxu"; "princeton" = "xxu"; "new haven" = "xxu"
# ── CANADA ──────────────────────────────────────────────────────────
"canada" = "xxc"; "toronto" = "xxc"; "montreal" = "xxc"
"vancouver" = "xxc"; "calgary" = "xxc"; "edmonton" = "xxc"
"ottawa" = "xxc"; "winnipeg" = "xxc"; "quebec" = "xxc"
"quebec city" = "xxc"; "ciudad de quebec" = "xxc"
"hamilton" = "xxc"; "kitchener" = "xxc"; "london" = "xxc"
"victoria" = "xxc"; "halifax" = "xxc"; "saskatoon" = "xxc"
# ── CARIBE ──────────────────────────────────────────────────────────
"cuba" = "cu "; "havana" = "cu "; "la habana" = "cu "
"haiti" = "ha "; "port-au-prince" = "ha "; "puerto principe" = "ha "
"jamaica" = "jm "; "kingston" = "jm "
"puerto rico" = "poru"; "san juan" = "poru"
"dominican republic" = "dr "; "republica dominicana" = "dr "
"santo domingo" = "dr "; "santiago de los caballeros" = "dr "
"trinidad and tobago" = "tr "; "trinidad y tobago" = "tr "; "port of spain" = "tr "
"barbados" = "bb "; "bridgetown" = "bb "
# ── CENTROAMERICA ────────────────────────────────────────────────────
"guatemala" = "gt "; "guatemala city" = "gt "; "ciudad de guatemala" = "gt "
"honduras" = "ho "; "tegucigalpa" = "ho "; "san pedro sula" = "ho "
"el salvador" = "es "; "san salvador" = "es "
"nicaragua" = "nu "; "managua" = "nu "
"costa rica" = "cr "; "san jose cr" = "cr "; "san jose costa rica" = "cr "
"panama" = "pn "; "panama city" = "pn "; "ciudad de panama" = "pn "
"belize" = "bh "; "belmopan" = "bh "
# ── SUDAMERICA ───────────────────────────────────────────────────────
"colombia" = "ck "; "bogota" = "ck "; "medellin" = "ck "; "cali" = "ck "
"barranquilla" = "ck "; "cartagena" = "ck "
"venezuela" = "ve "; "caracas" = "ve "; "maracaibo" = "ve "; "valencia" = "ve "
"ecuador" = "ec "; "quito" = "ec "; "guayaquil" = "ec "; "cuenca" = "ec "
"peru" = "pe "; "lima" = "pe "; "arequipa" = "pe "; "cusco" = "pe "
"brasil" = "bl "; "brazil" = "bl "; "sao paulo" = "bl "; "rio de janeiro" = "bl "
"brasilia" = "bl "; "salvador" = "bl "; "fortaleza" = "bl "
"belo horizonte" = "bl "; "manaus" = "bl "; "curitiba" = "bl "
"bolivia" = "bo "; "la paz bolivia" = "bo "; "santa cruz de la sierra" = "bo "
"sucre" = "bo "; "cochabamba" = "bo "
"chile" = "cl "; "santiago" = "cl "; "valparaiso" = "cl "; "concepcion" = "cl "
"argentina" = "ag "; "buenos aires" = "ag "; "cordoba" = "ag "
"rosario" = "ag "; "mendoza" = "ag "; "tucuman" = "ag "
"san miguel de tucuman" = "ag "; "la plata" = "ag "
"uruguay" = "uy "; "montevideo" = "uy "
"paraguay" = "py "; "asuncion" = "py "
"suriname" = "sr "; "paramaribo" = "sr "
"guyana" = "gy "; "georgetown" = "gy "
# ── ESPANA ───────────────────────────────────────────────────────────
"spain" = "sp "; "espana" = "sp "; "madrid" = "sp "; "barcelona" = "sp "
"seville" = "sp "; "sevilla" = "sp "; "bilbao" = "sp "; "valencia espana" = "sp "
"zaragoza" = "sp "; "malaga" = "sp "; "granada" = "sp "; "murcia" = "sp "
"palma" = "sp "; "las palmas" = "sp "; "salamanca" = "sp "
"valladolid" = "sp "; "alicante" = "sp "; "cadiz" = "sp "
"cordoba espana" = "sp "; "toledo" = "sp "; "burgos" = "sp "
# ── FRANCIA / FRANCE ─────────────────────────────────────────────────
"france" = "fr "; "francia" = "fr "; "paris" = "fr "
"marseille" = "fr "; "marsella" = "fr "; "lyon" = "fr "
"toulouse" = "fr "; "nantes" = "fr "; "strasbourg" = "fr "
"montpellier" = "fr "; "bordeaux" = "fr "; "lille" = "fr "
"rennes" = "fr "; "grenoble" = "fr "; "nice" = "fr "
# ── ALEMANIA / GERMANY ───────────────────────────────────────────────
"germany" = "gw "; "alemania" = "gw "; "berlin" = "gw "
"hamburg" = "gw "; "hamburgo" = "gw "; "munich" = "gw "; "munchen" = "gw "
"cologne" = "gw "; "colonia" = "gw "; "frankfurt" = "gw "
"stuttgart" = "gw "; "dusseldorf" = "gw "; "dortmund" = "gw "
"essen" = "gw "; "bremen" = "gw "; "leipzig" = "gw "
"dresden" = "gw "; "hanover" = "gw "; "hannover" = "gw "
"nuremberg" = "gw "; "nurnberg" = "gw "; "heidelberg" = "gw "
"bonn" = "gw "; "gottingen" = "gw "; "tubingen" = "gw "
# ── ITALIA / ITALY ───────────────────────────────────────────────────
"italy" = "it "; "italia" = "it "; "rome" = "it "; "roma" = "it "
"milan" = "it "; "milano" = "it "; "naples" = "it "; "napoles" = "it "
"turin" = "it "; "torino" = "it "; "palermo" = "it "
"genoa" = "it "; "genova" = "it "; "bologna" = "it "; "florence" = "it "
"florencia" = "it "; "firenze" = "it "; "venice" = "it "; "venecia" = "it "
"venezia" = "it "; "verona" = "it "; "catania" = "it "
"bari" = "it "; "messina" = "it "; "padua" = "it "; "padova" = "it "
# ── PORTUGAL ─────────────────────────────────────────────────────────
"portugal" = "po "; "lisbon" = "po "; "lisboa" = "po "
"porto" = "po "; "coimbra" = "po "; "braga" = "po "
# ── PAISES BAJOS / NETHERLANDS ───────────────────────────────────────
"netherlands" = "ne "; "paises bajos" = "ne "; "holanda" = "ne "
"amsterdam" = "ne "; "rotterdam" = "ne "; "the hague" = "ne "
"la haya" = "ne "; "utrecht" = "ne "; "eindhoven" = "ne "
# ── BELGICA / BELGIUM ────────────────────────────────────────────────
"belgium" = "be "; "belgica" = "be "; "brussels" = "be "
"bruselas" = "be "; "bruxelles" = "be "; "antwerp" = "be "
"amberes" = "be "; "ghent" = "be "; "gante" = "be "; "liege" = "be "
# ── SUIZA / SWITZERLAND ──────────────────────────────────────────────
"switzerland" = "sz "; "suiza" = "sz "; "zurich" = "sz "
"geneva" = "sz "; "ginebra" = "sz "
"bern" = "sz "; "berna" = "sz "; "basel" = "sz "; "basilea" = "sz "
"lausanne" = "sz "
# ── AUSTRIA ──────────────────────────────────────────────────────────
"austria" = "au "; "vienna" = "au "; "viena" = "au "
"salzburg" = "au "; "graz" = "au "; "innsbruck" = "au "
# ── SUECIA / SWEDEN ───────────────────────────────────────────────────
"sweden" = "sw "; "suecia" = "sw "; "stockholm" = "sw "
"gothenburg" = "sw "; "gotemburgo" = "sw "; "malmo" = "sw "
"uppsala" = "sw "
# ── NORUEGA / NORWAY ──────────────────────────────────────────────────
"norway" = "no "; "noruega" = "no "; "oslo" = "no "
"bergen" = "no "; "trondheim" = "no "
# ── DINAMARCA / DENMARK ───────────────────────────────────────────────
"denmark" = "dk "; "dinamarca" = "dk "; "copenhagen" = "dk "
"copenhague" = "dk "; "kobenhavn" = "dk "; "aarhus" = "dk "
# ── FINLANDIA / FINLAND ───────────────────────────────────────────────
"finland" = "fi "; "finlandia" = "fi "; "helsinki" = "fi "
"tampere" = "fi "; "turku" = "fi "
# ── IRLANDA / IRELAND ─────────────────────────────────────────────────
"ireland" = "ie "; "irlanda" = "ie "; "dublin" = "ie "
"cork" = "ie "; "galway" = "ie "; "limerick" = "ie "
# ── GRECIA / GREECE ───────────────────────────────────────────────────
"greece" = "gr "; "grecia" = "gr "; "athens" = "gr "
"atenas" = "gr "; "thessaloniki" = "gr "; "tesalonica" = "gr "
# ── LUXEMBURGO / LUXEMBOURG ───────────────────────────────────────────
"luxembourg" = "lu "; "luxemburgo" = "lu "; "luxembourg city" = "lu "
"ciudad de luxemburgo" = "lu "
# ── REINO UNIDO / UNITED KINGDOM ──────────────────────────────────────
"united kingdom" = "enk"; "reino unido" = "enk"; "england" = "enk"
"inglaterra" = "enk"; "london uk" = "enk"; "londres" = "enk"
"manchester" = "enk"; "birmingham" = "enk"; "leeds" = "enk"
"liverpool" = "enk"; "sheffield" = "enk"; "bristol" = "enk"
"nottingham" = "enk"; "leicester" = "enk"; "oxford" = "enk"
"cambridge uk" = "enk"; "coventry" = "enk"; "newcastle" = "enk"
"exeter" = "enk"; "york" = "enk"; "bath" = "enk"
"scotland" = "stk"; "escocia" = "stk"; "edinburgh" = "stk"
"edimburgo" = "stk"; "glasgow" = "stk"; "aberdeen" = "stk"
"dundee" = "stk"; "st andrews" = "stk"
"wales" = "wlk"; "gales" = "wlk"; "cardiff" = "wlk"
"swansea" = "wlk"; "newport" = "wlk"
"northern ireland" = "xxk"; "irlanda del norte" = "xxk"; "belfast" = "xxk"
# ── RUSIA / RUSSIA ────────────────────────────────────────────────────
"russia" = "ru "; "rusia" = "ru "; "moscow" = "ru "; "moscu" = "ru "
"saint petersburg" = "ru "; "san petersburgo" = "ru "
"novosibirsk" = "ru "; "yekaterinburg" = "ru "
# ── POLONIA / POLAND ──────────────────────────────────────────────────
"poland" = "pl "; "polonia" = "pl "; "warsaw" = "pl "; "varsovia" = "pl "
"krakow" = "pl "; "cracovia" = "pl "; "lodz" = "pl "; "wroclaw" = "pl "
# ── EUROPA DEL ESTE / EASTERN EUROPE ─────────────────────────────────
"czech republic" = "xr "; "republica checa" = "xr "; "czechia" = "xr "
"prague" = "xr "; "praga" = "xr "; "brno" = "xr "
"hungary" = "hu "; "hungria" = "hu "; "budapest" = "hu "
"romania" = "rm "; "rumania" = "rm "; "bucharest" = "rm "; "bucarest" = "rm "
"bulgaria" = "bu "; "sofia" = "bu "
"croatia" = "ci "; "croacia" = "ci "; "zagreb" = "ci "
"serbia" = "rb "; "belgrade" = "rb "; "belgrado" = "rb "
"slovakia" = "xo "; "eslovaquia" = "xo "; "bratislava" = "xo "
"slovenia" = "xv "; "eslovenia" = "xv "; "ljubljana" = "xv "
"ukraine" = "un "; "ucrania" = "un "; "kyiv" = "un "; "kiev" = "un "
# ── ASIA ──────────────────────────────────────────────────────────────
"japan" = "ja "; "japon" = "ja "; "tokyo" = "ja "; "tokio" = "ja "
"osaka" = "ja "; "kyoto" = "ja "; "yokohama" = "ja "; "nagoya" = "ja "
"china" = "cc "; "beijing" = "cc "; "pekin" = "cc "; "shanghai" = "cc "
"shenzhen" = "cc "; "guangzhou" = "cc "; "chengdu" = "cc "
"south korea" = "ko "; "corea del sur" = "ko "; "seoul" = "ko "
"busan" = "ko "; "incheon" = "ko "
"india" = "ii "; "new delhi" = "ii "; "nueva delhi" = "ii "
"mumbai" = "ii "; "bombay" = "ii "; "bangalore" = "ii "; "kolkata" = "ii "
"turkey" = "tu "; "turquia" = "tu "; "istanbul" = "tu "
"ankara" = "tu "; "izmir" = "tu "
"israel" = "is "; "tel aviv" = "is "; "jerusalem" = "is "; "jerusalen" = "is "
"singapore" = "si "; "singapur" = "si "
"hong kong" = "cc "
"taiwan" = "ch "; "taipei" = "ch "
# ── AFRICA ────────────────────────────────────────────────────────────
"south africa" = "sa "; "sudafrica" = "sa "; "johannesburg" = "sa "
"cape town" = "sa "; "ciudad del cabo" = "sa "; "pretoria" = "sa "
"nigeria" = "nr "; "lagos" = "nr "; "abuja" = "nr "
"egypt" = "ua "; "egipto" = "ua "; "cairo" = "ua "; "el cairo" = "ua "
"kenya" = "ke "; "nairobi" = "ke "
"morocco" = "mr "; "marruecos" = "mr "; "rabat" = "mr "
"casablanca" = "mr "; "marrakech" = "mr "
"ethiopia" = "et "; "etiopia" = "et "; "addis ababa" = "et "
"ghana" = "gh "; "accra" = "gh "
"tanzania" = "tz "; "dar es salaam" = "tz "
"senegal" = "sg "; "dakar" = "sg "
# ── OCEANIA ───────────────────────────────────────────────────────────
"australia" = "at "; "sydney" = "at "; "melbourne" = "at "
"brisbane" = "at "; "perth" = "at "; "adelaide" = "at "; "canberra" = "at "
"new zealand" = "nz "; "nueva zelanda" = "nz "; "nueva zelandia" = "nz "
"auckland" = "nz "; "wellington" = "nz "; "christchurch" = "nz "
}
# Primero busqueda exacta
if ($map.ContainsKey($p)) { return $map[$p] }
# Si no hay coincidencia exacta, buscar si alguna clave esta contenida en el string
# (maneja casos como "New York, N.Y.", "Buenos Aires, Argentina", etc.)
# Se ordena de mayor a menor longitud para preferir claves mas especificas
$sortedKeys = $map.Keys | Sort-Object { $_.Length } -Descending
foreach ($key in $sortedKeys) {
if ($p -match [regex]::Escape($key)) { return $map[$key] }
}
return "xx "
}
# ============================================================
# FUNCION PRINCIPAL: arma el registro MARC 21 completo a partir
# del hashtable $r capturado por CollectRecord/EditField.
# ============================================================
# Por cada dato disponible agrega su campo correspondiente:
# 001/005/008 numero de acceso, fecha de captura, campo fijo
# (fecha, tipo de registro, pais, idioma...)
# 040 fuente de catalogacion (fija: MX-MxBAC, RDA)
# 852 localizacion fisica (ej. 'Bufalo')
# 082/084 clasificacion Dewey o local, segun lo elegido
# 020/022/024 ISBN / ISSN / DOI
# 100/110/130 primer autor (personal / corporativo / titulo
# uniforme) y 700/710 para autores adicionales
# 245/250 titulo (con subtitulo y mencion de responsabilidad)
# y edicion
# 264 lugar, editorial y ano de publicacion
# 300 descripcion fisica (paginas, ilustraciones, cm)
# 336/337/338 tipo de contenido/medio/soporte RDA
# 490 serie
# 500/505/590 notas (general, de contenido, de ejemplar)
# 650 materias (con subdivisiones separadas por '--')
# 773/362 datos de revista (articulo) o designacion de
# fasciculo (numero completo de revista)
# 901 coleccion (ej. 'General', 'Infantil', 'Archivo')
# Al final reordena todos los campos por numero de etiqueta (tag),
# arma el lider (LDR) segun el tipo de registro, y devuelve el
# texto completo en bytes UTF-8 listo para escribirse a disco en
# formato MARCMaker (una linea '=TAG indicadores$subcampos' por
# campo).
function BuildMARC {
param([hashtable]$r)
$enc = New-Object System.Text.UTF8Encoding $false
$fields = @()
if ($r.AccNo) {
$fields += @{ Tag="001"; Control=$true; Data=$r.AccNo }
}
$now = Get-Date
$date005 = $now.ToString("yyyyMMddHHmmss") + ".0"
$fields += @{ Tag="005"; Control=$true; Data=$date005 }
# === GENERACION BLINDADA DEL CAMPO 008 ===
$date008 = $now.ToString("yyMMdd")
$ctry = if ($r.ForcedCountry) { $r.ForcedCountry } else { Get-MARCCountry $r.Place }
$year4 = if ($r.Year) { $r.Year.PadRight(4, " ").Substring(0, 4) } else { " " }
$ctry3 = $ctry.PadRight(3, " ").Substring(0, 3)
$illus008 = if ($r.Illstr) { "a" } else { " " }
if ($r.RecordType -eq "serial") {
# Posiciones 18-39: uu p 0 a0spa d (Total: 22 caracteres)
$f008_data = $date008 + "c" + $year4 + "9999" + $ctry3 + "uu p 0 a0spa d"
} elseif ($r.RecordType -eq "paper") {
# Posiciones 18-39: uu p 0 a0spa d (Total: 22 caracteres)
$f008_data = $date008 + "s" + $year4 + " " + $ctry3 + "uu p 0 a0spa d"
} else {
# Posiciones 18-39: [illus] 000 0 spa d (Total: 22 caracteres)
$f008_data = $date008 + "s" + $year4 + " " + $ctry3 + $illus008 + " 000 0 spa d"
}
# Corte de seguridad para garantizar los 40 caracteres exactos
$f008_data = $f008_data.PadRight(40, " ").Substring(0, 40)
$fields += @{ Tag="008"; Control=$true; Data=$f008_data }
$fields += MakeField "040" " " " " `
((MakeSubfield "a" "MX-MxBAC") + (MakeSubfield "b" "spa") +
(MakeSubfield "e" "rda"))
$fields += MakeField "852" "2" " " (MakeSubfield "e" $r.Location)
# Clasificacion local u oficial
if ($r.CallNo) {
$parts = $r.CallNo -split '\s+', 2
$class = $parts[0]
$cutter = if ($parts.Count -gt 1) { $parts[1] } else { "" }
if ($r.CallType -eq "local") {
$fields += MakeField "084" " " " " `
((MakeSubfield "a" $class) + (MakeSubfield "b" $cutter))
} else {
$fields += MakeField "082" "0" "4" `
((MakeSubfield "a" $class) + (MakeSubfield "b" $cutter) + (MakeSubfield "2" "20"))
}
}
# Identificadores estandar
if ($r.ISBN) {
$fields += MakeField "020" " " " " (MakeSubfield "a" $r.ISBN)
}
if ($r.ISSN -and $r.RecordType -ne "paper") {
$fields += MakeField "022" " " " " (MakeSubfield "a" $r.ISSN)
}
if ($r.DOI) {
$fields += MakeField "024" "7" " " ((MakeSubfield "a" $r.DOI) + (MakeSubfield "2" "doi"))
}
# Autores (Con logica de ruteo para 130)
$authorArray = @($r.Authors)
if ($authorArray.Count -gt 0) {
$first = $authorArray[0]
if ($first.Type -eq 3) {
$ind1 = if ([string]::IsNullOrWhiteSpace($first.NonFiling)) { "0" } else { $first.NonFiling.Substring(0,1) }
$d130 = MakeSubfield "a" $first.Name
if ($first.Part) { $d130 += MakeSubfield "p" $first.Part }
if ($first.Version) { $d130 += MakeSubfield "s" $first.Version }
if ($first.Language) { $d130 += MakeSubfield "l" $first.Language }
$fields += MakeField "130" $ind1 " " $d130
} else {
$firstTag = if ($first.Corporate) { "110" } else { "100" }
$firstInd1 = if ($first.Corporate) { "2" } else { "1" }
$firstName = $first.Name
$fields += MakeField $firstTag $firstInd1 " " `
((MakeSubfield "a" $firstName) + (MakeSubfield "e" $first.Relator))
}
for ($i = 1; $i -lt $authorArray.Count; $i++) {
$curr = $authorArray[$i]
if ($curr.Type -eq 3) { continue }
$addTag = if ($curr.Corporate) { "710" } else { "700" }
$addInd1 = if ($curr.Corporate) { "2" } else { "1" }
$addName = $curr.Name
$fields += MakeField $addTag $addInd1 " " `
((MakeSubfield "a" $addName) + (MakeSubfield "e" $curr.Relator))
}
}
# 245: datos limpios, sin puntuación ISBD entre subcampos ni al final
$t245a = $r.Title -replace '[.!?:/ ]+$', ''
$ind1_245 = if ($authorArray.Count -gt 0) { "1" } else { "0" }
$d245 = MakeSubfield "a" $t245a
if ($r.TitleSub) {
$subClean = $r.TitleSub -replace '[.!?:/ ]+$', ''
$d245 += MakeSubfield "b" $subClean
}
if ($r.TitleResp) {
$t245c = $r.TitleResp -replace '[.!?]+$', ''
$d245 += MakeSubfield "c" $t245c
}
$fields += MakeField "245" $ind1_245 "0" $d245
if ($r.Edition) {
$fields += MakeField "250" " " " " (MakeSubfield "a" $r.Edition)
}
$placeStr = if ($r.Place) { $r.Place } else { "" }
$pubStr = if ($r.Publisher) { $r.Publisher } else { "" }
$yearStr = if ($r.Year) { $r.Year } else { "" }
$pubData = (MakeSubfield "a" $placeStr) + (MakeSubfield "b" $pubStr) + (MakeSubfield "c" $yearStr)
if ($pubData -ne "") {
$fields += MakeField "264" " " "1" $pubData
}
if ($r.Pages -or $r.Illstr -or $r.Cm) {
$pagesStr = if ($r.Pages) { $r.Pages } else { "" }
$illstrStr = if ($r.Illstr) { $r.Illstr } else { "" }
$cmStr = if ($r.Cm) { $r.Cm + " cm" } else { "" }
$fields += MakeField "300" " " " " `
((MakeSubfield "a" $pagesStr) + (MakeSubfield "b" $illstrStr) + (MakeSubfield "c" $cmStr))
}
if ($r.Mat) {
$fields += MakeField "336" " " " " ((MakeSubfield "a" $r.Mat.CT_a) + (MakeSubfield "b" $r.Mat.CT_b) + (MakeSubfield "2" "rdacontent"))
$fields += MakeField "337" " " " " ((MakeSubfield "a" $r.Mat.MT_a) + (MakeSubfield "b" $r.Mat.MT_b) + (MakeSubfield "2" "rdamedia"))
$fields += MakeField "338" " " " " ((MakeSubfield "a" $r.Mat.CA_a) + (MakeSubfield "b" $r.Mat.CA_b) + (MakeSubfield "2" "rdacarrier"))
}
if ($r.Series) { $fields += MakeField "490" "0" " " (MakeSubfield "a" $r.Series) }
if ($r.Note500) { $fields += MakeField "500" " " " " (MakeSubfield "a" $r.Note500) }
if ($r.Note505) { $fields += MakeField "505" "0" " " (MakeSubfield "a" $r.Note505) }
if ($r.Notes) { $fields += MakeField "590" " " " " (MakeSubfield "a" $r.Notes) }
foreach ($subj in $r.Subjects) {
$parts = @($subj -split '--' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" })
$subflds = MakeSubfield "a" $parts[0]
for ($i = 1; $i -lt $parts.Count; $i++) {
$subflds += MakeSubfield "x" $parts[$i]
}
# Datos limpios: sin punto final añadido
$fields += MakeField "650" " " "4" $subflds
}
# Campo 773 para Papers / Articulos
if ($r.RecordType -eq "paper" -and $r.JournalTitle) {
$f773 = (MakeSubfield "t" $r.JournalTitle) + (MakeSubfield "g" $r.JournalIssue)
if ($r.ISSN) { $f773 += MakeSubfield "x" $r.ISSN }
$fields += MakeField "773" "0" " " $f773
}
# Campo 362 para Publicaciones Seriadas (Fasciculos Completos)
if ($r.RecordType -eq "serial" -and $r.SerialDesignation) {
$fields += MakeField "362" "0" " " (MakeSubfield "a" $r.SerialDesignation)
}
$col = if ($r.Collection) { $r.Collection } else { "General" }
$fields += MakeField "901" " " " " (MakeSubfield "a" $col)
# Reordenar todos los campos por número de etiqueta (tag), sin importar
# el orden en que se hayan ido agregando arriba. Se asigna un índice de
# orden original antes de ordenar, y se usa como desempate explícito,
# para garantizar que los campos repetidos (ej. varios 650 o 700)
# conserven su orden de captura sin depender de la estabilidad de
# Sort-Object (que no está garantizada para colecciones de este tamaño).
for ($idx = 0; $idx -lt $fields.Count; $idx++) { $fields[$idx].Order = $idx }
$fields = @($fields | Sort-Object -Property @{Expression={[int]$_.Tag}}, @{Expression={$_.Order}})
# LÓGICA DE EXPORTACIÓN EN TEXTO PLANO (MARCBreaker)
$pos07 = if ($r.RecordType -eq "paper") { "b" } elseif ($r.RecordType -eq "serial") { "s" } else { "m" }
$leader = "00000na" + $pos07 + " a22000004i 4500"
$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine("=LDR $leader")
foreach ($f in $fields) {
if ($f.Control) {
[void]$sb.AppendLine("=" + $f.Tag + " " + $f.Data)
} else {
$i1 = if ([string]::IsNullOrWhiteSpace($f.Ind1)) { "\" } else { $f.Ind1 }
$i2 = if ([string]::IsNullOrWhiteSpace($f.Ind2)) { "\" } else { $f.Ind2 }
# Reemplazar el carácter binario 0x1F por el signo de dólar $
$cleanData = $f.Data.Replace([char]0x1F, '$')
[void]$sb.AppendLine("=" + $f.Tag + " " + $i1 + $i2 + $cleanData)
}
}
[void]$sb.AppendLine()
return $enc.GetBytes($sb.ToString())
}
# ============================================================
# Flujo interactivo de captura: hace, en orden, TODAS las
# preguntas necesarias para llenar un registro nuevo (nivel
# bibliografico, numero de acceso, ISBN, clasificacion, tipo de
# material, autores, titulo, edicion, datos de revista si aplica,
# lugar/editorial/ano, descripcion fisica, serie, notas, materias,
# coleccion y localizacion). Devuelve el hashtable $r ya lleno,
# que luego se muestra en ShowPreview antes de guardar.
function CollectRecord {
Clear-Host
Write-Host (" " + (Get-Date -Format "dddd, dd MMMM yyyy HH:mm:ss")) -ForegroundColor White
Show-SectionHeader "NUEVO REGISTRO"
$r = @{}
Show-SectionHeader "NIVEL BIBLIOGRÁFICO"
Write-Host " 1 Libro / Monografía (LDR/07 = m) [defecto]" -ForegroundColor DarkGray
Write-Host " 2 Paper / Artículo de revista (LDR/07 = b)" -ForegroundColor DarkGray
Write-Host " 3 Número completo de revista (LDR/07 = s)" -ForegroundColor DarkGray
Write-Host " Selección [1]: " -NoNewline -ForegroundColor Cyan
$recTypeRaw = (Read-Host).Trim()
if ($recTypeRaw -eq "2") { $r.RecordType = "paper" }
elseif ($recTypeRaw -eq "3") { $r.RecordType = "serial" }
else { $r.RecordType = "book" }
$r.AccNo = Ask "Número de acceso" -Required
if ($r.RecordType -eq "book" -or $r.RecordType -eq "serial") {
$r.ISBN = Ask "ISBN"
}
Show-SectionHeader "TIPO DE CLASIFICACIÓN"
Write-Host " 1 Dewey 20 (campo 082) [defecto]" -ForegroundColor DarkGray
Write-Host " 2 Clasificación local (campo 084)" -ForegroundColor DarkGray
Write-Host " Selección [1]: " -NoNewline -ForegroundColor Cyan
$callTypeRaw = (Read-Host).Trim()
$r.CallType = if ($callTypeRaw -eq "2") { "local" } else { "dewey20" }
$r.CallNo = Ask "Call number / Signatura" -Required
Show-SectionHeader "TIPO DE MATERIAL"
$r.Mat = AskMaterialType
$authorsRaw = Ask "Autor(es) (separar múltiples con punto y coma)"
$authorNames = if ($authorsRaw) { @($authorsRaw -split ";" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }) } else { @() }
$r.Authors = @()
foreach ($name in $authorNames) {
$aType = AskAuthorType
if ($aType -eq 3) {
$nf = Ask " Caracteres a omitir al alfabetizar (0-9)" -Default "0"
$p = Ask " Parte de la obra (`$p) [Opcional]"
$s = Ask " Version (`$s) [Opcional]"
$l = Ask " Idioma (`$l) [Opcional]"
$r.Authors += [pscustomobject]@{ Name=$name; Type=3; Corporate=$false; Relator=""; NonFiling=$nf; Part=$p; Version=$s; Language=$l }
} else {
$isCorp = ($aType -eq 2)
$rel = AskRelator $name
$r.Authors += [pscustomobject]@{ Name=$name; Type=$aType; Corporate=$isCorp; Relator=$rel }
}
}
$r.Title = Ask "Título (245 `$a)" -Required
$r.TitleSub = Ask "Subtitulo (245 `$b) [Opcional]"
$r.TitleResp = Ask "Mencion de responsabilidad (245 `$c) [Opcional]"
$edNum = Ask "Edición (solo el número)"
$r.Edition = if ($edNum) { ("${edNum}a edición") } else { "" }
# Bloque exclusivo y agrupado para Papers y Revistas
if ($r.RecordType -eq "paper") {
Show-SectionHeader "DATOS DEL ARTÍCULO Y LA REVISTA"
$r.JournalTitle = Ask "Titulo de la revista (773 `$t)" -Required
$r.JournalIssue = Ask "Volumen, Numero, Fecha, Paginas (773 `$g) (ej. Vol. 5, no. 2, p. 45-60)" -Required
$r.ISSN = Ask "ISSN de la revista (022)"
$r.DOI = Ask "DOI del articulo (024 `$a)"
} elseif ($r.RecordType -eq "serial") {
Show-SectionHeader "DATOS DEL FASCÍCULO (REVISTA)"
$r.SerialDesignation = Ask "Designacion secuencial (362 `$a) (ej. Vol. 5, no. 2 (Mayo 2026))" -Required
$r.ISSN = Ask "ISSN de la revista (022)"
$r.DOI = Ask "DOI del numero (024 `$a) [Opcional, poco frecuente]"
}
$rawPlace = Ask "Lugar de publicación"
if ($rawPlace -match '\[([^\]]+)\]\s*$') {
$r.ForcedCountry = Get-MARCCountry $matches[1]
$stripped = $rawPlace -replace '\s*\[[^\]]+\]\s*$', ''
$r.Place = if ($stripped -eq "") { $matches[0] } else { $stripped }
} else {
$r.ForcedCountry = $null
$r.Place = $rawPlace
}
$r.Publisher = Ask "Editorial"
$r.Year = Ask "Año"
$r.Pages = Ask "Extension (`$a)"
$r.Illstr = Ask "Otros detalles fisicos (`$b)"
$r.Cm = Ask "Dimensiones (`$c) (cm)"
$r.Series = Ask "Serie"
$r.Note500 = ""
$r.Note505 = ""
$r.Notes = ""
Write-Host " ¿Agregar notas? [s/N]: " -NoNewline -ForegroundColor Cyan
$wantNotes = (Read-Host).Trim()
if ($wantNotes -match "^[sS]$") {
$r.Note500 = Ask "Nota general (500)"
$r.Note505 = Ask "Nota de contenido (505)"
$r.Notes = Ask "Nota de ejemplar (590)"
}
$r.Subjects = AskList "Subject headings"
$r.Collection = Ask "Colección (901)" -Default "General"
$r.Location = Ask "Localización (852 `$e)" -Default "Búfalo"
return $r
}
# Imprime una vista previa NUMERADA (1 a 22) de todos los campos
# capturados, con su etiqueta MARC correspondiente, para que el
# usuario revise antes de guardar. Los numeros de esta lista son
# los mismos que despues acepta EditField para corregir un campo
# especifico sin tener que volver a capturar todo el registro.
function ShowPreview {
param([hashtable]$r)
$prevPhys = ($(if ($r.Pages) { $r.Pages } else { "" }) +
$(if ($r.Illstr) { " " + $r.Illstr } else { "" }) +
$(if ($r.Cm) { " " + $r.Cm + " cm" } else { "" })).Trim()
$matLabel = if ($r.Mat) { $r.Mat.Label } else { "(no definido)" }
$callTag = if ($r.CallType -eq "local") { "084" } else { "082" }
$recLabel = if ($r.RecordType -eq "paper") { "Paper / Articulo (b)" } elseif ($r.RecordType -eq "serial") { "Revista Completa (s)" } else { "Libro / Monografia (m)" }
Show-SectionHeader "VISTA PREVIA DEL REGISTRO"
Write-Host " 1. LDR Nivel biblio. : " -NoNewline -ForegroundColor DarkGray; Write-Host $recLabel -ForegroundColor White
Write-Host " 2. 001 Acceso : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.AccNo -ForegroundColor White
Write-Host " 3. 020 ISBN : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.ISBN -ForegroundColor White
Write-Host " 4. 022 ISSN : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.ISSN -ForegroundColor White
Write-Host " 5. 024 DOI : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.DOI -ForegroundColor White
Write-Host " 6. $callTag Call number : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.CallNo -ForegroundColor White
Write-Host " 7. 336 Tipo material : " -NoNewline -ForegroundColor DarkGray; Write-Host $matLabel -ForegroundColor White
$firstAuth = $r.Authors | Select-Object -First 1
$authorTag = if ($firstAuth -and $firstAuth.Type -eq 3) { "130" } elseif ($firstAuth -and $firstAuth.Corporate) { "110" } else { "100" }
Write-Host " 8. $authorTag Autor(es) : " -NoNewline -ForegroundColor DarkGray
Write-Host (($r.Authors | ForEach-Object {
if ($_.Type -eq 3) {
$_.Name + "." + $(if ($_.Part) { " " + $_.Part + "." } else { "" }) + $(if ($_.Version) { " " + $_.Version + "." } else { "" }) + $(if ($_.Language) { " " + $_.Language + "." } else { "" }) + " [Titulo Uniforme]"
} else {
$_.Name + " [" + ($_.Relator -replace '\.$','') + $(if ($_.Corporate) { "/corp" } else { "" }) + "]"
}
}) -join " ; ") -ForegroundColor White
$prev245 = $r.Title + $(if ($r.TitleSub) { " $b " + $r.TitleSub } else { "" }) + $(if ($r.TitleResp) { " $c " + $r.TitleResp } else { "" })
Write-Host " 9. 245 Title : " -NoNewline -ForegroundColor DarkGray; Write-Host $prev245 -ForegroundColor White
Write-Host " 10. 250 Edition : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.Edition -ForegroundColor White
Write-Host " 11. 264 Place : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.Place -ForegroundColor White
Write-Host " 12. 264 Publisher : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.Publisher -ForegroundColor White
Write-Host " 13. 264 Year : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.Year -ForegroundColor White
Write-Host " 14. 300 Physical desc : " -NoNewline -ForegroundColor DarkGray; Write-Host $prevPhys -ForegroundColor White
if ($r.RecordType -eq "paper") {
Write-Host " 15. 773 Revista : " -NoNewline -ForegroundColor DarkGray; Write-Host "$($r.JournalTitle) $($r.JournalIssue)" -ForegroundColor White
} elseif ($r.RecordType -eq "serial") {
Write-Host " 15. 362 Designacion : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.SerialDesignation -ForegroundColor White
} else {
Write-Host " 15. 773/362 Revista : " -NoNewline -ForegroundColor DarkGray; Write-Host "" -ForegroundColor White
}
Write-Host " 16. 490 Series : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.Series -ForegroundColor White
Write-Host " 17. 500 General note : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.Note500 -ForegroundColor White
Write-Host " 18. 505 Nota contenido : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.Note505 -ForegroundColor White
Write-Host " 19. 590 Copy notes : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.Notes -ForegroundColor White
Write-Host " 20. 650 Subjects : " -NoNewline -ForegroundColor DarkGray; Write-Host ($r.Subjects -join " | ") -ForegroundColor White
Write-Host " 21. 901 Collection : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.Collection -ForegroundColor White
Write-Host " 22. 852 Localización : " -NoNewline -ForegroundColor DarkGray; Write-Host $r.Location -ForegroundColor White
Write-Host " $($script:Sep)" -ForegroundColor DarkGray
}
# Permite corregir UN campo especifico (por su numero, 1-22, el
# mismo numero que se ve en ShowPreview) sin repetir todas las
# preguntas. Cada 'case' del switch repite la misma pregunta que
# se hizo originalmente en CollectRecord para ese campo.
function EditField {
param([hashtable]$r, [int]$n)
switch ($n) {
1 {
Show-SectionHeader "NIVEL BIBLIOGRÁFICO"
Write-Host " 1 Libro / Monografía (LDR/07 = m)" -ForegroundColor DarkGray
Write-Host " 2 Paper / Artículo de revista (LDR/07 = b)" -ForegroundColor DarkGray
Write-Host " 3 Número completo de revista (LDR/07 = s)" -ForegroundColor DarkGray
Write-Host " Selección: " -NoNewline -ForegroundColor Cyan
$recTypeRaw = (Read-Host).Trim()
if ($recTypeRaw -eq "2") { $r.RecordType = "paper" }
elseif ($recTypeRaw -eq "3") { $r.RecordType = "serial" }
else { $r.RecordType = "book" }
}
2 { $r.AccNo = Ask "Número de acceso" -Required }
3 { $r.ISBN = Ask "ISBN" }
4 { $r.ISSN = Ask "ISSN" }
5 { $r.DOI = Ask "DOI (024)" }
6 {
Show-SectionHeader "TIPO DE CLASIFICACIÓN"
Write-Host " 1 Dewey 20 (campo 082) [defecto]" -ForegroundColor DarkGray
Write-Host " 2 Clasificación local (campo 084)" -ForegroundColor DarkGray
Write-Host " Selección [1]: " -NoNewline -ForegroundColor Cyan
$callTypeRaw = (Read-Host).Trim()
$r.CallType = if ($callTypeRaw -eq "2") { "local" } else { "dewey20" }
$r.CallNo = Ask "Call number / Signatura" -Required
}
7 { Show-SectionHeader "TIPO DE MATERIAL"; $r.Mat = AskMaterialType }
8 {
$authorsRaw = Ask "Autor(es) (separar múltiples con punto y coma)"
$authorNames = if ($authorsRaw) { @($authorsRaw -split ";" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }) } else { @() }
$r.Authors = @()
foreach ($name in $authorNames) {
$aType = AskAuthorType
if ($aType -eq 3) {
$nf = Ask " Caracteres a omitir al alfabetizar (0-9)" -Default "0"
$p = Ask " Parte de la obra (`$p) [Opcional]"
$s = Ask " Version (`$s) [Opcional]"
$l = Ask " Idioma (`$l) [Opcional]"
$r.Authors += [pscustomobject]@{ Name=$name; Type=3; Corporate=$false; Relator=""; NonFiling=$nf; Part=$p; Version=$s; Language=$l }
} else {
$isCorp = ($aType -eq 2)
$rel = AskRelator $name
$r.Authors += [pscustomobject]@{ Name=$name; Type=$aType; Corporate=$isCorp; Relator=$rel }
}
}
}
9 {
$r.Title = Ask "Título (245 `$a)" -Required
$r.TitleSub = Ask "Subtitulo (245 `$b) [Opcional]"
$r.TitleResp = Ask "Mencion de responsabilidad (245 `$c) [Opcional]"
}
10 { $edNum = Ask "Edición (solo el número)"; $r.Edition = if ($edNum) { ("${edNum}a edición") } else { "" } }
11 {
$rawPlace = Ask "Lugar de publicación"
if ($rawPlace -match '\[([^\]]+)\]\s*$') {
$r.ForcedCountry = Get-MARCCountry $matches[1]
$stripped = $rawPlace -replace '\s*\[[^\]]+\]\s*$', ''
$r.Place = if ($stripped -eq "") { $matches[0] } else { $stripped }
} else {
$r.ForcedCountry = $null; $r.Place = $rawPlace
}
}
12 { $r.Publisher = Ask "Editorial" }
13 { $r.Year = Ask "Año" }
14 {
$r.Pages = Ask "Extension (`$a)"
$r.Illstr = Ask "Otros detalles fisicos (`$b)"
$r.Cm = Ask "Dimensiones (`$c) (cm)"
}
15 {
if ($r.RecordType -eq "paper") {
$r.JournalTitle = Ask "Titulo de la revista (773 `$t)"
$r.JournalIssue = Ask "Volumen, Numero, Fecha, Paginas (773 `$g)"
} elseif ($r.RecordType -eq "serial") {
$r.SerialDesignation = Ask "Designacion secuencial (362 `$a)"
}
}
16 { $r.Series = Ask "Serie" }
17 { $r.Note500 = Ask "Nota general (500)" }
18 { $r.Note505 = Ask "Nota de contenido (505)" }
19 { $r.Notes = Ask "Nota de ejemplar (590)" }
20 { $r.Subjects = AskList "Subject headings" }
21 { $r.Collection = Ask "Colección (901)" -Default "General" }
22 { $r.Location = Ask "Localización (852 `$e)" -Default "Búfalo" }
}
return $r
}
# ============================================================
# PROGRAMA PRINCIPAL
# ============================================================
# Se asegura de que exista la carpeta de salida, muestra el
# encabezado, y entra al bucle principal: captura un registro,
# lo muestra en vista previa, deja editar campo por campo hasta
# que el usuario decide Guardar (G) o Descartar (X), y al final
# pregunta si se desea capturar otro registro (bucle 'while $true'
# que solo termina si se responde que no).
if (-not (Test-Path $OutputFolder)) { New-Item -ItemType Directory -Path $OutputFolder | Out-Null }
Clear-Host
Write-Host ""
Write-Host " CATALOGACIÓN MARC — BAC" -ForegroundColor Cyan
Write-Host " Biblioteca Alonso Cossío" -ForegroundColor DarkCyan
Write-Host " $($script:Sep)" -ForegroundColor DarkGray
Write-Host " Salida: $OutputFolder" -ForegroundColor DarkGray
while ($true) {
$record = CollectRecord
while ($true) {
ShowPreview $record
Write-Host " Número de campo para editar, G para guardar, X para descartar: " -NoNewline -ForegroundColor Cyan
$input = Read-Host
if ($input -match "^[xX]$") {
Write-Host " Registro descartado." -ForegroundColor Yellow
break
}
if ($input -match "^[gG]$") {
$marcBytes = BuildMARC $record
$safeName = if ($record.AccNo) { $record.AccNo -replace '[\\/:*?"<>|]', '_' } else { (Get-Date -Format "yyyyMMdd_HHmmss") }
$outPath = Join-Path $OutputFolder "${safeName}.mrk"
if (Test-Path $outPath) {
Write-Host ""
Write-Host " Advertencia: '$safeName.mrk' ya existe." -ForegroundColor Yellow
Write-Host " ¿Sobreescribir? [s/N]: " -NoNewline -ForegroundColor Yellow
$confirm = Read-Host
if ($confirm -notmatch "^[sS]$") {
Write-Host " Guardado cancelado." -ForegroundColor DarkGray
continue
}
}
[System.IO.File]::WriteAllBytes($outPath, $marcBytes)
Write-Host ""
Write-Host " Guardado -> $outPath" -ForegroundColor Green
Write-Host ""
break
}
$n = $input -as [int]
if ($n -ge 1 -and $n -le 22) {
$record = EditField $record $n
} else {
Write-Host " Ingresa un número del 1 al 22, G para guardar o X para descartar." -ForegroundColor Yellow
}
}
Write-Host ""
Write-Host " $($script:Sep)" -ForegroundColor DarkGray
Write-Host " ¿Catalogar otro registro? [s/N]: " -NoNewline -ForegroundColor Cyan
$again = Read-Host
if ($again -notmatch "^[sS]$") {
Write-Host ""
Write-Host " Hasta luego." -ForegroundColor Cyan
Write-Host ""
break
}
}
<#
============================================================================
Search.ps1
----------------------------------------------------------------------------
Tipo de archivo : Script de Windows PowerShell (.ps1)
Requiere : Windows PowerShell 5.1 o superior (tambien funciona
en PowerShell 7+). No requiere modulos externos ni
conexion a internet.
Proyecto : Biblioteca Alonso Cossio (BAC)
Licencia : MIT. Se permite uso, copia, modificacion y distribucion
conservando este aviso de copyright. Texto completo en
https://bibliotecaalonsocossio.com/scripts.html
Copyright : (c) 2026 Alonso Cossio Vazquez
Proposito : CONSULTA. Es el buscador/explorador de catalogo que
se usa desde la consola, en la propia computadora.
QUE HACE
Lee TODOS los archivos .mrk (formato MARCMaker, el mismo que produce
Catalog.ps1) de una carpeta, los interpreta como registros MARC 21 y
arma con ellos un catalogo en memoria. A partir de ahi ofrece un menu
interactivo para:
- Buscar por titulo, autor, materia, clasificacion, editorial, rango
de anos, numero de acceso, coleccion, ISBN, ISSN, serie, notas o
cualquier campo a la vez (busqueda "todas las palabras" sin
distinguir mayusculas ni acentos).
- Explorar por rango Dewey, decada, formato, coleccion o un registro
al azar.
- Ver estadisticas generales (total de registros, materias y
autores mas frecuentes, distribucion por Dewey y por formato).
- Revisar la calidad de los datos (registros sin clasificacion, sin
materias, sin ano, sin autor, o libros sin ISBN).
- Ver el registro MARC completo (crudo) de cualquier ficha.
- Generar la cita de un registro en 5 estilos: APA, MLA, Chicago,
ISO 690 y BibTeX.
- Exportar el catalogo completo (o por coleccion) a CSV.
COMO SE EJECUTA
powershell -File .\Search.ps1
(o, en el Explorador de Windows, clic derecho -> "Ejecutar con
PowerShell"). Por defecto busca los .mrk en la misma carpeta donde
esta el script; tambien se puede indicar otra con -Folder.
PARAMETRO
-Folder Carpeta (se busca de forma recursiva) donde estan los
archivos .mrk a cargar. Por defecto: la carpeta del script.
NOTA SOBRE ESTA VERSION PUBLICADA EN EL SITIO WEB
La logica de parseo y busqueda de este script (Parse-MarcRecord,
Load-Catalog, Do-Search, Strip-Diacritics, ConvertTo-NaturalKey,
Build-Citation...) fue "traducida" tambien a Python
(build_index.py, en el repositorio BAC-web) para poder ofrecer esta
misma busqueda directamente en la pagina "Catalogo" del sitio, sin
necesidad de instalar PowerShell. Ambas versiones deben mantenerse
equivalentes en su comportamiento.
ESTRUCTURA DEL SCRIPT (de arriba a abajo)
1. PARSEO DE MARC - funciones para leer el texto .mrk y convertirlo
en datos utilizables (Parse-MarcRecord, Get-Subfield...).
2. CARGA DEL CATALOGO - Load-Catalog: lee todos los .mrk y arma la
lista de registros "aplanados" (un objeto por registro con todos
los campos ya limpios y listos para mostrar/buscar).
3. AYUDANTES DE PANTALLA - funciones para imprimir fichas, barras de
progreso y tablas en la consola.
4. CITAS - Build-Citation arma la referencia bibliografica en los 5
estilos soportados.
5. BUSQUEDA - Do-Search (el filtro real) mas el historial de
busquedas recientes.
6. EXPLORAR - navegacion por Dewey, decada, formato, coleccion o
registro aleatorio.
7. ESTADISTICAS Y CALIDAD - reportes agregados del catalogo.
8. EXPORTAR - genera archivos CSV.
9. MENU PRINCIPAL - el bucle que despliega el menu y llama a cada
seccion segun la opcion elegida.
AVISO
Esta copia se publica en el sitio unicamente para lectura y copia
(transparencia del proyecto). No consulta ningun dato personal: la
coleccion "Archivo" (material privado) esta excluida por completo del
catalogo que se publica en la web (ver build_index.py), aunque este
script, ejecutado localmente con acceso a todos los .mrk, si la
incluiria.
============================================================================
#>
param([string]$Folder = $PSScriptRoot)
# ===========================================================================
# MARC PARSING
# ===========================================================================
# Convierte un bloque de lineas de texto .mrk (un solo registro,
# desde su '=LDR' hasta el siguiente) en un objeto con el lider
# (Leader) y un diccionario Fields donde cada llave es la etiqueta
# de 3 digitos (ej. '245') y el valor es un arreglo con el texto
# crudo de cada aparicion de esa etiqueta (un campo puede repetirse,
# como varios 650 de materia).
function Parse-MarcRecord([string[]]$lines) {
if ($lines.Count -eq 0) { return $null }
$leader = ""
$fields = @{}
foreach ($line in $lines) {
if ($line.StartsWith("=LDR ") -and $line.Length -ge 6) {
$leader = $line.Substring(6)
} elseif ($line.StartsWith("=") -and $line.Length -ge 6) {
$tag = $line.Substring(1, 3)
$raw = $line.Substring(6)
if (-not $fields.ContainsKey($tag)) { $fields[$tag] = @() }
$fields[$tag] += $raw
}
}
if ($leader -eq "" -and $fields.Count -eq 0) { return $null }
return [PSCustomObject]@{ Fields = $fields; Leader = $leader }
}
# Separa el texto crudo de un campo (ya sin indicadores) por el
# caracter '$' y regresa una lista de pares (codigo de subcampo,
# valor). Es la base de la que se apoyan Get-Subfield y
# Get-AllSubfields.
function Get-SubfieldList([string]$raw) {
$result = [System.Collections.Generic.List[hashtable]]::new()
$parts = $raw -split '\$'
foreach ($part in $parts) {
if ($part.Length -ge 2) {
$result.Add(@{ Code = $part[0]; Value = $part.Substring(1) })
}
}
return $result
}
# Regresa el valor de UN subcampo especifico (por su codigo de una
# letra, ej. 'a') dentro de un campo. Si no existe, regresa cadena
# vacia.
function Get-Subfield([string]$raw, [char]$code) {
foreach ($sf in (Get-SubfieldList $raw)) {
if ($sf.Code -eq $code) { return $sf.Value.Trim() }
}
return ""
}
# Concatena TODOS los subcampos de un campo en una sola cadena,
# separados por espacio (o el separador que se indique). Excluye
# por defecto los codigos '2', '6' y '8', que en MARC son
# tecnicos/de control y no aportan texto legible (fuente del
# vocabulario, enlace de campo, numero de secuencia).
function Get-AllSubfields([string]$raw, [string]$sep = " ", [char[]]$exclude = @('2','6','8')) {
$parts = foreach ($sf in (Get-SubfieldList $raw)) {
if ($sf.Code -notin $exclude) { $sf.Value }
}
return ($parts -join $sep).Trim()
}
# Quita los dos caracteres de indicadores al inicio de un campo
# crudo (o los deja tal cual si son espacios/backslash), dejando
# solo los subcampos listos para Get-SubfieldList.
function Strip-Indicators([string]$raw) {
return ($raw -replace '^[0-9\\ ]{0,2}', '').Trim()
}
# Traduce las posiciones 6 y 7 del lider MARC (tipo de registro y
# nivel bibliografico) a una etiqueta legible en espanol, por
# ejemplo 'am' -> 'Libro / Monografia', 'g' -> 'Video'. Se usa
# cuando el registro no trae un tipo de soporte (338) explicito.
function Get-FormatFromLeader([string]$leader) {
if ($leader.Length -lt 8) { return "Desconocido" }
$recType = $leader[6]
$bibLevel = $leader[7]
switch ($recType) {
'a' {
switch ($bibLevel) {
'm' { return "Libro / Monografía" }
'b' { return "Paper / Artículo" }
's' { return "Publicación seriada" }
'i' { return "Recurso integrador" }
'c' { return "Colección" }
'd' { return "Subunidad" }
default { return "Texto" }
}
}
'c' { return "Partitura" }
'd' { return "Partitura (mss.)" }
'e' { return "Mapa" }
'f' { return "Mapa (mss.)" }
'g' { return "Video" }
'i' { return "Audio (no musical)" }
'j' { return "Audio (musical)" }
'k' { return "Imagen fija" }
'm' { return "Archivo de computadora" }
'o' { return "Kit" }
'p' { return "Materiales mixtos" }
'r' { return "Objeto tridimensional" }
't' { return "Manuscrito" }
default { return "Desconocido" }
}
}
# Lista de palabras de 'relator' (rol del autor: autor, editor,
# compilador, traductor...) que Clean-Author reconoce y recorta
# del final del nombre, para mostrar solo el nombre limpio en las
# fichas y en las citas.
$script:RelatorTerms = @(
'autor','autora','editor','editora','compilador','compiladora',
'traductor','traductora','prologuista','coordinador','coordinadora',
'author','compiler','translator','illustrator','editor literario'
)
# Limpia un campo de autor (100/110/700/710) para mostrarlo o
# citarlo: se queda solo con el subcampo $a (el nombre), le quita
# el termino de relator si quedo pegado al final, y le quita puntos
# sobrantes salvo que ese punto final sea parte de una inicial
# (ej. 'Garcia, J.' conserva el punto; 'Garcia, Juan.' lo pierde).
function Clean-Author([string]$raw) {
$a = $raw.Trim()
$a = Strip-Indicators $a
$sfList = Get-SubfieldList $a
if ($sfList.Count -gt 0) {
$nameOnly = ($sfList | Where-Object { $_.Code -eq 'a' } | Select-Object -First 1)
if ($null -ne $nameOnly) { $a = $nameOnly.Value }
}
$a = $a -replace '\$e.*$', ''
foreach ($term in $script:RelatorTerms) {
$a = $a -replace (',?\s*' + [regex]::Escape($term) + '\.?\s*$'), ''
}
$a = $a.Trim().TrimEnd(',').Trim()
if ($a.EndsWith('.')) {
$isInitial = $a -match '(?:^|\s)(?:[A-ZÁÉÍÓÚÜÑ]\.)+$' -or
$a -match '(?:^|\s)[A-ZÁÉÍÓÚÜÑ]\.$'
if (-not $isInitial) { $a = $a.TrimEnd('.').Trim() }
}
return $a
}
# Quita puntuacion sobrante al inicio (': ', '/ ', '; ') y al final
# (comas, puntos, dos puntos, punto y coma, diagonales) de un valor,
# dejando el texto listo para mostrarse sin la puntuacion ISBD
# tecnica que trae el registro MARC.
function Clean-Punctuation([string]$s) {
$s = $s.Trim()
$s = $s -replace '^[\s]*[:/;]+[\s]*', ''
while ($s -match '[,.:;/\\]\s*$') {
$s = $s -replace '[,.:;/\\]\s*$', ''
$s = $s.Trim()
}
return $s
}
# Quita acentos/diacriticos y pasa todo a minusculas. Es la base
# de las busquedas (para que 'mexico' encuentre 'Mexico') y del
# ordenamiento alfabetico (para que los acentos no alteren el
# orden de las fichas).
function Strip-Diacritics([string]$s) {
$nd = $s.Normalize([System.Text.NormalizationForm]::FormD)
$sb = [System.Text.StringBuilder]::new()
foreach ($c in $nd.ToCharArray()) {
if ([System.Globalization.CharUnicodeInfo]::GetUnicodeCategory($c) -ne
[System.Globalization.UnicodeCategory]::NonSpacingMark) {
[void]$sb.Append($c)
}
}
return $sb.ToString().Normalize([System.Text.NormalizationForm]::FormC).ToLower()
}
# Convierte una cadena en una clave de ordenamiento "natural": la parte entera
# de cada número se rellena con ceros a la izquierda para que los enteros se
# comparen por valor y no como texto (así 2 va antes que 10, y Dewey 86 antes
# que 100). La parte decimal (después del punto) se deja tal cual, porque como
# fracción se compara correctamente carácter por carácter (.2 va después de
# .0151). Sirve para números de acceso y para signaturas (Dewey y locales).
function ConvertTo-NaturalKey([string]$s) {
if ([string]::IsNullOrEmpty($s)) { return "" }
return [regex]::Replace($s, '\d+(?:\.\d+)?', {
param($m)
$parts = $m.Value.Split('.')
$key = $parts[0].PadLeft(12, '0')
if ($parts.Count -gt 1) { $key += '.' + $parts[1] }
$key
})
}
# Verdadero cuando la entrada estándar está redirigida (tubería o archivo) y ya
# se agotó. Permite que los menús salgan en vez de repetirse infinitamente al
# llegar al fin de la entrada. En uso interactivo normal siempre es falso, de
# modo que presionar Enter en blanco sigue comportándose como antes.
function Test-InputEof($val) {
return ($null -eq $val) -or ([Console]::IsInputRedirected -and [string]::IsNullOrEmpty($val))
}
# ===========================================================================
# CARGA DEL CATÁLOGO
# ===========================================================================
# Dibuja/actualiza una barra de progreso de texto (bloques llenos
# y vacios) mientras se cargan los archivos .mrk, para que la carga
# de un catalogo grande no se sienta 'colgada'.
function Show-Progress([int]$done, [int]$total, [string]$label) {
$width = 36
$filled = if ($total -gt 0) { [int]([math]::Round($done / $total * $width)) } else { 0 }
$bar = [string]::new([char]'█', $filled) + [string]::new([char]'░', $width - $filled)
$pct = if ($total -gt 0) { [int]($done / $total * 100) } else { 0 }
Write-Host "`r [$bar] $pct% $label " -NoNewline -ForegroundColor Cyan
}
# ============================================================
# Carga TODO el catalogo: recorre recursivamente la carpeta en
# busca de archivos .mrk, separa cada archivo en sus registros
# individuales (cada uno empieza con una linea '=LDR'), los
# parsea con Parse-MarcRecord, y arma un objeto 'aplanado' por
# registro con todos los campos ya limpios y listos para buscar
# y mostrar: titulo, autores, materias, clasificacion, ISBN/ISSN/
# DOI, descripcion fisica, notas, serie, datos de revista,
# coleccion, y un campo SearchText (todo el texto relevante del
# registro, sin acentos y en minusculas) que es lo que realmente
# recorre Do-Search cuando se busca 'en cualquier campo'.
# ============================================================
function Load-Catalog([string]$folder) {
$mrkFiles = Get-ChildItem -Path $folder -Filter "*.mrk" -File -Recurse
if ($mrkFiles.Count -eq 0) {
Write-Host " No se encontraron archivos .mrk en: $folder" -ForegroundColor Red
pause; exit 1
}
# Primera pasada: contar registros totales para la barra
$totalRecords = 0
foreach ($file in $mrkFiles) {
$totalRecords += ([System.IO.File]::ReadLines($file.FullName,
[System.Text.Encoding]::UTF8) | Where-Object { $_ -like "=LDR*" }).Count
}
if ($totalRecords -eq 0) { $totalRecords = 1 }
$catalog = [System.Collections.Generic.List[object]]::new()
$processed = 0
foreach ($file in $mrkFiles) {
$lines = [System.IO.File]::ReadLines($file.FullName, [System.Text.Encoding]::UTF8)
$currentRecordLines = @()
$recordGroups = @()
foreach ($line in $lines) {
if ($line.StartsWith("=LDR") -and $currentRecordLines.Count -gt 0) {
$recordGroups += ,$currentRecordLines
$currentRecordLines = @()
}
if (-not [string]::IsNullOrWhiteSpace($line)) { $currentRecordLines += $line }
}
if ($currentRecordLines.Count -gt 0) { $recordGroups += ,$currentRecordLines }
foreach ($group in $recordGroups) {
$rec = Parse-MarcRecord $group
if ($null -eq $rec) { continue }
$f = $rec.Fields
# 001 Acceso
$accession = if ($f.ContainsKey("001")) { $f["001"][0].Trim() } else { "" }
# 245 / 240 / 130 Título
$titleMain = ""; $titleSub = ""; $titleResp = ""
if ($f.ContainsKey("245")) {
$raw245 = Strip-Indicators $f["245"][0]
$titleMain = Clean-Punctuation (Get-Subfield $raw245 'a')
$titleSub = Clean-Punctuation (Get-Subfield $raw245 'b')
$titleResp = Clean-Punctuation (Get-Subfield $raw245 'c')
} elseif ($f.ContainsKey("240")) {
$titleMain = Clean-Punctuation (Get-AllSubfields (Strip-Indicators $f["240"][0]))
} elseif ($f.ContainsKey("130")) {
$titleMain = Clean-Punctuation (Get-AllSubfields (Strip-Indicators $f["130"][0]))
}
# 250 Edición
$edition = ""
if ($f.ContainsKey("250")) {
$edition = Clean-Punctuation (Get-Subfield (Strip-Indicators $f["250"][0]) 'a')
}
# 264 / 260 Publicación
$place = ""; $publisher = ""; $year = 0
foreach ($tag in @("264","260")) {
if ($f.ContainsKey($tag)) {
$raw264 = Strip-Indicators $f[$tag][0]
$place = Clean-Punctuation (Get-Subfield $raw264 'a')
$rawPub = (Get-Subfield $raw264 'b') -replace '^[\s:]+', ''
$publisher = Clean-Punctuation $rawPub
$rawYear = Get-Subfield $raw264 'c'
$m = [regex]::Match($rawYear, '\d{4}')
if ($m.Success) { $year = [int]$m.Value }
break
}
}
# 300 Descripción física
$physExtent = ""; $physDetails = ""; $physDim = ""
if ($f.ContainsKey("300")) {
$raw300 = Strip-Indicators $f["300"][0]
$physExtent = Clean-Punctuation (Get-Subfield $raw300 'a')
$rawB300 = (Get-Subfield $raw300 'b') -replace '^[\s:]+', ''
$physDetails = Clean-Punctuation $rawB300
$rawC300 = (Get-Subfield $raw300 'c') -replace '^[\s;]+', ''
$physDim = Clean-Punctuation $rawC300
}
# 336/337/338 RDA
$rdaContent = ""; $rdaMedia = ""; $rdaCarrier = ""
if ($f.ContainsKey("336")) { $rdaContent = Clean-Punctuation (Get-Subfield (Strip-Indicators $f["336"][0]) 'a') }
if ($f.ContainsKey("337")) { $rdaMedia = Clean-Punctuation (Get-Subfield (Strip-Indicators $f["337"][0]) 'a') }
if ($f.ContainsKey("338")) { $rdaCarrier = Clean-Punctuation (Get-Subfield (Strip-Indicators $f["338"][0]) 'a') }
$format = if ($rdaCarrier.Length -gt 0) { $rdaCarrier } else { Get-FormatFromLeader $rec.Leader }
# 082/084 Clasificación
$callNum = ""; $callNumClass = ""; $callScheme = "None"
foreach ($tag in @("082","084","092","050","090","099")) {
if ($f.ContainsKey($tag)) {
$rawCall = Strip-Indicators $f[$tag][0]
$subA = Get-Subfield $rawCall 'a'
$subB = Get-Subfield $rawCall 'b'
if ($subA.Length -gt 0) {
$callNumClass = $subA
$callNum = if ($subB.Length -gt 0) { "$subA $subB" } else { $subA }
$callScheme = if ($tag -eq "084") { "Local" } elseif ($subA -match '^\d') { "Dewey" } else { "Otro" }
break
}
}
}
# 020/022/024 Identificadores
$isbn = if ($f.ContainsKey("020")) { Clean-Punctuation (Get-Subfield (Strip-Indicators $f["020"][0]) 'a') } else { "" }
$issn = if ($f.ContainsKey("022")) { Clean-Punctuation (Get-Subfield (Strip-Indicators $f["022"][0]) 'a') } else { "" }
$doi = if ($f.ContainsKey("024")) { Clean-Punctuation (Get-Subfield (Strip-Indicators $f["024"][0]) 'a') } else { "" }
# 100/110/111/700/710/711 Autores
$personalAuthors = @(); $corporateAuthors = @()
$addedPersonal = @(); $addedCorporate = @()
if ($f.ContainsKey("100")) { $personalAuthors = @(Clean-Author (Strip-Indicators $f["100"][0])) }
foreach ($tag in @("110","111")) {
if ($f.ContainsKey($tag)) { $corporateAuthors += Clean-Author (Strip-Indicators $f[$tag][0]) }
}
if ($f.ContainsKey("700")) {
$addedPersonal = @($f["700"] | ForEach-Object { Clean-Author (Strip-Indicators $_) })
}
foreach ($tag in @("710","711")) {
if ($f.ContainsKey($tag)) { $addedCorporate += $f[$tag] | ForEach-Object { Clean-Author (Strip-Indicators $_) } }
}
$allAuthors = @($personalAuthors) + @($corporateAuthors) + @($addedPersonal) + @($addedCorporate)
# 650/651/600/610 Materias
$seenSubjects = @{}
$allSubjects = foreach ($tag in @("650","651","600","610")) {
if ($f.ContainsKey($tag)) {
foreach ($rawSubj in $f[$tag]) {
# Unir subcampos con ' -- ' para mostrar México -- Historia -- Siglo XX
$parts = @(Get-SubfieldList (Strip-Indicators $rawSubj) |
Where-Object { $_.Code -notin @('2','6','8') } |
ForEach-Object { Clean-Punctuation $_.Value } |
Where-Object { $_ -ne "" })
$s = $parts -join ' -- '
$n = (Strip-Diacritics $s).ToLower().Trim()
if ($s.Length -gt 0 -and -not $seenSubjects.ContainsKey($n)) {
$seenSubjects[$n] = $true; $s
}
}
}
}
$allSubjects = @($allSubjects)
# 490 Serie
$series = ""
if ($f.ContainsKey("490")) {
$series = Clean-Punctuation (Get-AllSubfields (Strip-Indicators $f["490"][0]))
}
# 500/505/590 Notas
$note500 = @(); $note505 = @(); $note590 = @()
if ($f.ContainsKey("500")) {
$note500 = @($f["500"] | ForEach-Object {
$n = Clean-Punctuation (Get-Subfield (Strip-Indicators $_) 'a')
if ($n.Length -gt 0) { $n }
})
}
if ($f.ContainsKey("505")) {
$note505 = @($f["505"] | ForEach-Object {
$n = Clean-Punctuation (Get-AllSubfields (Strip-Indicators $_))
if ($n.Length -gt 0) { $n }
})
}
if ($f.ContainsKey("590")) {
$note590 = @($f["590"] | ForEach-Object {
$n = Clean-Punctuation (Get-Subfield (Strip-Indicators $_) 'a')
if ($n.Length -eq 0) { $n = Clean-Punctuation (Get-AllSubfields (Strip-Indicators $_)) }
if ($n.Length -gt 0) { $n }
})
}
$allNotes = @($note500) + @($note505) + @($note590)
# 362/773 Serie / artículo
$serialDesignation = ""
if ($f.ContainsKey("362")) {
$serialDesignation = Clean-Punctuation (Get-Subfield (Strip-Indicators $f["362"][0]) 'a')
}
$journalTitle = ""; $journalIssue = ""
if ($f.ContainsKey("773")) {
$raw773 = Strip-Indicators $f["773"][0]
$journalTitle = Clean-Punctuation (Get-Subfield $raw773 't')
$journalIssue = Clean-Punctuation (Get-Subfield $raw773 'g')
}
# 901 Colección
$collection = ""
if ($f.ContainsKey("901")) {
$col = Get-Subfield (Strip-Indicators $f["901"][0]) 'a'
if ($col.Length -eq 0) { $col = Get-AllSubfields (Strip-Indicators $f["901"][0]) }
$collection = $col.Trim()
}
$rawSearchText = "$titleMain $titleSub $([string]::Join(' ',$allAuthors)) $([string]::Join(' ',$allSubjects)) $callNum $publisher $year $([string]::Join(' ',$allNotes)) $collection $isbn $issn $doi $series $journalTitle"
$searchText = Strip-Diacritics $rawSearchText
$catalog.Add([PSCustomObject]@{
TitleMain = $titleMain; TitleSub = $titleSub; TitleResp = $titleResp
Edition = $edition
Place = $place; Publisher = $publisher; Year = $year
PhysExtent = $physExtent; PhysDetails = $physDetails; PhysDim = $physDim
RdaContent = $rdaContent; RdaMedia = $rdaMedia; RdaCarrier = $rdaCarrier
Format = $format
Accession = $accession
ISBN = $isbn; ISSN = $issn; DOI = $doi
CallNum = $callNum; CallNumClass = $callNumClass; CallScheme = $callScheme
PersonalAuthors = $personalAuthors; CorporateAuthors = $corporateAuthors
AddedPersonal = $addedPersonal; AddedCorporate = $addedCorporate
AllAuthors = $allAuthors
Subjects = $allSubjects
Series = $series
Note500 = $note500; Note505 = $note505; Note590 = $note590
Notes = $allNotes
SerialDesignation = $serialDesignation
JournalTitle = $journalTitle; JournalIssue = $journalIssue
Collection = $collection
File = $file.Name
Title = $titleMain
Author = if ($allAuthors.Count -gt 0) { $allAuthors[0] } else { "" }
Subject = if ($allSubjects.Count -gt 0) { $allSubjects[0] } else { "" }
SearchText = $searchText
RawFields = $f
Leader = $rec.Leader
})
$processed++
Show-Progress $processed $totalRecords $file.Name
}
}
Write-Host "" # salto de línea tras la barra
return $catalog.ToArray()
}
# ===========================================================================
# AYUDANTES DE PANTALLA
# ===========================================================================
# Dibuja una barra horizontal de texto (para las estadisticas:
# materias/autores/formatos mas frecuentes), proporcional al
# valor mas alto del grupo que se este mostrando.
function Show-Bar([string]$label, [int]$count, [int]$max, [int]$labelWidth) {
$barMax = 30
$filled = if ($max -gt 0) { [int]([math]::Round($count / $max * $barMax)) } else { 0 }
$bar = [string]::new([char]'█', $filled) + [string]::new([char]'░', $barMax - $filled)
if ($label.Length -gt $labelWidth) { $label = $label.Substring(0, $labelWidth - 3) + "..." }
$padded = $label.PadRight($labelWidth)
Write-Host " $padded [$bar] $count"
}
# Calcula el ancho de columna necesario para alinear una lista de
# etiquetas (usado por Show-Bar), sin pasarse de un maximo dado.
function Get-MaxWidth([object[]]$items, [scriptblock]$selector, [int]$cap) {
$longest = ($items | ForEach-Object { (& $selector $_).Length } | Measure-Object -Maximum).Maximum
return [math]::Min($longest + 2, $cap)
}
# Imprime una fila 'Etiqueta: valor' en la ficha de un registro.
# Si el valor viene vacio, no imprime nada (asi las fichas no se
# llenan de campos en blanco).
function Show-Row {
param(
[string]$Label,
[string]$Value,
[string]$LabelColor = "DarkCyan",
[string]$ValueColor = "White",
[int] $LabelWidth = 15
)
if ([string]::IsNullOrWhiteSpace($Value)) { return }
$padded = ($Label + ":").PadRight($LabelWidth)
Write-Host " $padded " -NoNewline -ForegroundColor $LabelColor
Write-Host $Value -ForegroundColor $ValueColor
}
# Igual que Show-Row, pero para campos que pueden tener VARIOS
# valores (por ejemplo, varios autores o varias materias): imprime
# el primero junto a la etiqueta y el resto alineado debajo.
function Show-MultiRow {
param(
[string] $Label,
[string[]]$Values,
[string] $LabelColor = "DarkCyan",
[string] $ValueColor = "White",
[int] $LabelWidth = 15
)
$nonEmpty = @($Values | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($nonEmpty.Count -eq 0) { return }
$padded = ($Label + ":").PadRight($LabelWidth)
$blank = "".PadRight($LabelWidth + 1)
Write-Host " $padded " -NoNewline -ForegroundColor $LabelColor
Write-Host $nonEmpty[0] -ForegroundColor $ValueColor
for ($i = 1; $i -lt $nonEmpty.Count; $i++) {
Write-Host " $blank " -NoNewline -ForegroundColor $LabelColor
Write-Host $nonEmpty[$i] -ForegroundColor $ValueColor
}
}
# Lineas separadoras reutilizadas por Show-SectionHeader y
# Show-Record/Show-RawFields para dar formato visual a la consola.
$script:Sep = "─" * 56
$script:SepFull = "**********************************************************"
# Imprime un titulo de seccion en cian seguido de una linea
# separadora (identico proposito que en Catalog.ps1).
function Show-SectionHeader([string]$title) {
Write-Host ""
Write-Host " $title" -ForegroundColor Cyan
Write-Host " $($script:Sep)" -ForegroundColor DarkGray
}
# Imprime la FICHA completa de un registro: titulo, autor(es),
# edicion, publicacion, descripcion fisica, clasificacion, numero
# de acceso, ISBN/ISSN/DOI, serie, materias, datos de revista,
# notas, formato, coleccion y archivo de origen. Cada linea se
# omite automaticamente si el dato no existe (via Show-Row/
# Show-MultiRow).
function Show-Record([array]$results, [int]$idx) {
$r = $results[$idx]
$n = $idx + 1
$tot = $results.Count
Write-Host ""
if ($tot -gt 1) { Write-Host " [$n de $tot]" -ForegroundColor DarkGray }
Write-Host " $($script:Sep)" -ForegroundColor Magenta
# Título
if ($r.TitleMain.Length -gt 0) {
$fullTitle = $r.TitleMain
if ($r.TitleSub.Length -gt 0) { $fullTitle += " : " + $r.TitleSub }
if ($r.TitleResp.Length -gt 0) { $fullTitle += " / " + $r.TitleResp }
Show-Row "Título" $fullTitle "DarkCyan" "White"
}
Show-MultiRow "Autor" $r.AllAuthors "DarkCyan" "White"
Show-Row "Edición" $r.Edition "DarkCyan" "White"
if ($r.Place.Length -gt 0 -or $r.Publisher.Length -gt 0 -or $r.Year -gt 0) {
$pub = @()
if ($r.Place.Length -gt 0) { $pub += $r.Place }
if ($r.Publisher.Length -gt 0) { $pub += $r.Publisher }
if ($r.Year -gt 0) { $pub += [string]$r.Year }
Show-Row "Publicación" ($pub -join " : ") "DarkCyan" "White"
}
if ($r.PhysExtent.Length -gt 0 -or $r.PhysDetails.Length -gt 0 -or $r.PhysDim.Length -gt 0) {
$phys = @()
if ($r.PhysExtent.Length -gt 0) { $phys += $r.PhysExtent }
if ($r.PhysDetails.Length -gt 0) { $phys += $r.PhysDetails }
if ($r.PhysDim.Length -gt 0) { $phys += $r.PhysDim }
Show-Row "Desc. física" ($phys -join " : ") "DarkCyan" "White"
}
Show-Row "Clasificación" $r.CallNum "DarkCyan" "White"
Show-Row "Acceso" $r.Accession "DarkCyan" "White"
Show-Row "ISBN" $r.ISBN "DarkGray" "Gray"
Show-Row "ISSN" $r.ISSN "DarkGray" "Gray"
Show-Row "DOI" $r.DOI "DarkGray" "Gray"
Show-Row "Serie" $r.Series "DarkCyan" "Gray"
Show-MultiRow "Materia" $r.Subjects "DarkCyan" "White"
if ($r.JournalTitle.Length -gt 0) {
$jStr = $r.JournalTitle
if ($r.JournalIssue.Length -gt 0) { $jStr += ", " + $r.JournalIssue }
Show-Row "En revista" $jStr "DarkCyan" "White"
}
Show-Row "Designación" $r.SerialDesignation "DarkCyan" "White"
Show-MultiRow "Nota" $r.Note500 "DarkYellow" "White"
Show-MultiRow "Contenido" $r.Note505 "DarkYellow" "White"
Show-MultiRow "Nota de copia" $r.Note590 "DarkYellow" "White"
Show-Row "Formato" $r.Format "DarkGray" "Gray"
Show-Row "Colección" $r.Collection "DarkCyan" "White"
Show-Row "Archivo" $r.File "DarkGray" "DarkGray"
Write-Host " $($script:Sep)" -ForegroundColor Magenta
}
# Imprime el registro MARC 'crudo': cada etiqueta de campo seguida
# de su contenido tal cual esta en el archivo .mrk, ordenadas
# alfabeticamente por etiqueta. Es la opcion 'Ver MARC completo'
# del menu de resultados.
function Show-RawFields($r) {
Write-Host ""
Write-Host " MARC COMPLETO: $($r.TitleMain)" -ForegroundColor Cyan
Write-Host " $($script:SepFull)" -ForegroundColor DarkGray
foreach ($tag in ($r.RawFields.Keys | Sort-Object)) {
foreach ($val in $r.RawFields[$tag]) {
Write-Host " $tag " -NoNewline -ForegroundColor Green
Write-Host $val
}
}
Write-Host " $($script:SepFull)" -ForegroundColor DarkGray
}
# Define las 4 formas en que se pueden ordenar los resultados de
# una busqueda: por Titulo, por Autor (primer autor), por
# Clasificacion o por Numero de acceso. 'Key' es la funcion que
# calcula la clave de ordenamiento de cada registro (usa
# Strip-Diacritics para texto y ConvertTo-NaturalKey para numeros,
# asi Dewey '86' ordena antes que '100').
$script:SortOrders = @{
"T" = @{ Label = "Título"; Key = { param($r) Strip-Diacritics $r.TitleMain } }
"U" = @{ Label = "Autor"; Key = { param($r) Strip-Diacritics $r.Author } }
"C" = @{ Label = "Clasificación"; Key = { param($r) ConvertTo-NaturalKey $r.CallNum } }
"N" = @{ Label = "Número de acceso"; Key = { param($r) ConvertTo-NaturalKey $r.Accession } }
}
# Aplica uno de los 4 ordenes de script:SortOrders a una lista de
# resultados.
function Sort-Results([array]$results, [string]$order) {
if (-not $script:SortOrders.ContainsKey($order)) { return $results }
$key = $script:SortOrders[$order].Key
return @($results | Sort-Object { & $key $_ })
}
# ===========================================================================
# CITAS
# ===========================================================================
# Determina el lugar y la editorial a usar en una cita: si faltan
# en el registro, usa las abreviaturas bibliograficas estandar
# 's. l.' (sine loco, sin lugar) y 's. n.' (sine nomine, sin
# editorial), en vez de dejar el dato vacio en la referencia.
function Get-PlacePub([object]$r) {
# Sine loco / sine nomine cuando falta lugar o editorial
$place = if ($r.Place.Length -gt 0) { $r.Place } else { "s. l." }
$pub = if ($r.Publisher.Length -gt 0) { $r.Publisher } else { "s. n." }
return @{ Place = $place; Publisher = $pub }
}
# ============================================================
# Arma la referencia bibliografica de un registro en el estilo
# solicitado: APA, MLA, Chicago, ISO 690 o BibTeX. Cada 'case' del
# switch sigue las reglas de formato propias de ese estilo (orden
# de autor(es), uso de parentesis para el ano, comillas para el
# titulo en MLA, mayusculas en ISO, llaves de BibTeX, etc). El ano
# se muestra como 's.f.' (sin fecha) cuando el registro no trae
# ano de publicacion.
# ============================================================
function Build-Citation([object]$r, [string]$style) {
$authors = @($r.AllAuthors)
$year = if ($r.Year -gt 0) { [string]$r.Year } else { "s.f." }
$title = $r.TitleMain
if ($r.TitleSub.Length -gt 0) { $title += ": $($r.TitleSub)" }
$pp = Get-PlacePub $r
switch ($style) {
"APA" {
$authStr = if ($authors.Count -eq 0) { "" }
elseif ($authors.Count -eq 1) { $authors[0] }
elseif ($authors.Count -le 7) {
($authors[0..($authors.Count - 2)] -join ", ") + " & " + $authors[-1]
} else {
($authors[0..5] -join ", ") + ", ... " + $authors[-1]
}
$parts = @()
if ($authStr.Length -gt 0) { $parts += "$authStr ($year)." } else { $parts += "($year)." }
$parts += "$title."
if ($r.Edition.Length -gt 0) { $parts += "($($r.Edition))." }
$parts += "$($pp.Place): $($pp.Publisher)."
if ($r.DOI.Length -gt 0) { $parts += "https://doi.org/$($r.DOI)" }
return ($parts -join " ")
}
"MLA" {
$authStr = if ($authors.Count -gt 0) { $authors[0] } else { "" }
$parts = @()
if ($authStr.Length -gt 0) { $parts += "$authStr." }
$parts += "`"$title.`""
$parts += "$($pp.Publisher),"
if ($r.Year -gt 0) { $parts += "$year." } else { $parts += "s.f." }
return ($parts -join " ")
}
"Chicago" {
$authStr = if ($authors.Count -gt 0) { $authors[0] } else { "" }
$parts = @()
if ($authStr.Length -gt 0) { $parts += "$authStr." }
$parts += "$title."
$parts += "$($pp.Place): $($pp.Publisher),"
if ($r.Year -gt 0) { $parts += "$year." } else { $parts += "s.f." }
return ($parts -join " ")
}
"ISO" {
# ISO 690 (formato autor-fecha simplificado)
$authStr = if ($authors.Count -eq 0) { "" }
elseif ($authors.Count -eq 1) { $authors[0].ToUpper() }
else { "$($authors[0].ToUpper()), et al." }
$parts = @()
if ($authStr.Length -gt 0) { $parts += "$authStr." }
$parts += "$title."
if ($r.Edition.Length -gt 0) { $parts += "$($r.Edition)." }
$parts += "$($pp.Place): $($pp.Publisher),"
if ($r.Year -gt 0) { $parts += "$year." } else { $parts += "s.f." }
if ($r.ISBN.Length -gt 0) { $parts += "ISBN $($r.ISBN)." }
if ($r.ISSN.Length -gt 0) { $parts += "ISSN $($r.ISSN)." }
if ($r.DOI.Length -gt 0) { $parts += "https://doi.org/$($r.DOI)" }
return ($parts -join " ")
}
"BibTeX" {
$entryType = if ($r.JournalTitle.Length -gt 0) { "article" } else { "book" }
$firstAuth = if ($authors.Count -gt 0) { $authors[0] } else { "anon" }
$key = (Strip-Diacritics "$firstAuth$year") -replace '[^a-z0-9]', ''
$lines = @("@$entryType{$key,")
if ($authors.Count -gt 0) { $lines += " author = {$($authors -join ' and ')}," }
$lines += " title = {$title},"
if ($r.Year -gt 0) { $lines += " year = {$year}," }
$lines += " publisher = {$($pp.Publisher)},"
$lines += " address = {$($pp.Place)},"
if ($r.JournalTitle.Length -gt 0){ $lines += " journal = {$($r.JournalTitle)}," }
if ($r.ISBN.Length -gt 0) { $lines += " isbn = {$($r.ISBN)}," }
if ($r.DOI.Length -gt 0) { $lines += " doi = {$($r.DOI)}," }
$lines += "}"
return ($lines -join "`n")
}
}
}
# Muestra, una tras otra, las 5 citas (APA/MLA/Chicago/ISO/BibTeX)
# de un registro, generadas al vuelo con Build-Citation.
function Show-CitationMenu([object]$r) {
Show-SectionHeader "CITAR: $($r.TitleMain)"
$styles = @(
@{ Label = "APA"; Key = "APA" }
@{ Label = "MLA"; Key = "MLA" }
@{ Label = "Chicago"; Key = "Chicago" }
@{ Label = "ISO 690"; Key = "ISO" }
@{ Label = "BibTeX"; Key = "BibTeX" }
)
foreach ($s in $styles) {
$citation = Build-Citation $r $s.Key
Write-Host ""
Write-Host " $($s.Label)" -ForegroundColor DarkCyan
Write-Host " $($script:Sep)" -ForegroundColor DarkGray
Write-Host " $citation" -ForegroundColor White
}
Write-Host ""
Read-Host " Presione Enter para volver"
}
# ============================================================
# Pantalla de RESULTADOS: pagina la lista de registros encontrados
# (5 por pagina), permite cambiar el orden (script:SortOrders),
# avanzar/retroceder de pagina, ver el MARC completo de un
# registro (Show-RawFields) o generar sus citas
# (Show-CitationMenu). Es el punto de salida comun de casi todas
# las busquedas y exploraciones del script.
# ============================================================
function Show-Results([array]$results, [bool]$allowRawView = $true, [double]$elapsed = -1) {
if ($null -eq $results) { $results = @() }
if ($results.Count -eq 0) {
Write-Host "`n No se encontraron registros." -ForegroundColor Red
return
}
$pageSize = 5
$page = 0
$sortOrder = "T" # orden por defecto: título
$sorted = @(Sort-Results $results $sortOrder)
$total = $sorted.Count
while ($true) {
$start = $page * $pageSize
$end = [math]::Min($start + $pageSize, $total) - 1
$timeStr = if ($elapsed -ge 0) { " ($([math]::Round($elapsed,2))s)" } else { "" }
$sortLbl = $script:SortOrders[$sortOrder].Label
if ($total -gt 1) {
Write-Host "`n $total resultado(s). Mostrando $($start+1)–$($end+1).$timeStr " -NoNewline -ForegroundColor Green
Write-Host "Orden: $sortLbl" -ForegroundColor DarkGray
} else {
Write-Host "`n 1 resultado.$timeStr" -ForegroundColor Green
}
for ($i = $start; $i -le $end; $i++) { Show-Record ([array]$sorted) $i }
$hasNext = ($end + 1) -lt $total
$hasPrev = $page -gt 0
Write-Host ""
if ($hasNext) { Write-Host " [S] Siguiente" -ForegroundColor Cyan }
if ($hasPrev) { Write-Host " [A] Anterior" -ForegroundColor Cyan }
if ($total -gt 1) {
Write-Host " [O] Ordenar " -NoNewline -ForegroundColor Cyan
Write-Host "(actual: $sortLbl)" -ForegroundColor DarkGray
}
if ($allowRawView) {
if ($total -eq 1) {
Write-Host " [M] Ver MARC completo" -ForegroundColor Cyan
} else {
Write-Host " [M] Ver MARC completo (+ número)" -ForegroundColor Cyan
}
}
if ($total -eq 1) {
Write-Host " [C] Citar" -ForegroundColor Cyan
} else {
Write-Host " [C] Citar (+ número)" -ForegroundColor Cyan
}
Write-Host " [V] Volver" -ForegroundColor Cyan
Write-Host ""
$choice = Read-Host " Opción"
if (Test-InputEof $choice) { return }
switch ($choice.ToUpper()) {
"S" { if ($hasNext) { $page++ } }
"A" { if ($hasPrev) { $page-- } }
"V" { return }
"O" {
Write-Host ""
Write-Host " Ordenar por:" -ForegroundColor Cyan
foreach ($k in @("T","U","C","N")) {
$mark = if ($k -eq $sortOrder) { "●" } else { " " }
Write-Host " [$k] $mark $($script:SortOrders[$k].Label)"
}
Write-Host ""
$pick = (Read-Host " Opción").ToUpper()
if ($script:SortOrders.ContainsKey($pick)) {
$sortOrder = $pick
$sorted = @(Sort-Results $results $sortOrder)
$page = 0
}
}
"M" {
if (-not $allowRawView) { break }
if ($total -eq 1) {
Show-RawFields $sorted[0]
Read-Host " Presione Enter para continuar"
} else {
$idx = Read-Host " Número de registro"
$n = 0
if ([int]::TryParse($idx, [ref]$n) -and $n -ge 1 -and $n -le $total) {
Show-RawFields $sorted[$n - 1]
Read-Host " Presione Enter para continuar"
} else {
Write-Host " Número fuera de rango." -ForegroundColor Red
}
}
}
"C" {
if ($total -eq 1) {
Show-CitationMenu $sorted[0]
} else {
$idx = Read-Host " Número de registro"
$n = 0
if ([int]::TryParse($idx, [ref]$n) -and $n -ge 1 -and $n -le $total) {
Show-CitationMenu $sorted[$n - 1]
} else {
Write-Host " Número fuera de rango." -ForegroundColor Red
}
}
}
}
}
}
# ===========================================================================
# BÚSQUEDA
# ===========================================================================
# EL FILTRO DE BUSQUEDA. Separa el termino buscado en palabras
# (sin acentos), y se queda solo con los registros donde TODAS
# esas palabras aparecen en el campo elegido (titulo, autor,
# materia, clasificacion, editorial, notas, numero de acceso,
# coleccion, ISBN, ISSN, serie, o 'cualquier campo' usando el
# SearchText ya armado por Load-Catalog). No distingue mayusculas
# ni acentos.
function Do-Search([array]$catalog, [string]$field, [string]$term) {
$words = @((Strip-Diacritics $term) -split '\s+' | Where-Object { $_.Length -gt 0 })
return @($catalog | Where-Object {
$r = $_
$h = switch ($field) {
"title" { Strip-Diacritics "$($r.TitleMain) $($r.TitleSub) $($r.TitleResp)" }
"author" { Strip-Diacritics ([string]::Join(" ", $r.AllAuthors)) }
"subject" { Strip-Diacritics ([string]::Join(" ", $r.Subjects)) }
"callnum" { Strip-Diacritics $r.CallNum }
"publisher" { Strip-Diacritics $r.Publisher }
"notes" { Strip-Diacritics ([string]::Join(" ", $r.Notes)) }
"accession" { Strip-Diacritics $r.Accession }
"collection" { Strip-Diacritics $r.Collection }
"isbn" { Strip-Diacritics $r.ISBN }
"issn" { Strip-Diacritics $r.ISSN }
"series" { Strip-Diacritics $r.Series }
default { $r.SearchText }
}
$match = $true
foreach ($w in $words) { if (-not $h.Contains($w)) { $match = $false; break } }
$match
})
}
# Guarda en memoria (no en disco) las ultimas 10 busquedas hechas
# en esta sesion, para poder repetirlas rapido desde el menu
# 'Busquedas recientes'.
$script:SearchHistory = @()
# Agrega una busqueda al historial (al principio de la lista), y
# recorta el historial a un maximo de 10 entradas.
function Add-SearchHistory([hashtable]$entry) {
$script:SearchHistory = @($entry) + ($script:SearchHistory | Select-Object -First 9)
}
# Muestra el historial de busquedas recientes con el numero de
# resultados que tendria cada una AHORA MISMO (se recalcula al
# vuelo, por si el catalogo cambio desde que se hizo la busqueda
# original), y permite repetir cualquiera de ellas.
function Show-SearchHistory([array]$catalog) {
if ($script:SearchHistory.Count -eq 0) {
Write-Host "`n Sin búsquedas recientes." -ForegroundColor Yellow
return
}
Show-SectionHeader "BÚSQUEDAS RECIENTES"
for ($i = 0; $i -lt $script:SearchHistory.Count; $i++) {
$h = $script:SearchHistory[$i]
$count = if ($h.Type -eq "yearrange") {
@($catalog | Where-Object { $_.Year -ge $h.From -and $_.Year -le $h.To }).Count
} else {
(Do-Search $catalog $h.Field $h.Term).Count
}
Write-Host " $($i+1) $($h.Label) " -NoNewline
Write-Host "($count resultado(s))" -ForegroundColor Green
}
Write-Host ""
$pick = Read-Host " Número para repetir, Enter para volver"
$n = 0
if ([int]::TryParse($pick,[ref]$n) -and $n -ge 1 -and $n -le $script:SearchHistory.Count) {
$h = $script:SearchHistory[$n - 1]
$results = if ($h.Type -eq "yearrange") {
@($catalog | Where-Object { $_.Year -ge $h.From -and $_.Year -le $h.To })
} else {
Do-Search $catalog $h.Field $h.Term
}
Show-Results ([array]$results)
}
}
# ============================================================
# MENU DE BUSQUEDA: ofrece las 14 formas de buscar (titulo,
# autor, materia, clasificacion, editorial, rango de anos,
# cualquier campo, busquedas recientes, notas, numero de acceso,
# coleccion, ISBN, ISSN, serie), pide el termino, ejecuta Do-Search
# (o el filtro de rango de anos / numero de acceso exacto, que son
# casos especiales), y muestra los resultados con Show-Results.
# ============================================================
function Search-Menu([array]$catalog) {
Show-SectionHeader "BUSCAR POR"
Write-Host " 1 Título"
Write-Host " 2 Autor"
Write-Host " 3 Materia"
Write-Host " 4 Clasificación"
Write-Host " 5 Editorial"
Write-Host " 6 Rango de años"
Write-Host " 7 Cualquier campo"
Write-Host " 8 Búsquedas recientes"
Write-Host " 9 Notas"
Write-Host " A Número de acceso"
Write-Host " B Colección"
Write-Host " C ISBN"
Write-Host " D ISSN"
Write-Host " E Serie"
Write-Host " 0 Volver"
Write-Host ""
$choice = Read-Host " Opción"
if ($choice -eq "0") { return }
if ($choice -eq "8") { Show-SearchHistory $catalog; return }
if ($choice.ToUpper() -eq "A") {
$term = Read-Host " Número de acceso"
if ([string]::IsNullOrWhiteSpace($term)) { Write-Host " Campo vacío." -ForegroundColor Red; return }
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$results = @($catalog | Where-Object { $_.Accession.Trim() -eq $term.Trim() })
$sw.Stop()
Add-SearchHistory @{ Type="term"; Field="accession"; Term=$term; Label="Acceso: $term" }
Show-Results ([array]$results) $true $sw.Elapsed.TotalSeconds
return
}
if ($choice -eq "6") {
$from = Read-Host " Desde año"
$to = Read-Host " Hasta año"
$f = 0; $t = 0
if ([int]::TryParse($from,[ref]$f) -and [int]::TryParse($to,[ref]$t)) {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$results = @($catalog | Where-Object { $_.Year -ge $f -and $_.Year -le $t })
$sw.Stop()
Add-SearchHistory @{ Type="yearrange"; From=$f; To=$t; Label="Años $f–$t" }
Show-Results ([array]$results) $true $sw.Elapsed.TotalSeconds
} else {
Write-Host " Rango de años inválido." -ForegroundColor Red
}
return
}
$fieldMap = @{
"1"="title"; "2"="author"; "3"="subject"; "4"="callnum"
"5"="publisher"; "7"="any"; "9"="notes"; "B"="collection"
"C"="isbn"; "D"="issn"; "E"="series"
}
$key = $choice.ToUpper()
if (-not $fieldMap.ContainsKey($key)) { return }
$term = Read-Host " Término de búsqueda"
if ([string]::IsNullOrWhiteSpace($term)) { Write-Host " Campo vacío." -ForegroundColor Red; return }
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$results = Do-Search $catalog $fieldMap[$key] $term
$sw.Stop()
Add-SearchHistory @{ Type="term"; Field=$fieldMap[$key]; Term=$term; Label=$term }
Show-Results ([array]$results) $true $sw.Elapsed.TotalSeconds
}
# ===========================================================================
# EXPLORAR
# ===========================================================================
# ============================================================
# MENU DE EXPLORACION (sin necesidad de escribir un termino de
# busqueda): ver todos los registros, navegar por rango Dewey
# (000-900), por decada de publicacion, por formato, por
# coleccion, o pedir un registro al azar.
# ============================================================
function Browse-Menu([array]$catalog) {
Show-SectionHeader "EXPLORAR POR"
Write-Host " 1 Todos los registros"
Write-Host " 2 Rango Dewey"
Write-Host " 3 Década"
Write-Host " 4 Formato"
Write-Host " 5 Registro aleatorio"
Write-Host " 6 Colección"
Write-Host " 0 Volver"
Write-Host ""
$choice = Read-Host " Opción"
switch ($choice) {
"1" { Show-Results ([array]$catalog) }
"2" {
Show-SectionHeader "RANGOS DEWEY"
$deweyMap = @{
"0"="000s Generalidades"; "1"="100s Filosofía"; "2"="200s Religión"
"3"="300s Cs. Sociales"; "4"="400s Lengua"; "5"="500s Cs. Naturales"
"6"="600s Cs. Aplicadas"; "7"="700s Arte"; "8"="800s Literatura"
"9"="900s Historia"
}
foreach ($k in ($deweyMap.Keys | Sort-Object)) { Write-Host " $k $($deweyMap[$k])" }
Write-Host ""
$d = Read-Host " Centena (0–9)"
if ($d -notmatch '^\d$') { Write-Host " Opción inválida." -ForegroundColor Red; return }
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$results = @($catalog | Where-Object { $_.CallScheme -eq "Dewey" -and $_.CallNumClass.StartsWith($d) }) | Sort-Object { ConvertTo-NaturalKey $_.CallNumClass }
$sw.Stop()
Show-Results ([array]$results) $true $sw.Elapsed.TotalSeconds
}
"3" {
$decades = @{}
foreach ($r in $catalog) {
if ($r.Year -gt 1000 -and $r.Year -le 2100) {
$dec = [math]::Floor($r.Year / 10) * 10
$decades[$dec] = ($decades[$dec] + 1)
}
}
Show-SectionHeader "DÉCADAS"
foreach ($d in ($decades.Keys | Sort-Object)) {
Write-Host " $d " -NoNewline
Write-Host "($($decades[$d]) registros)" -ForegroundColor Green
}
Write-Host ""
$pick = Read-Host " Década (ej. 1990)"
$dec = 0
if ([int]::TryParse($pick,[ref]$dec)) {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$results = @($catalog | Where-Object { $_.Year -ge $dec -and $_.Year -lt ($dec + 10) }) | Sort-Object Year
$sw.Stop()
Show-Results ([array]$results) $true $sw.Elapsed.TotalSeconds
}
}
"4" {
$formats = @{}
foreach ($r in $catalog) {
$fmt = if ($r.Format) { $r.Format } else { "Desconocido" }
$formats[$fmt] = ($formats[$fmt] + 1)
}
Show-SectionHeader "FORMATOS"
$fk = @($formats.Keys | Sort-Object)
for ($i = 0; $i -lt $fk.Count; $i++) {
Write-Host " $($i+1) $($fk[$i]) " -NoNewline
Write-Host "($($formats[$fk[$i]]) registros)" -ForegroundColor Green
}
Write-Host ""
$pick = Read-Host " Opción"
$n = 0
if ([int]::TryParse($pick,[ref]$n) -and $n -ge 1 -and $n -le $fk.Count) {
$fmt = $fk[$n - 1]
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$results = @($catalog | Where-Object { $_.Format -eq $fmt -or ($null -eq $_.Format -and $fmt -eq "Desconocido") }) | Sort-Object TitleMain
$sw.Stop()
Show-Results ([array]$results) $true $sw.Elapsed.TotalSeconds
}
}
"5" {
if ($catalog.Count -gt 0) { Show-Results @(Get-Random -InputObject $catalog) }
}
"6" {
$cols = @{}
foreach ($r in $catalog) {
$c = if ($r.Collection) { $r.Collection } else { "Sin asignar" }
$cols[$c] = ($cols[$c] + 1)
}
Show-SectionHeader "COLECCIONES"
$ck = @($cols.Keys | Sort-Object)
for ($i = 0; $i -lt $ck.Count; $i++) {
Write-Host " $($i+1) $($ck[$i]) " -NoNewline
Write-Host "($($cols[$ck[$i]]) registros)" -ForegroundColor Green
}
Write-Host ""
$pick = Read-Host " Opción"
$n = 0
if ([int]::TryParse($pick,[ref]$n) -and $n -ge 1 -and $n -le $ck.Count) {
$c = $ck[$n - 1]
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$results = @($catalog | Where-Object { $_.Collection -eq $c -or ($null -eq $_.Collection -and $c -eq "Sin asignar") }) | Sort-Object TitleMain
$sw.Stop()
Show-Results ([array]$results) $true $sw.Elapsed.TotalSeconds
}
}
}
}
# ===========================================================================
# ESTADÍSTICAS Y CALIDAD
# ===========================================================================
# ============================================================
# ESTADISTICAS del catalogo completo: total de registros, rango
# de anos cubierto, las 5 materias y los 5 autores personales mas
# frecuentes, distribucion por centena Dewey, y distribucion por
# formato. Todo se muestra con barras horizontales de texto
# (Show-Bar).
# ============================================================
function Show-Stats([array]$catalog) {
if ($catalog.Count -eq 0) { Write-Host " Catálogo vacío." -ForegroundColor Red; return }
Show-SectionHeader "ESTADÍSTICAS DEL CATÁLOGO"
Write-Host " Total de registros : " -NoNewline; Write-Host $catalog.Count -ForegroundColor Green
$years = @($catalog | Where-Object { $_.Year -gt 1000 -and $_.Year -le 2100 } | Select-Object -ExpandProperty Year)
if ($years.Count -gt 0) {
$minY = ($years | Measure-Object -Minimum).Minimum
$maxY = ($years | Measure-Object -Maximum).Maximum
Write-Host " Rango de años : " -NoNewline; Write-Host "$minY – $maxY" -ForegroundColor Green
}
$topSubj = @($catalog | Select-Object -ExpandProperty Subjects | Group-Object | Sort-Object Count -Descending | Select-Object -First 5)
if ($topSubj.Count -gt 0) {
Write-Host "`n Materias principales:" -ForegroundColor Cyan
$maxC = $topSubj[0].Count
$maxL = Get-MaxWidth $topSubj { $_.Name } 42
foreach ($s in $topSubj) { Show-Bar $s.Name $s.Count $maxC $maxL }
}
$topAuth = @($catalog | Select-Object -ExpandProperty PersonalAuthors | Group-Object | Sort-Object Count -Descending | Select-Object -First 5)
if ($topAuth.Count -gt 0) {
Write-Host "`n Autores principales:" -ForegroundColor Cyan
$maxC = $topAuth[0].Count
$maxL = Get-MaxWidth $topAuth { $_.Name } 42
foreach ($a in $topAuth) { Show-Bar $a.Name $a.Count $maxC $maxL }
}
$deweyGroups = @($catalog | Where-Object { $_.CallScheme -eq "Dewey" } | ForEach-Object {
if ($_.CallNumClass.Length -gt 0) { $_.CallNumClass.Substring(0,1) + "00s" }
} | Group-Object | Sort-Object Name)
if ($deweyGroups.Count -gt 0) {
Write-Host "`n Distribución Dewey:" -ForegroundColor Cyan
$maxC = ($deweyGroups | Measure-Object -Property Count -Maximum).Maximum
foreach ($dg in $deweyGroups) { Show-Bar $dg.Name $dg.Count $maxC 10 }
}
$fmtGroups = @($catalog | Group-Object Format | Sort-Object Count -Descending | Select-Object -First 6)
if ($fmtGroups.Count -gt 0) {
Write-Host "`n Por formato:" -ForegroundColor Cyan
$maxC = $fmtGroups[0].Count
$maxL = Get-MaxWidth $fmtGroups { $_.Name } 30
foreach ($fg in $fmtGroups) { Show-Bar $fg.Name $fg.Count $maxC $maxL }
}
Read-Host "`n Presione Enter para volver"
}
# ============================================================
# REPORTE DE CALIDAD DE DATOS: cuenta y permite revisar los
# registros a los que les falta clasificacion, materias, ano de
# publicacion, autor, o (si son libros) ISBN. Sirve para detectar
# registros incompletos que conviene corregir con Catalog.ps1.
# ============================================================
function Show-Quality([array]$catalog) {
if ($catalog.Count -eq 0) { return }
$missingCallNum = @($catalog | Where-Object { [string]::IsNullOrWhiteSpace($_.CallNum) })
$missingSubj = @($catalog | Where-Object { $null -eq $_.Subjects -or $_.Subjects.Count -eq 0 })
$missingYear = @($catalog | Where-Object { $_.Year -le 0 })
$missingAuthor = @($catalog | Where-Object { $_.AllAuthors.Count -eq 0 })
$missingISBN = @($catalog | Where-Object {
($_.Format -match "Libro|Monografía") -and [string]::IsNullOrWhiteSpace($_.ISBN)
})
Show-SectionHeader "CALIDAD DE DATOS"
$w = 24
Write-Host " 1 $("Sin clasificación".PadRight($w))" -NoNewline
Write-Host $missingCallNum.Count -ForegroundColor $(if($missingCallNum.Count -gt 0){"Yellow"}else{"Green"})
Write-Host " 2 $("Sin materias".PadRight($w))" -NoNewline
Write-Host $missingSubj.Count -ForegroundColor $(if($missingSubj.Count -gt 0){"Yellow"}else{"Green"})
Write-Host " 3 $("Sin año".PadRight($w))" -NoNewline
Write-Host $missingYear.Count -ForegroundColor $(if($missingYear.Count -gt 0){"Yellow"}else{"Green"})
Write-Host " 4 $("Sin autor".PadRight($w))" -NoNewline
Write-Host $missingAuthor.Count -ForegroundColor $(if($missingAuthor.Count -gt 0){"Yellow"}else{"Green"})
Write-Host " 5 $("Libros sin ISBN".PadRight($w))" -NoNewline
Write-Host $missingISBN.Count -ForegroundColor $(if($missingISBN.Count -gt 0){"Yellow"}else{"Green"})
Write-Host " 0 Volver"
Write-Host ""
$choice = Read-Host " Opción"
switch ($choice) {
"1" { Show-Results ([array]$missingCallNum) }
"2" { Show-Results ([array]$missingSubj) }
"3" { Show-Results ([array]$missingYear) }
"4" { Show-Results ([array]$missingAuthor) }
"5" { Show-Results ([array]$missingISBN) }
}
}
# ===========================================================================
# EXPORTAR
# ===========================================================================
# Vuelca una lista de registros a un archivo CSV (codificado en
# UTF-8 con BOM, para que Excel lo abra bien con acentos), con una
# columna por cada dato relevante del registro. El nombre del
# archivo incluye la fecha y hora para no sobreescribir
# exportaciones anteriores.
function Export-Records([array]$records, [string]$suffix) {
$outPath = Join-Path $Folder "CatalogExport_${suffix}_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
$flat = foreach ($r in $records) {
[PSCustomObject]@{
Acceso = $r.Accession
Clasificacion = $r.CallNum
Esquema = $r.CallScheme
Autor = $r.Author
Titulo = $r.TitleMain
Subtitulo = $r.TitleSub
Responsabilidad = $r.TitleResp
Edicion = $r.Edition
Lugar = $r.Place
Editorial = $r.Publisher
Anio = if ($r.Year -gt 0) { $r.Year } else { "" }
DescFisica = (@($r.PhysExtent, $r.PhysDetails, $r.PhysDim) | Where-Object { $_ }) -join " : "
Formato = $r.Format
ISBN = $r.ISBN
ISSN = $r.ISSN
DOI = $r.DOI
Serie = $r.Series
Revista = $r.JournalTitle
Fasciculo = $r.JournalIssue
Designacion = $r.SerialDesignation
Coleccion = $r.Collection
Materias = [string]::Join(" ; ", $r.Subjects)
Notas = [string]::Join(" ; ", $r.Notes)
Autores = [string]::Join(" ; ", $r.AllAuthors)
Archivo = $r.File
}
}
$csv = @($flat) | ConvertTo-Csv -NoTypeInformation
[System.IO.File]::WriteAllLines($outPath, $csv, [System.Text.UTF8Encoding]::new($true))
Write-Host " $($records.Count) registro(s) guardado(s) en:" -ForegroundColor Green
Write-Host " $outPath" -ForegroundColor DarkGray
}
# ============================================================
# MENU DE EXPORTACION A CSV: exportar el catalogo completo, o
# elegir una coleccion especifica (o todas, en archivos
# separados) para exportar solo esa parte del catalogo.
# ============================================================
function Export-Menu([array]$catalog) {
Show-SectionHeader "EXPORTAR A CSV"
Write-Host " 1 Catálogo completo " -NoNewline
Write-Host "($($catalog.Count) registros)" -ForegroundColor Green
Write-Host " 2 Por colección"
Write-Host " 0 Volver"
Write-Host ""
$choice = Read-Host " Opción"
switch ($choice) {
"1" {
Write-Host "`n Exportando catálogo completo..." -ForegroundColor Cyan
Export-Records $catalog "completo"
Read-Host "`n Presione Enter para volver"
}
"2" {
$cols = @{}
foreach ($r in $catalog) {
$c = if ($r.Collection) { $r.Collection } else { "Sin asignar" }
$cols[$c] = ($cols[$c] + 1)
}
if ($cols.Count -eq 0) {
Write-Host "`n No hay colecciones definidas." -ForegroundColor Red
Read-Host " Presione Enter para volver"
return
}
Show-SectionHeader "EXPORTAR POR COLECCIÓN"
$ck = @($cols.Keys | Sort-Object)
for ($i = 0; $i -lt $ck.Count; $i++) {
Write-Host " $($i+1) $($ck[$i]) " -NoNewline
Write-Host "($($cols[$ck[$i]]) registros)" -ForegroundColor Green
}
Write-Host " T Todas en archivos separados"
Write-Host " 0 Volver"
Write-Host ""
$pick = Read-Host " Opción"
if ($pick.ToUpper() -eq "T") {
Write-Host ""
foreach ($c in $ck) {
$subset = @($catalog | Where-Object {
($_.Collection -eq $c) -or ([string]::IsNullOrWhiteSpace($_.Collection) -and $c -eq "Sin asignar")
})
$safeName = $c -replace '[\\/:*?"<>|]', '_'
Write-Host " Exportando '$c'..." -ForegroundColor Cyan
Export-Records $subset $safeName
}
Read-Host "`n Presione Enter para volver"
} else {
$n = 0
if ([int]::TryParse($pick, [ref]$n) -and $n -ge 1 -and $n -le $ck.Count) {
$c = $ck[$n - 1]
$subset = @($catalog | Where-Object {
($_.Collection -eq $c) -or ([string]::IsNullOrWhiteSpace($_.Collection) -and $c -eq "Sin asignar")
})
$safeName = $c -replace '[\\/:*?"<>|]', '_'
Write-Host "`n Exportando '$c'..." -ForegroundColor Cyan
Export-Records $subset $safeName
Read-Host "`n Presione Enter para volver"
}
}
}
}
}
# ===========================================================================
# MENÚ PRINCIPAL
# ===========================================================================
# Envoltura de Load-Catalog que ademas mide y reporta cuanto
# tardo la carga y cuantos registros se cargaron. Se usa tanto al
# arrancar el script como en la opcion 'Recargar catalogo' del
# menu principal.
function Load-And-Report([string]$folder) {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$catalog = Load-Catalog $folder
$sw.Stop()
Write-Host " $($catalog.Count) registro(s) cargado(s) en $([math]::Round($sw.Elapsed.TotalSeconds,2))s." -ForegroundColor Green
return $catalog
}
# ============================================================
# PROGRAMA PRINCIPAL
# ============================================================
# Carga el catalogo una vez al arrancar, y entra al bucle del
# menu principal (Buscar / Explorar / Estadisticas / Calidad de
# datos / Exportar a CSV / Recargar catalogo / Salir), que se
# repite hasta que el usuario elige 'Q' o cierra la entrada
# (Test-InputEof).
Clear-Host
Write-Host ""
Write-Host " CATÁLOGO MARC — BAC" -ForegroundColor Cyan
Write-Host " Biblioteca Alonso Cossío" -ForegroundColor DarkCyan
Write-Host " $($script:Sep)" -ForegroundColor DarkGray
Write-Host " Carpeta: $Folder" -ForegroundColor DarkGray
Write-Host ""
$catalog = Load-And-Report $Folder
while ($true) {
Write-Host ""
Write-Host " $($script:Sep)" -ForegroundColor DarkGray
Write-Host " MENÚ PRINCIPAL" -ForegroundColor Cyan
Write-Host " $($script:Sep)" -ForegroundColor DarkGray
Write-Host " 1 Buscar"
Write-Host " 2 Explorar"
Write-Host " 3 Estadísticas"
Write-Host " 4 Calidad de datos"
Write-Host " 5 Exportar a CSV"
Write-Host " 6 Recargar catálogo"
Write-Host " Q Salir"
Write-Host ""
$choice = Read-Host " Opción"
if (Test-InputEof $choice) { Write-Host "`n Hasta luego.`n" -ForegroundColor Cyan; exit 0 }
switch ($choice.ToUpper()) {
"1" { Search-Menu ([array]$catalog) }
"2" { Browse-Menu ([array]$catalog) }
"3" { Show-Stats ([array]$catalog) }
"4" { Show-Quality ([array]$catalog) }
"5" { Export-Menu ([array]$catalog) }
"6" {
Write-Host ""
$catalog = Load-And-Report $Folder
}
"Q" { Write-Host "`n Hasta luego.`n" -ForegroundColor Cyan; exit 0 }
}
}
MIT License Copyright (c) 2026 Alonso Cossío Vázquez Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.