diff --git a/DESCRIPTION b/DESCRIPTION index 5d90fa1..293cf50 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -23,6 +23,8 @@ Imports: janitor, lubridate, nanoarrow, + processx, + quarto, readr, renv, rlang, diff --git a/R/cli.R b/R/cli.R new file mode 100644 index 0000000..5df4e55 --- /dev/null +++ b/R/cli.R @@ -0,0 +1,71 @@ +#' @title Ask for Yes/No Confirmation +#' +#' @description Interactive yes/no confirmation with cli styling. +#' Returns TRUE for yes, FALSE for no. Follows the pattern from cli issue #488. +#' +#' @param text The question text (supports cli styling) +#' @param yes Character vector of affirmative options (default: c("Yes", "yeah")) +#' @param no Character vector of negative options (default: c("No", "nope")) +#' @param n_yes Number of yes options to show (default: 1) +#' @param n_no Number of no options to show (default: 1) +#' @param shuffle Whether to shuffle the options (default: TRUE) +#' @param ... Additional arguments passed to cli::cli_alert() +#' @param .envir Environment for glue interpolation +#' +#' @return Logical TRUE for yes, FALSE for no +#' +#' @keywords internal +#' +#' @examples +#' \dontrun{ +#' # Simple confirmation +#' if (cli_yeah("Delete all files?")) { +#' # Proceed with deletion +#' } +#' +#' # Custom options +#' proceed <- cli_yeah( +#' "Continue with migration?", +#' yes = c("Yes, proceed", "Continue"), +#' no = c("Cancel", "Stop") +#' ) +#' } +cli_yeah <- function( + text, + yes = c("Yes", "yeah"), + no = c("No", "nope"), + n_yes = 1, + n_no = 1, + shuffle = TRUE, + ..., + .envir = parent.frame() +) { + if (!rlang::is_interactive()) { + cli::cli_abort( + c( + "User input required, but session is not interactive.", + i = "Query: {text}" + ), + .envir = .envir + ) + } + + n_yes <- min(n_yes, length(yes)) + n_no <- min(n_no, length(no)) + + # Sample options + qs <- c(sample(yes, n_yes), sample(no, n_no)) + + if (shuffle) { + qs <- sample(qs) + } + + # Show the question + cli::cli_alert(text, ..., .envir = .envir) + + # Present menu and get choice + choice <- utils::menu(qs, title = "Choose an option:") + + # Return TRUE if yes option selected + choice != 0L && qs[[choice]] %in% yes +} diff --git a/R/template.R b/R/template.R index 7d2d3a9..1c85d8c 100644 --- a/R/template.R +++ b/R/template.R @@ -1,74 +1,216 @@ -#' @title Function to use the OJO quarto HTML template -#' -#' @description Wrapper for `quarto use template` command. Defaults to OJO html template. -#' -#' @param path The name of the directory where the template should be added. This should be a string representing an absolute path, or a path relative to the current working directory. -#' @param template The name of the Quarto template to use. This should be a string in the format "username/repository". Default is "openjusticeok/ojo-report-template". -#' @return This function does not return a value. If the user chooses not to proceed with the operation, the function exits silently. +validate_project_name <- function(name) { + stringr::str_detect(name, "^[a-z0-9]+(-[a-z0-9]+)*$") +} + +prompt_project_name <- function() { + project_name <- readline(cli::format_inline("Project name (kebab-case): ")) + while (!validate_project_name(project_name)) { + cli::cli_alert_danger("Invalid name. Use lowercase, numbers, hyphens only.") + project_name <- readline(cli::format_inline("Project name (kebab-case): ")) + } + project_name +} + +prompt_parent_dir <- function(default = fs::path_wd()) { + prompt_text <- cli::format_inline("Parent directory [{.path {default}}]: ") + parent_dir <- readline(prompt_text) + if (parent_dir == "") { + parent_dir <- default + } + parent_dir <- fs::path_abs(parent_dir) + if (!fs::dir_exists(parent_dir)) { + cli::cli_alert_info("Creating parent directory {.path {parent_dir}}...") + fs::dir_create(parent_dir, recurse = TRUE) + } + parent_dir +} + +prompt_template <- function(default = "website") { + choices <- c( + "website" = "website (multi-page site)", + "report" = "report (single-page document)" + ) + # Pre-select default by putting it first + if (default == "report") { + choices <- choices[c("report", "website")] + } + choice <- utils::menu(choices, title = cli::format_inline("Select template type:")) + if (choice == 0L) { + return(invisible()) + } + names(choices)[choice] +} + +#' @title Function to use the OKPolicy quarto website template +#' @description Wrapper for `quarto use template` command. Creates a new project directory +#' and installs a Quarto template. The project name is derived from the last component +#' of the path and must be in kebab-case format. +#' @param path Character. The path to the project directory. Can be absolute or relative +#' to the current working directory. The last component of the path will be used as the +#' project name and must be in kebab-case (lowercase letters, numbers, and hyphens only). +#' If `NULL` (default) and in interactive mode, prompts for project name and location. +#' @param template Character. The type of project template to use. Must be one of: +#' * `"website"` (default) - Multi-page website template +#' * `"report"` - Single-page report template +#' @param .interactive Logical. Whether to prompt for user confirmation in interactive mode. +#' Defaults to `rlang::is_interactive()`. #' -#' @export +#' @return Invisibly returns the result of the quarto command execution. #' #' @examples #' \dontrun{ -#' ojo_use_template(template = "openjusticeok/ojo-report-template", path = "my_directory") +#' # Interactive mode (prompts for project name and location) +#' ojo_use_template() +#' +#' # Create a new website project (default) +#' ojo_use_template("my-new-website") +#' +#' # Create a report project +#' ojo_use_template("my-new-report", template = "report") +#' +#' # Create a project in a specific directory +#' ojo_use_template("~/Documents/Reports/my-new-website") +#' +#' # Create a project with interactive turned off +#' ojo_use_template("my-new-website", .interactive = FALSE) #' } +#' @export ojo_use_template <- function( - path, - template = "openjusticeok/ojo-report-template" + path = NULL, + template = c("website", "report"), + .interactive = rlang::is_interactive() ) { - command <- paste("quarto use template", template, "--no-prompt") + # If path is NULL and interactive, enter full interactive mode + if (is.null(path)) { + if (!.interactive) { + cli::cli_abort("{.arg path} is required in non-interactive mode.") + } - full_dir <- dplyr::if_else( - fs::is_absolute_path(path), - path, - fs::path_wd(path) - ) + project_name <- prompt_project_name() + parent_dir <- prompt_parent_dir() + selected_template <- prompt_template() - # Check if the directory exists - if (!dir.exists(full_dir)) { - # If not, ask if we should create it - ans <- utils::menu( - choices = c("Yes", "No"), - title = paste0( - "The path `", - full_dir, - "` does not exist.", - "\nDo you want to create it?" - ) - ) - # If they say no, just stop - if (ans == 2) { + # Handle cancellation from template prompt + if (is.null(selected_template)) { return(invisible()) } - # Otherwise, go ahead - fs::dir_create(full_dir) + + template <- selected_template + path <- fs::path(parent_dir, project_name) } - # Check that the directory is empty - if (!dir_empty(full_dir)) { - cli::cli_abort( - paste0( - "Provided directory `", full_dir, "` is not empty!\n", - "Please specify an empty directory (or enter the name of one to create) for the report to live in!" - ) - ) + # Validate template argument + template <- rlang::arg_match(template) + + # Map friendly names to GitHub template specs + template_spec <- switch(template, + website = "openjusticeok/okpolicy-quarto-templates/okpolicy-website-template", + report = "openjusticeok/okpolicy-quarto-templates/okpolicy-report-template" + ) + + # Path Resolution + project_dir <- fs::path_abs(path) + project_name <- fs::path_file(path) + + # Validate project name + if (!validate_project_name(project_name)) { + cli::cli_abort(c( + "x" = "{.arg path} must end with a valid kebab-case project name.", + "i" = "Allowed: lowercase letters, numbers, hyphens", + "i" = "Examples: {.code my-project}, {.code report-2024}, {.code data-analysis}" + )) } - # Confirm that directory is correct - if (utils::menu(choices = c("Yes", "No"), title = paste0( - "The template `", - template, - "` will be added to the directory `", - full_dir, - "`.\nDo you want to proceed?" - )) == 2) { - return(invisible()) + quarto_args <- c("use", "template", template_spec, "--no-prompt") + + # Find the executable + quarto_bin <- quarto::quarto_path(normalize = TRUE) + + # Graceful fail if Quarto is not installed + if (is.null(quarto_bin) || !nzchar(quarto_bin)) { + cli::cli_abort("Quarto is not installed or could not be found.") + } + # Directory handling logic + success <- FALSE + + if (!fs::dir_exists(project_dir)) { + # Directory doesn't exist, create automatically + cli::cli_alert_info("Creating directory {.path {project_dir}}...") + fs::dir_create(project_dir) + + # Set up cleanup on failure + on.exit(if (!success) try(fs::dir_delete(project_dir), silent = TRUE), add = TRUE) + } else { + # Directory exists, check if empty + if (!dir_empty(project_dir)) { + # Directory not empty, abort + cli::cli_abort(c( + "x" = "Directory {.path {project_dir}} exists and is not empty.", + "i" = "Remove the directory first or choose a different path." + )) + } + # Directory exists but empty, proceed without asking + } + + # Confirm user wants to proceed (only for new directories in interactive mode) + if (.interactive && !fs::dir_exists(project_dir)) { + if (!cli_yeah("Install the template to {.path {project_dir}}?")) { + cli::cli_alert_info("Aborted by user.") + fs::dir_delete(project_dir) + return(invisible()) + } } - # Run command - withr::with_dir(new = full_dir, code = system(command)) + cli::cli_alert_info("Installing {.field {template}} template to {.path {project_dir}}...") + + # Error/Status/StdErr/StdOut capturing + result <- tryCatch( + withr::with_dir( + new = project_dir, + code = { + processx::run( + command = quarto_bin, + args = quarto_args, + echo = .interactive, # print the stdout of the command + echo_cmd = .interactive, # print the command that will be run + error_on_status = TRUE, # if the command fails, throw an R error, which is then caught by the tryCatch block below + spinner = .interactive # show a spinner while the command is running + ) + } + ), + # c() is concatenating the following CLI styling together for cli::abort message + # x = red + # i = blue + # " " = invisible indent for clean continuation lines + # v = green + # ! = yellow warning + system_command_status_error = function(error) { + cli::cli_abort( + c( + "x" = "Quarto encountered an error. Template installation failed.", + # if quarto wrote a message to stderr write it to CLI, if not write "(no stderr)" + " " = if (nzchar(error$stderr)) error$stderr else "(no stderr)", + "i" = "Exit status: {error$status}" + ), + parent = error + ) + }, + # captures other errors that happen before or during quarto launch + error = function(error) { + cli::cli_abort( + c( + "!" = "Something went wrong while preparing the Quarto command.", + "i" = conditionMessage(error) + ), + parent = error + ) + } + ) + + success <- TRUE + cli::cli_alert_success( + "{.field {template}} template successfully added to directory {.path {project_dir}}!" + ) - cli::cli_alert_success(paste0( - "Template `", template, "` successfully added to directory `", full_dir, "`!" - )) + invisible(result) } diff --git a/man/cli_yeah.Rd b/man/cli_yeah.Rd new file mode 100644 index 0000000..f841782 --- /dev/null +++ b/man/cli_yeah.Rd @@ -0,0 +1,57 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cli.R +\name{cli_yeah} +\alias{cli_yeah} +\title{Ask for Yes/No Confirmation} +\usage{ +cli_yeah( + text, + yes = c("Yes", "yeah"), + no = c("No", "nope"), + n_yes = 1, + n_no = 1, + shuffle = TRUE, + ..., + .envir = parent.frame() +) +} +\arguments{ +\item{text}{The question text (supports cli styling)} + +\item{yes}{Character vector of affirmative options (default: c("Yes", "yeah"))} + +\item{no}{Character vector of negative options (default: c("No", "nope"))} + +\item{n_yes}{Number of yes options to show (default: 1)} + +\item{n_no}{Number of no options to show (default: 1)} + +\item{shuffle}{Whether to shuffle the options (default: TRUE)} + +\item{...}{Additional arguments passed to cli::cli_alert()} + +\item{.envir}{Environment for glue interpolation} +} +\value{ +Logical TRUE for yes, FALSE for no +} +\description{ +Interactive yes/no confirmation with cli styling. +Returns TRUE for yes, FALSE for no. Follows the pattern from cli issue #488. +} +\examples{ +\dontrun{ +# Simple confirmation +if (cli_yeah("Delete all files?")) { + # Proceed with deletion +} + +# Custom options +proceed <- cli_yeah( + "Continue with migration?", + yes = c("Yes, proceed", "Continue"), + no = c("Cancel", "Stop") +) +} +} +\keyword{internal} diff --git a/man/ojo_use_template.Rd b/man/ojo_use_template.Rd index c8b4742..c14d936 100644 --- a/man/ojo_use_template.Rd +++ b/man/ojo_use_template.Rd @@ -2,23 +2,52 @@ % Please edit documentation in R/template.R \name{ojo_use_template} \alias{ojo_use_template} -\title{Function to use the OJO quarto HTML template} +\title{Function to use the OKPolicy quarto website template} \usage{ -ojo_use_template(path, template = "openjusticeok/ojo-report-template") +ojo_use_template( + path = NULL, + template = c("website", "report"), + .interactive = rlang::is_interactive() +) } \arguments{ -\item{path}{The name of the directory where the template should be added. This should be a string representing an absolute path, or a path relative to the current working directory.} +\item{path}{Character. The path to the project directory. Can be absolute or relative +to the current working directory. The last component of the path will be used as the +project name and must be in kebab-case (lowercase letters, numbers, and hyphens only). +If \code{NULL} (default) and in interactive mode, prompts for project name and location.} -\item{template}{The name of the Quarto template to use. This should be a string in the format "username/repository". Default is "openjusticeok/ojo-report-template".} +\item{template}{Character. The type of project template to use. Must be one of: +\itemize{ +\item \code{"website"} (default) - Multi-page website template +\item \code{"report"} - Single-page report template +}} + +\item{.interactive}{Logical. Whether to prompt for user confirmation in interactive mode. +Defaults to \code{rlang::is_interactive()}.} } \value{ -This function does not return a value. If the user chooses not to proceed with the operation, the function exits silently. +Invisibly returns the result of the quarto command execution. } \description{ -Wrapper for \verb{quarto use template} command. Defaults to OJO html template. +Wrapper for \verb{quarto use template} command. Creates a new project directory +and installs a Quarto template. The project name is derived from the last component +of the path and must be in kebab-case format. } \examples{ \dontrun{ -ojo_use_template(template = "openjusticeok/ojo-report-template", path = "my_directory") +# Interactive mode (prompts for project name and location) +ojo_use_template() + +# Create a new website project (default) +ojo_use_template("my-new-website") + +# Create a report project +ojo_use_template("my-new-report", template = "report") + +# Create a project in a specific directory +ojo_use_template("~/Documents/Reports/my-new-website") + +# Create a project with interactive turned off +ojo_use_template("my-new-website", .interactive = FALSE) } }