@@ -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+
130170pull_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
331371get_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
616665get_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+
651804get_codebook_viewer_tables <- function (codebook ) {
652805 tables <- list (
653806 project = list (
0 commit comments