Scripts


Herramientas en PowerShell para el catálogo de la BAC.

Catalog.ps1

Generador interactivo de registros MARC 21 (.mrk). Publicado para lectura y copia.

<#
============================================================================
  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

Buscador interactivo del catálogo: búsqueda, orden, citas y exportación a CSV. Publicado para lectura y copia.

<#
============================================================================
  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 }
    }
}

Licencia

Ambos scripts se publican bajo la Licencia MIT. Puede usarlos, copiarlos, modificarlos y distribuirlos libremente, conservando el aviso de copyright.

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.

← volver al índice