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)
