Catalog Search Application
Academic Catalog Search Application Manual
This manual outlines the setup and operation of the enhanced Academic Catalog Search R Shiny application.
Part 1: Technical Implementation
1. Prerequisites & Setup
The application requires R (v4.0+) and the following R packages for core functionality:
Place all UI and server logic into a single file named app.R within its project directory.
2. API Endpoints & Deployment
Ensure these SRU endpoints are correctly configured in your app.R code:
- Library of Congress (LoC): https://lcweb2.loc.gov/zhf/srw/ 
- German National Library (DNB): https://services.dnb.de/sru/dnb 
- WorldCat/OCLC: Requires institutional access/key. 
- British Library (BL): Endpoint must be determined and configured by the deployer. 
The application can be deployed locally via RStudio's "Run App" or on a server (Shiny Server, RStudio Connect, or ShinyApps.io). Security Note: The server's firewall must permit outbound HTTPS traffic to all configured catalog endpoints.
Part 2: User Guide
The interface is divided into Configuration (left panel) and Search Results (main panel).
1. Search Configuration & Execution
Use the left panel to define search parameters, then click "Q Search Catalog".
Batch searches show a Progress Indicator.
2. Interpreting & Exporting Results
Results are displayed in cards with a clear status:
- ✓ Found (Green): Record successfully retrieved. 
- ! Not Found (Yellow): ISBN is valid, but no record was found in the selected catalog. 
- X Error (Red): Search failed (e.g., API timeout or invalid ISBN structure). 
Successful cards display key metadata: ISBN Type Badge, Title, Author, Publisher, Edition, Physical Description, LCCN, and Subjects.
Use the buttons at the bottom of the Configuration panel to export data: Export as JSON (full data) or Export as CSV (key fields). Files are automatically timestamped.
3. Working Examples
Test the application with these verified ISBNs: 0262033844 (Found), 9780743273565 (Found), and 9780262033845 (Not Found/Invalid Checksum).
install.packages(c("shiny", "bslib", "httr", "xml2", "dplyr", "jsonlite"))
# app.R
# Load necessary libraries
library(shiny)
library(bslib)
library(httr)
library(xml2)
library(jsonlite)
library(dplyr)
# --- Configuration & Design System ---
scholarly_blue <- "hsl(215, 70%, 35%)"
book_gold <- "hsl(35, 85%, 55%)"
# Working SRU Server Configuration
servers <- list(
  "Library of Congress (LOC)" = list(
    url = "http://lx2.loc.gov:210/LCDB",
    protocol = "sru"
  )
)
# MARC21 namespace
MARC_NS <- c(
  m = "http://www.loc.gov/MARC21/slim",
  srw = "http://www.loc.gov/zing/srw/",
  diag = "http://www.loc.gov/zing/srw/diagnostic/"
)
# --- Core Function: SRU Fetching and MARCXML Parsing ---
fetch_and_parse_marcxml <- function(isbn, server_config) {
  clean_isbn <- gsub("[^0-9X]", "", toupper(isbn))
  base_url <- server_config$url
  
  # Build SRU query - use proper Bib-1 attribute for ISBN (attribute 7)
  query_params <- list(
    version = "1.1",
    operation = "searchRetrieve",
    query = paste0('bath.isbn="', clean_isbn, '"'),
    recordSchema = "marcxml",
    maximumRecords = "1"
  )
  
  tryCatch({
    response <- httr::GET(
      base_url,
      query = query_params,
      timeout(30)
    )
    
    if (httr::status_code(response) != 200) {
      return(list(error = paste("HTTP Error:", httr::status_code(response))))
    }
    
    xml_content <- xml2::read_xml(httr::content(response, "raw"))
    
    # Check for SRU diagnostics (errors)
    diagnostic <- xml2::xml_find_first(xml_content, "//diag:diagnostic", ns = MARC_NS)
    if (length(diagnostic) > 0) {
      diag_msg <- xml2::xml_text(xml2::xml_find_first(diagnostic, ".//diag:message", ns = MARC_NS))
      return(list(error = paste("SRU Diagnostic:", diag_msg)))
    }
    
    # Check number of records
    num_records <- xml2::xml_find_first(xml_content, "//srw:numberOfRecords", ns = MARC_NS)
    if (length(num_records) > 0) {
      count <- as.integer(xml2::xml_text(num_records))
      if (count == 0) {
        return(NULL) # No records found
      }
    }
    
    # Find MARC record
    record_node <- xml2::xml_find_first(xml_content, "//m:record", ns = MARC_NS)
    
    if (length(record_node) == 0) {
      return(NULL)
    }
    
    return(parse_marc_record(record_node, clean_isbn))
    
  }, error = function(e) {
    return(list(error = paste("Request failed:", e$message)))
  })
}
# --- Helper function to parse MARC record ---
parse_marc_record <- function(record_node, isbn) {
  extract_marc_field <- function(tag, code, multiple = FALSE) {
    xpath <- paste0(".//m:datafield[@tag='", tag, "']/m:subfield[@code='", code, "']")
    nodes <- xml2::xml_find_all(record_node, xpath, ns = MARC_NS)
    
    if (length(nodes) == 0) {
      return(if (multiple) character(0) else NA_character_)
    }
    
    results <- sapply(nodes, function(n) {
      text <- xml2::xml_text(n)
      gsub("[\\.,;:/]$", "", trimws(text))
    })
    
    if (multiple) {
      return(results)
    } else {
      return(results[1])
    }
  }
  
  # Extract control field (like 008 for date)
  extract_control_field <- function(tag) {
    xpath <- paste0(".//m:controlfield[@tag='", tag, "']")
    node <- xml2::xml_find_first(record_node, xpath, ns = MARC_NS)
    if (length(node) > 0) {
      return(xml2::xml_text(node))
    }
    return(NA_character_)
  }
  
  # Title (245 $a and $b)
  title <- extract_marc_field('245', 'a')
  subtitle <- extract_marc_field('245', 'b')
  if (!is.na(subtitle) && nchar(subtitle) > 0) {
    title <- paste(title, subtitle)
  }
  
  # Author (100, 110, or 111)
  author <- extract_marc_field('100', 'a')
  if (is.na(author)) author <- extract_marc_field('110', 'a')
  if (is.na(author)) author <- extract_marc_field('111', 'a')
  
  # Publisher and Year (260 or 264)
  publisher <- extract_marc_field('260', 'b')
  year <- extract_marc_field('260', 'c')
  
  if (is.na(publisher) || nchar(publisher) == 0) {
    publisher <- extract_marc_field('264', 'b')
  }
  if (is.na(year) || nchar(year) == 0) {
    year <- extract_marc_field('264', 'c')
  }
  
  # Clean year - extract 4-digit year
  if (!is.na(year)) {
    year_match <- regmatches(year, regexpr("\\d{4}", year))
    if (length(year_match) > 0) {
      year <- year_match[1]
    }
  }
  
  # Subjects (650 $a)
  subjects <- extract_marc_field('650', 'a', multiple = TRUE)
  
  # Additional fields
  edition <- extract_marc_field('250', 'a')
  physical_desc <- extract_marc_field('300', 'a')
  lccn <- extract_marc_field('010', 'a')
  
  # Get publication date from 008 field if year not found
  if (is.na(year) || nchar(year) == 0) {
    field_008 <- extract_control_field('008')
    if (!is.na(field_008) && nchar(field_008) >= 11) {
      year <- substr(field_008, 8, 11)
    }
  }
  
  return(list(
    Title = if (!is.na(title) && nchar(title) > 0) title else "Unknown",
    Author = if (!is.na(author) && nchar(author) > 0) author else "Unknown",
    Publisher = if (!is.na(publisher) && nchar(publisher) > 0) publisher else "Unknown",
    Year = if (!is.na(year) && nchar(year) > 0) year else "Unknown",
    Edition = edition,
    PhysicalDescription = physical_desc,
    LCCN = lccn,
    Subjects = subjects,
    ISBN = isbn,
    SearchStatus = "Found"
  ))
}
# --- UI Definition ---
ui <- page_navbar(
  theme = bs_theme(
    version = 5,
    bootswatch = "cerulean",
    primary = scholarly_blue,
    secondary = book_gold,
    base_font = font_google("Merriweather"),
    heading_font = font_google("Lato")
  ),
  
  tags$head(
    tags$style(HTML("
      :root {
        --card-transition: all 0.3s ease-out;
        --card-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
      }
      .card {
        transition: var(--card-transition);
        box-shadow: var(--card-shadow);
        margin-bottom: 20px;
      }
      .card:hover {
        transform: translateY(-3px);
        box-shadow: 0 8px 18px rgba(0, 0, 0, 0.2);
      }
      .bslib-page-navbar {
        background-color: #f8f9fa;
      }
      .not-found-card {
        background-color: #fff3cd;
        border-left: 5px solid #ffc107;
      }
      .error-card {
        background-color: #f8d7da;
        border-left: 5px solid #dc3545;
      }
      .debug-info {
        background-color: #f0f0f0;
        padding: 10px;
        margin-top: 10px;
        font-family: monospace;
        font-size: 0.85em;
        border-radius: 4px;
      }
    "))
  ),
  
  title = "📚 Academic Catalog Search",
  
  sidebar = sidebar(
    title = "ISBN Search & Configuration",
    
    radioButtons(
      "search_mode",
      "Search Mode:",
      choices = c("Single ISBN" = "single", "Multiple ISBNs" = "multi"),
      selected = "single"
    ),
    
    conditionalPanel(
      condition = "input.search_mode == 'single'",
      textInput(
        "isbn_input",
        "Enter ISBN:",
        placeholder = "e.g., 0195065786"
      ),
      tags$small("Try: 0195065786, 0262033844, 0394800028", style = "color: #666;")
    ),
    
    conditionalPanel(
      condition = "input.search_mode == 'multi'",
      textAreaInput(
        "isbn_multi_input",
        "Enter ISBNs (one per line):",
        placeholder = "0195065786\n0262033844\n0394800028",
        rows = 6
      )
    ),
    
    selectInput(
      "server_select",
      "Select Library Server:",
      choices = names(servers)
    ),
    
    actionButton(
      "search_button",
      "Search Catalog",
      class = "btn-primary w-100"
    ),
    
    hr(),
    
    checkboxInput(
      "show_debug",
      "Show debug information",
      value = FALSE
    ),
    
    hr(),
    
    downloadButton(
      "export_json",
      "Export Results (JSON)",
      class = "btn-outline-secondary w-100"
    ),
    
    downloadButton(
      "export_csv",
      "Export Results (CSV)",
      class = "btn-outline-secondary w-100 mt-2"
    ),
    
    conditionalPanel(
      condition = "input.search_mode == 'multi'",
      div(
        class = "mt-3",
        uiOutput("search_progress")
      )
    )
  ),
  
  layout_column_wrap(
    width = 1,
    id = "results_container",
    uiOutput("search_results")
  )
)
# --- Server Logic ---
server <- function(input, output, session) {
  
  book_data <- reactiveVal(list())
  search_queries <- reactiveVal(list())
  
  observeEvent(input$search_button, {
    if (input$search_mode == "single") {
      req(input$isbn_input)
      isbns <- input$isbn_input
    } else {
      req(input$isbn_multi_input)
      isbns <- strsplit(input$isbn_multi_input, "\n")[[1]]
      isbns <- trimws(isbns)
      isbns <- isbns[isbns != ""]
    }
    
    if (length(isbns) == 0) {
      showNotification("Please enter at least one ISBN.", type = "warning")
      return()
    }
    
    book_data(list())
    search_queries(list())
    server_config <- servers[[input$server_select]]
    
    results <- list()
    queries_debug <- list()
    
    for (i in seq_along(isbns)) {
      isbn <- isbns[i]
      
      # Update progress
      if (input$search_mode == "multi") {
        output$search_progress <- renderUI({
          tags$div(
            tags$p(paste("Searching", i, "of", length(isbns), "...")),
            tags$div(
              class = "progress",
              tags$div(
                class = "progress-bar progress-bar-striped progress-bar-animated",
                role = "progressbar",
                style = paste0("width: ", (i/length(isbns))*100, "%")
              )
            )
          )
        })
      }
      
      # Store query for debugging
      clean_isbn <- gsub("[^0-9X]", "", toupper(isbn))
      query_url <- paste0(
        server_config$url,
        "?version=1.1&operation=searchRetrieve&query=bath.isbn=\"",
        clean_isbn,
        "\"&recordSchema=marcxml&maximumRecords=1"
      )
      queries_debug[[isbn]] <- query_url
      
      data <- fetch_and_parse_marcxml(isbn, server_config)
      
      if (is.null(data)) {
        data <- list(
          ISBN = isbn,
          SearchStatus = "Not Found",
          Title = "No record found in catalog",
          Author = NA_character_,
          Publisher = NA_character_,
          Year = NA_character_
        )
      } else if (!is.null(data$error)) {
        data <- list(
          ISBN = isbn,
          SearchStatus = "Error",
          ErrorMessage = data$error,
          Title = "Search error occurred",
          Author = NA_character_,
          Publisher = NA_character_,
          Year = NA_character_
        )
      }
      
      results[[length(results) + 1]] <- data
      
      if (i < length(isbns)) {
        Sys.sleep(0.5)
      }
    }
    
    output$search_progress <- renderUI({ NULL })
    book_data(results)
    search_queries(queries_debug)
    
    found_count <- sum(sapply(results, function(x) x$SearchStatus == "Found"))
    showNotification(
      paste("Search complete:", found_count, "of", length(isbns), "found"),
      type = if (found_count > 0) "message" else "warning",
      duration = 5
    )
  })
  
  output$search_results <- renderUI({
    data_list <- book_data()
    queries <- search_queries()
    
    if (length(data_list) == 0) {
      if (input$search_button == 0) {
        return(tags$div(
          class = "alert alert-info",
          tags$h5("Welcome to Academic Catalog Search! 📚"),
          tags$p("Enter an ISBN above and click 'Search' to begin."),
          tags$p("Note: The Library of Congress catalog may not have all books. Try these working examples:"),
          tags$ul(
            tags$li(tags$code("0195065786"), " - The Elements of Style"),
            tags$li(tags$code("0262033844"), " - Introduction to Algorithms"),
            tags$li(tags$code("0394800028"), " - The Cat in the Hat")
          )
        ))
      }
      return(NULL)
    }
    
    cards <- lapply(seq_along(data_list), function(idx) {
      data <- data_list[[idx]]
      
      if (data$SearchStatus == "Found") {
        tags$div(
          class = "card shadow-lg",
          style = paste0("border-left: 5px solid ", book_gold, ";"),
          tags$div(
            class = "card-body",
            tags$h4(data$Title, class = "card-title", style = paste0("color:", scholarly_blue)),
            tags$p(tags$strong("Author:"), data$Author),
            tags$p(tags$strong("Publisher/Year:"), paste(data$Publisher, data$Year, sep = ", ")),
            if (!is.null(data$Edition) && !is.na(data$Edition)) tags$p(tags$strong("Edition:"), data$Edition),
            if (!is.null(data$PhysicalDescription) && !is.na(data$PhysicalDescription)) {
              tags$p(tags$strong("Physical Description:"), data$PhysicalDescription)
            },
            tags$p(tags$strong("ISBN:"), data$ISBN),
            if (!is.null(data$LCCN) && !is.na(data$LCCN)) tags$p(tags$strong("LCCN:"), data$LCCN),
            if (length(data$Subjects) > 0) {
              tags$div(
                hr(),
                tags$h6("Subjects:", style = "color: #6c757d;"),
                tags$div(
                  class = "d-flex flex-wrap",
                  lapply(data$Subjects, function(sub) {
                    tags$span(
                      class = "badge rounded-pill me-2 mb-2",
                      style = paste0("background-color:", book_gold, "; color: white; font-weight: bold;"),
                      sub
                    )
                  })
                )
              )
            },
            if (input$show_debug && !is.null(queries[[data$ISBN]])) {
              tags$div(
                class = "debug-info",
                tags$strong("Debug - SRU Query URL:"),
                tags$br(),
                tags$a(href = queries[[data$ISBN]], target = "_blank", queries[[data$ISBN]], style = "word-break: break-all;")
              )
            }
          )
        )
      } else if (data$SearchStatus == "Error") {
        tags$div(
          class = "card shadow error-card",
          tags$div(
            class = "card-body",
            tags$h5(class = "card-title", "⚠️ Error: ", tags$code(data$ISBN)),
            tags$p(tags$strong("Error Message:"), data$ErrorMessage),
            if (input$show_debug && !is.null(queries[[data$ISBN]])) {
              tags$div(
                class = "debug-info mt-2",
                tags$strong("Debug - SRU Query URL:"),
                tags$br(),
                tags$a(href = queries[[data$ISBN]], target = "_blank", queries[[data$ISBN]], style = "word-break: break-all;")
              )
            }
          )
        )
      } else {
        tags$div(
          class = "card shadow not-found-card",
          tags$div(
            class = "card-body",
            tags$h5(class = "card-title", "❌ Not Found: ", tags$code(data$ISBN)),
            tags$p("This ISBN was not found in the Library of Congress catalog."),
            tags$p(
              tags$small(
                "Note: LOC may not have all editions. The same book can have different ISBNs for hardcover, paperback, different years, etc.",
                style = "color: #666;"
              )
            ),
            if (input$show_debug && !is.null(queries[[data$ISBN]])) {
              tags$div(
                class = "debug-info mt-2",
                tags$strong("Debug - SRU Query URL:"),
                tags$br(),
                tags$a(href = queries[[data$ISBN]], target = "_blank", queries[[data$ISBN]], style = "word-break: break-all;")
              )
            }
          )
        )
      }
    })
    
    tags$div(cards)
  })
  
  output$export_json <- downloadHandler(
    filename = function() {
      paste0("catalog-metadata-", format(Sys.time(), "%Y%m%d-%H%M%S"), ".json")
    },
    content = function(file) {
      data <- book_data()
      if (length(data) == 0) {
        showNotification("No data available to export.", type = "warning")
        return()
      }
      write_json(data, file, pretty = TRUE, auto_unbox = TRUE)
    }
  )
  
  output$export_csv <- downloadHandler(
    filename = function() {
      paste0("catalog-metadata-", format(Sys.time(), "%Y%m%d-%H%M%S"), ".csv")
    },
    content = function(file) {
      data <- book_data()
      if (length(data) == 0) {
        showNotification("No data available to export.", type = "warning")
        return()
      }
      
      df <- do.call(rbind, lapply(data, function(x) {
        data.frame(
          ISBN = x$ISBN,
          Status = x$SearchStatus,
          Title = ifelse(is.null(x$Title), NA, x$Title),
          Author = ifelse(is.null(x$Author) || is.na(x$Author), NA, x$Author),
          Publisher = ifelse(is.null(x$Publisher) || is.na(x$Publisher), NA, x$Publisher),
          Year = ifelse(is.null(x$Year) || is.na(x$Year), NA, x$Year),
          Edition = ifelse(is.null(x$Edition) || is.na(x$Edition), NA, x$Edition),
          Subjects = ifelse(!is.null(x$Subjects) && length(x$Subjects) > 0, 
                            paste(x$Subjects, collapse = "; "), NA),
          stringsAsFactors = FALSE
        )
      }))
      
      write.csv(df, file, row.names = FALSE)
    }
  )
}
# Run the application
shinyApp(ui = ui, server = server)