Skip to content

Commit 5e652d4

Browse files
authored
Merge pull request #12 from CHOP-CGTInformatics/codex/codebook-save-html
Fix HTML save for codebook viewer
2 parents e39f883 + c24fccd commit 5e652d4

7 files changed

Lines changed: 245 additions & 10 deletions

File tree

NAMESPACE

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export(build_quality_report)
77
export(build_record_status_data)
88
export(plot_record_status)
99
export(pull_redcap_project)
10+
export(save_codebook)
1011
export(view_codebook)
1112
importFrom(DT,datatable)
1213
importFrom(REDCapR,redcap_arm_export)
@@ -57,8 +58,10 @@ importFrom(ggplot2,scale_x_discrete)
5758
importFrom(ggplot2,scale_y_discrete)
5859
importFrom(ggplot2,theme)
5960
importFrom(ggplot2,theme_minimal)
61+
importFrom(htmltools,HTML)
6062
importFrom(htmltools,browsable)
6163
importFrom(htmltools,htmlDependency)
64+
importFrom(htmltools,save_html)
6265
importFrom(htmltools,tagList)
6366
importFrom(htmltools,tags)
6467
importFrom(purrr,flatten_chr)

R/REDCapExploreR-package.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
#' right_join row_number select slice summarise transmute
99
#' @importFrom ggplot2 aes element_blank element_text geom_tile ggplot labs
1010
#' scale_fill_gradientn scale_x_discrete scale_y_discrete theme theme_minimal
11-
#' @importFrom htmltools browsable htmlDependency tagList tags
11+
#' @importFrom htmltools browsable HTML htmlDependency save_html tagList tags
1212
#' @importFrom purrr flatten_chr map map_chr map_dbl map_lgl
1313
#' @importFrom REDCapR redcap_arm_export redcap_event_instruments
1414
#' redcap_instruments redcap_event_read redcap_instrument_repeating

R/codebook.R

Lines changed: 159 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ get_codebook <- function(project) {
7070
#' `view_codebook()` turns a `redcap_codebook` object from [build_codebook()]
7171
#' into a static HTML viewer with tab-style section navigation and one
7272
#' interactive table per codebook section. The returned object prints in the
73-
#' RStudio/Posit Viewer and can be saved as an HTML file with
74-
#' [htmltools::save_html()]. Event, event-instrument, and repeating-structure
73+
#' RStudio/Posit Viewer. Use [save_codebook()] to save an HTML file for sharing
74+
#' or email attachment. Event, event-instrument, and repeating-structure
7575
#' sections are omitted when those API tables are empty.
7676
#'
7777
#' @param codebook A `redcap_codebook` object returned by [build_codebook()].
@@ -88,7 +88,7 @@ get_codebook <- function(project) {
8888
#' )
8989
#'
9090
#' viewer <- view_codebook(codebook)
91-
#' htmltools::save_html(viewer, "codebook.html")
91+
#' save_codebook(codebook, "codebook.html")
9292
#' }
9393
#'
9494
#' @export
@@ -127,6 +127,46 @@ view_codebook <- function(codebook, page_length = 25) {
127127
)
128128
}
129129

130+
#' @title Save a REDCap codebook viewer
131+
#'
132+
#' @description
133+
#' `save_codebook()` saves the interactive HTML viewer from [view_codebook()]
134+
#' to disk as a single HTML file. Local CSS and JavaScript dependencies are
135+
#' inlined so the file can be opened in a browser or attached to an email
136+
#' without a separate dependency folder.
137+
#'
138+
#' @param codebook A `redcap_codebook` object returned by [build_codebook()].
139+
#' @param file Output HTML file path.
140+
#' @param page_length Number of rows to show per interactive table page.
141+
#'
142+
#' @returns The output file path, invisibly.
143+
#'
144+
#' @examples
145+
#' \dontrun{
146+
#' codebook <- build_codebook(
147+
#' redcap_uri = Sys.getenv("REDCAP_URI"),
148+
#' token = Sys.getenv("REDCAP_TOKEN")
149+
#' )
150+
#'
151+
#' save_codebook(codebook, "codebook.html")
152+
#' }
153+
#'
154+
#' @export
155+
save_codebook <- function(
156+
codebook,
157+
file,
158+
page_length = 25
159+
) {
160+
get_validate_codebook(codebook)
161+
get_validate_html_file(file)
162+
get_validate_page_length(page_length)
163+
164+
viewer <- view_codebook(codebook, page_length = page_length)
165+
get_save_codebook_single_file(viewer, file)
166+
167+
invisible(file)
168+
}
169+
130170
pull_redcap_codebook <- function(redcap_uri, token) {
131171
get_validate_api_credentials(redcap_uri, token)
132172

@@ -330,7 +370,11 @@ get_codebook_forms <- function(project) {
330370

331371
get_codebook_project_summary <- function(project) {
332372
project_info <- project$project_info
333-
project_title <- if ("project_title" %in% names(project_info) && nrow(project_info) > 0) {
373+
has_title_col <- "project_title" %in% names(project_info)
374+
has_info_rows <- nrow(project_info) > 0
375+
has_project_title <- has_title_col && has_info_rows
376+
377+
project_title <- if (has_project_title) {
334378
as.character(project_info$project_title[[1]])
335379
} else {
336380
"Unknown REDCap project"
@@ -550,7 +594,12 @@ get_codebook_repeating_summary <- function(project) {
550594
}
551595

552596
repeating_events <- if (
553-
"redcap_event_name" %in% names(repeating) && all(c("redcap_event_name", "form_name") %in% names(project$event_instruments))
597+
"redcap_event_name" %in%
598+
names(repeating) &&
599+
all(
600+
c("redcap_event_name", "form_name") %in%
601+
names(project$event_instruments)
602+
)
554603
) {
555604
event_rows <- if ("form_name" %in% names(repeating)) {
556605
repeating |> filter(get_is_missing(.data$form_name))
@@ -614,7 +663,10 @@ get_codebook_validation_label <- function(
614663
}
615664

616665
get_codebook_event_count <- function(project) {
617-
if (nrow(project$events) == 0 || !"redcap_event_name" %in% names(project$events)) {
666+
if (
667+
nrow(project$events) == 0 ||
668+
!"redcap_event_name" %in% names(project$events)
669+
) {
618670
return(NA_integer_)
619671
}
620672

@@ -648,6 +700,107 @@ get_validate_page_length <- function(page_length) {
648700
invisible(NULL)
649701
}
650702

703+
get_validate_html_file <- function(file) {
704+
valid_file <- is.character(file) &&
705+
length(file) == 1 &&
706+
!is.na(file) &&
707+
file != ""
708+
709+
if (!valid_file) {
710+
cli_abort("{.arg file} must be a single non-empty HTML file path.")
711+
}
712+
713+
parent_dir <- dirname(file)
714+
if (!dir.exists(parent_dir)) {
715+
cli_abort("The output directory for {.arg file} must already exist.")
716+
}
717+
718+
invisible(NULL)
719+
}
720+
721+
get_save_codebook_single_file <- function(viewer, file) {
722+
temp_dir <- tempfile("redcap-codebook-save-")
723+
dir.create(temp_dir)
724+
on.exit(unlink(temp_dir, recursive = TRUE), add = TRUE)
725+
726+
temp_file <- file.path(temp_dir, basename(file))
727+
save_html(viewer, file = temp_file, libdir = "lib")
728+
729+
html <- paste(readLines(temp_file, warn = FALSE), collapse = "\n")
730+
html <- get_inline_html_styles(html, temp_dir)
731+
html <- get_inline_html_scripts(html, temp_dir)
732+
733+
writeLines(html, file, useBytes = TRUE)
734+
invisible(file)
735+
}
736+
737+
get_inline_html_styles <- function(html, base_dir) {
738+
get_inline_html_dependency(
739+
html = html,
740+
base_dir = base_dir,
741+
pattern = "<link([^>]+)href=\"([^\"]+)\"([^>]*)>",
742+
replacement = \(path, content) {
743+
paste0(
744+
"<style data-redcap-codebook-source=\"",
745+
path,
746+
"\">\n",
747+
content,
748+
"\n</style>"
749+
)
750+
}
751+
)
752+
}
753+
754+
get_inline_html_scripts <- function(html, base_dir) {
755+
get_inline_html_dependency(
756+
html = html,
757+
base_dir = base_dir,
758+
pattern = "<script([^>]*)src=\"([^\"]+)\"([^>]*)></script>",
759+
replacement = \(path, content) {
760+
paste0(
761+
"<script data-redcap-codebook-source=\"",
762+
path,
763+
"\">\n",
764+
content,
765+
"\n</script>"
766+
)
767+
}
768+
)
769+
}
770+
771+
get_inline_html_dependency <- function(html, base_dir, pattern, replacement) {
772+
matches <- gregexpr(pattern, html, perl = TRUE)
773+
starts <- matches[[1]]
774+
if (starts[[1]] == -1) {
775+
return(html)
776+
}
777+
778+
match_text <- regmatches(html, matches)[[1]]
779+
paths <- map_chr(match_text, \(match) {
780+
sub(pattern, "\\2", match, perl = TRUE)
781+
})
782+
783+
for (index in rev(seq_along(match_text))) {
784+
path <- paths[[index]]
785+
local_file <- file.path(base_dir, path)
786+
if (!file.exists(local_file)) {
787+
next
788+
}
789+
790+
content <- paste(readLines(local_file, warn = FALSE), collapse = "\n")
791+
inline <- replacement(path, content)
792+
start <- starts[[index]]
793+
end <- start + attr(starts, "match.length")[[index]] - 1
794+
html <- paste0(
795+
substr(html, 1, start - 1),
796+
inline,
797+
substr(html, end + 1, nchar(html))
798+
)
799+
}
800+
801+
html
802+
}
803+
651804
get_codebook_viewer_tables <- function(codebook) {
652805
tables <- list(
653806
project = list(

man/save_codebook.Rd

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/view_codebook.Rd

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkgdown/_pkgdown.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ reference:
4343
Build and view REDCap project codebooks.
4444
contents:
4545
- build_codebook
46+
- save_codebook
4647
- view_codebook
4748
- title: "Build Quality Reports"
4849
desc: >

tests/testthat/test-codebook.R

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,3 +605,46 @@ test_that("view_codebook escapes REDCap metadata values as text", {
605605
expect_match(as.character(viewer), "&lt;script&gt;alert")
606606
expect_false(grepl("<script>alert", as.character(viewer), fixed = TRUE))
607607
})
608+
609+
test_that("save_codebook validates inputs", {
610+
metadata <- tibble::tibble(
611+
field_name = "record_id",
612+
form_name = "main",
613+
field_type = "text",
614+
field_label = "Record ID"
615+
)
616+
codebook <- build_mock_codebook(metadata)
617+
output_file <- tempfile(fileext = ".html")
618+
619+
expect_error(save_codebook(tibble::tibble(), output_file), "redcap_codebook")
620+
expect_error(save_codebook(codebook, ""), "file")
621+
expect_error(
622+
save_codebook(codebook, output_file, page_length = 0),
623+
"page_length"
624+
)
625+
})
626+
627+
test_that("save_codebook writes a single HTML file with inlined dependencies", {
628+
metadata <- tibble::tibble(
629+
field_name = c("record_id", "status"),
630+
form_name = c("main", "main"),
631+
field_type = c("text", "dropdown"),
632+
field_label = c("Record ID", "Status"),
633+
select_choices_or_calculations = c(NA, "1, Open | 2, Closed")
634+
)
635+
project_info <- tibble::tibble(project_title = "Saved Codebook")
636+
codebook <- build_mock_codebook(metadata, project_info = project_info)
637+
output_dir <- tempdir()
638+
output_file <- file.path(output_dir, "saved-codebook.html")
639+
640+
result <- save_codebook(codebook, output_file)
641+
642+
expect_equal(result, output_file)
643+
expect_true(file.exists(output_file))
644+
expect_false(dir.exists(file.path(output_dir, "saved-codebook_files")))
645+
output <- paste(readLines(output_file, warn = FALSE), collapse = "\n")
646+
expect_match(output, "Saved Codebook")
647+
expect_match(output, "redcap-codebook-viewer")
648+
expect_match(output, "data-redcap-codebook-source")
649+
expect_false(grepl("saved-codebook_files", output, fixed = TRUE))
650+
})

0 commit comments

Comments
 (0)