decisions / R /data.R
fmegahed's picture
Deploy 7a572e1 from GitHub
9cee884 verified
Raw History Blame Contribute Delete
13 kB
# Data of the visitor: images (loose files or zip files), references, pasted
# text and CSV files. Each reader returns a list with `inputs` (the inputs
# tibble of spec.R) and `problems` (short sentences for the visitor). The
# readers never stop on bad data: they skip it and say why.
IMAGE_EXTENSIONS <- c("jpg", "jpeg", "png", "webp")
# Bytes in a limit given in MB
mb_bytes <- function(mb) mb * 1024^2
# TRUE when a file name is an image that Jev can read
is_image_name <- function(x) tolower(tools::file_ext(x)) %in% IMAGE_EXTENSIONS
# TRUE when a zip entry stays inside the folder of the zip: no absolute path,
# no drive letter, no backslash and no `..`
safe_entry <- function(x) {
!startsWith(x, "/") && !grepl("^[A-Za-z]:", x) && !grepl("\\", x, fixed = TRUE) &&
!any(strsplit(x, "/", fixed = TRUE)[[1]] == "..")
}
# TRUE for the files that macOS and Windows add to a zip file: a folder
# __MACOSX, a dot file or folder (._a.jpg, .DS_Store), Thumbs.db, desktop.ini
is_junk <- function(x) {
parts <- strsplit(x, "/", fixed = TRUE)[[1]]
any(parts == "__MACOSX" | startsWith(parts, ".")) ||
tolower(utils::tail(parts, 1)) %in% c("thumbs.db", "desktop.ini")
}
# The inputs tibble with no rows
empty_inputs <- function() {
tibble::tibble(
id = character(), path = character(), text = character(),
file = character(), label = character()
)
}
# Unique ids: a running number joined with the file name, so that the same
# name in two folders gives two ids. Spaces and odd characters become "_".
make_ids <- function(file) {
clean <- stringr::str_replace_all(file, "[^\\p{L}\\p{N}._-]+", "_")
sprintf("%03d_%s", seq_along(file), clean)
}
# Unpack the safe image entries of a zip file into the folder `dest`. The
# entries are counted from the listing first: when they are more than
# `budget`, nothing is extracted. An entry over the file limit is never
# extracted. One call to zip::unzip() extracts the rest, below `dest` only.
# Returns the files (`rel`, `path`, `size`), the count of other files, the
# count of images and the problems.
unpack_zip <- function(zipfile, name, dest, budget = MAX_IMAGES) {
entries <- tryCatch(zip::zip_list(zipfile), error = function(e) NULL)
if (is.null(entries)) {
return(list(
files = NULL, other = 0L, count = 0L,
problems = sprintf("%s could not be opened. Check that it is a zip file.", name)
))
}
kept <- entries |>
dplyr::filter(!endsWith(filename, "/")) |>
dplyr::filter(purrr::map_lgl(filename, safe_entry)) |>
dplyr::filter(!purrr::map_lgl(filename, is_junk))
# A single top folder around the label folders is not a label
parts <- strsplit(kept$filename, "/", fixed = TRUE)
tops <- unique(purrr::map_chr(parts, 1))
rel <- kept$filename
if (length(tops) == 1 && any(lengths(parts) >= 3)) {
rel <- stringr::str_remove(rel, stringr::fixed(paste0(tops, "/")))
}
kept <- kept |> dplyr::mutate(rel = rel)
is_img <- is_image_name(kept$filename)
too_big <- is_img & kept$uncompressed_size > mb_bytes(MAX_FILE_MB)
use <- kept |> dplyr::filter(is_img, !too_big)
problems <- sprintf("%s was skipped. A file can be at most %s MB.", basename(kept$rel[too_big]), MAX_FILE_MB)
out <- list(files = NULL, other = sum(!is_img), count = nrow(use), problems = problems)
if (nrow(use) == 0 || nrow(use) > budget) return(out)
dir.create(dest, recursive = TRUE, showWarnings = FALSE)
zip::unzip(zipfile, files = use$filename, exdir = dest)
out$files <- use |>
dplyr::mutate(path = file.path(dest, filename), size = uncompressed_size) |>
dplyr::select(rel, path, size)
out
}
# Sentence for the files that are not images
not_image_problem <- function(n) {
if (n == 0) return(character())
if (n == 1) return("1 file is not an image and was skipped. Use JPEG, PNG or WebP.")
sprintf("%d files are not images and were skipped. Use JPEG, PNG or WebP.", n)
}
# Copy a loose upload into `dir` (one subfolder for each upload); returns the new path
keep_upload <- function(path, name, dir, i) {
dest <- file.path(dir, sprintf("file%03d", i))
dir.create(dest, recursive = TRUE, showWarnings = FALSE)
file.copy(path, file.path(dest, basename(name)))
file.path(dest, basename(name))
}
# Images from the files of shiny::fileInput() (columns `name`, `datapath`).
# A folder in a zip file gives the label; loose files have no label. Every
# file is written below `dir`, so the caller can delete `dir` at the end of
# the session. The result carries `dir`.
read_images <- function(files, dir = tempfile("upload")) {
if (is.null(files) || nrow(files) == 0) {
return(list(inputs = empty_inputs(), problems = character(), dir = dir))
}
problems <- character()
other <- 0L
loaded <- 0L
counted <- 0L
found <- purrr::pmap(list(files$name, files$datapath, seq_len(nrow(files))), \(name, path, i) {
if (tolower(tools::file_ext(name)) == "zip") {
if (file.size(path) > mb_bytes(MAX_ZIP_MB)) {
problems <<- c(problems, sprintf("%s was skipped. A zip file can be at most %s MB.", name, MAX_ZIP_MB))
return(NULL)
}
zipped <- unpack_zip(path, name, file.path(dir, sprintf("zip%03d", i)), MAX_IMAGES - loaded)
problems <<- c(problems, zipped$problems)
other <<- other + zipped$other
counted <<- counted + zipped$count
loaded <<- loaded + NROW(zipped$files)
return(zipped$files)
}
if (is_junk(name)) return(NULL)
if (!is_image_name(name)) {
other <<- other + 1L
return(NULL)
}
if (file.size(path) > mb_bytes(MAX_FILE_MB)) {
problems <<- c(problems, sprintf("%s was skipped. A file can be at most %s MB.", name, MAX_FILE_MB))
return(NULL)
}
counted <<- counted + 1L
if (loaded >= MAX_IMAGES) return(NULL)
loaded <<- loaded + 1L
tibble::tibble(rel = name, path = keep_upload(path, name, dir, i), size = file.size(path))
})
images <- dplyr::bind_rows(found)
problems <- c(problems, not_image_problem(other))
if (counted > MAX_IMAGES) {
problems <- c(problems, sprintf(
"A run can have at most %d images. This upload has %d. Upload fewer images.",
MAX_IMAGES, counted
))
images <- images[0, ]
}
if (nrow(images) == 0) return(list(inputs = empty_inputs(), problems = problems, dir = dir))
parts <- strsplit(images$rel, "/", fixed = TRUE)
file <- purrr::map_chr(parts, utils::tail, 1)
label <- purrr::map_chr(parts, \(p) if (length(p) >= 2) p[1] else NA_character_)
inputs <- tibble::tibble(
id = make_ids(file), path = images$path, text = NA_character_,
file = file, label = label
)
list(inputs = inputs, problems = problems, dir = dir)
}
# Reference images: loose image files, at most MAX_REFERENCES, copied below `dir`
read_references <- function(files, dir = tempfile("upload")) {
if (is.null(files) || nrow(files) == 0) {
return(list(paths = character(), problems = character(), dir = dir))
}
is_img <- is_image_name(files$name)
too_big <- is_img & file.size(files$datapath) > mb_bytes(MAX_FILE_MB)
problems <- c(
sprintf("%s was skipped. A file can be at most %s MB.", files$name[too_big], MAX_FILE_MB),
not_image_problem(sum(!is_img))
)
use <- which(is_img & !too_big)
if (length(use) > MAX_REFERENCES) {
problems <- c(problems, sprintf(
"You can add at most %d references for each answer. The app used the first %d.",
MAX_REFERENCES, MAX_REFERENCES
))
use <- use[seq_len(MAX_REFERENCES)]
}
paths <- purrr::map_chr(use, \(i) keep_upload(files$datapath[i], files$name[i], dir, i))
list(paths = paths, problems = problems, dir = dir)
}
# Inputs from texts: skip the texts that are too long, and stop when there
# are too many. `file` is the display name of each text.
texts_to_inputs <- function(text, file, label, problems = character()) {
long <- stringr::str_length(text) > MAX_TEXT_CHARS
if (any(long)) {
problems <- c(problems, sprintf(
"%s was skipped. A text can have at most %s characters.",
file[long], format(MAX_TEXT_CHARS, big.mark = ",")
))
}
keep <- !long
if (sum(keep) > MAX_TEXTS) {
problems <- c(problems, sprintf(
"A run can have at most %d texts. This upload has %d. Use fewer texts.",
MAX_TEXTS, sum(keep)
))
keep <- rep(FALSE, length(text))
}
if (!any(keep)) return(list(inputs = empty_inputs(), problems = problems))
inputs <- tibble::tibble(
id = make_ids(file[keep]), path = NA_character_, text = text[keep],
file = file[keep], label = label[keep]
)
list(inputs = inputs, problems = problems)
}
# One input for each paragraph; one or more blank lines separate paragraphs
read_text_paste <- function(text) {
if (is.null(text) || length(text) == 0 || is.na(text[1])) text <- ""
paragraphs <- text[1] |>
stringr::str_replace_all("\r\n?", "\n") |>
stringr::str_split("\\n\\s*\\n") |>
unlist() |>
stringr::str_trim()
paragraphs <- paragraphs[paragraphs != ""]
texts_to_inputs(
paragraphs, sprintf("Text %d", seq_along(paragraphs)),
rep(NA_character_, length(paragraphs))
)
}
# The encoding of a CSV file: UTF-8 when the bytes are valid UTF-8 (a BOM is
# fine), else the best guess of readr, else Latin-1
csv_encoding <- function(path) {
bytes <- readBin(path, "raw", file.size(path))
if (!0x00 %in% bytes && validUTF8(rawToChar(bytes))) return("UTF-8")
guess <- readr::guess_encoding(path)$encoding
if (length(guess) == 0 || is.na(guess[1])) "latin1" else guess[1]
}
# Read a CSV file with every column as text; NULL when it cannot be read
read_csv_text <- function(path, n_max = Inf) {
tryCatch(
readr::read_csv(
path, locale = readr::locale(encoding = csv_encoding(path)),
col_types = readr::cols(.default = "c"), na = character(),
n_max = n_max, progress = FALSE
),
error = function(e) NULL
)
}
# The column names of a CSV file
csv_columns <- function(path) {
if (file.size(path) > mb_bytes(MAX_FILE_MB)) return(character())
names(read_csv_text(path, n_max = 0))
}
# Inputs from a CSV file: the text column, and optionally the label column.
# Empty texts are dropped with a count; blank labels stay unlabelled.
read_text_csv <- function(path, text_col, label_col = NULL, name = basename(path)) {
if (file.size(path) > mb_bytes(MAX_FILE_MB)) {
return(list(
inputs = empty_inputs(),
problems = sprintf("%s was skipped. A file can be at most %s MB.", name, MAX_FILE_MB)
))
}
data <- read_csv_text(path)
if (is.null(data)) {
return(list(inputs = empty_inputs(), problems = "The CSV file could not be read."))
}
if (!text_col %in% names(data)) {
return(list(
inputs = empty_inputs(),
problems = sprintf("The column %s is not in the file.", text_col)
))
}
has_label <- !is.null(label_col) && label_col %in% names(data)
data <- tibble::tibble(
text = stringr::str_trim(data[[text_col]]),
label = if (has_label) stringr::str_trim(data[[label_col]]) else NA_character_,
file = sprintf("Row %d", seq_len(nrow(data)))
) |>
dplyr::mutate(label = dplyr::na_if(label, ""))
empty <- data$text == ""
problems <- if (sum(empty) == 1) {
"1 empty text was dropped"
} else if (sum(empty) > 1) {
sprintf("%d empty texts were dropped", sum(empty))
} else {
character()
}
data <- data[!empty, ]
texts_to_inputs(data$text, data$file, data$label, problems)
}
# Match the labels to the answer names after str_squish() and str_to_lower().
# A matched label gets the exact answer name; any other label becomes NA (the
# input stays, unlabelled) and is listed once. `answers` is the vector of
# answers (named) or of answer names.
match_labels <- function(inputs, answers) {
clean <- \(x) stringr::str_to_lower(stringr::str_squish(x))
answer_names <- if (is.null(names(answers))) answers else names(answers)
hit <- match(clean(inputs$label), clean(answer_names))
unmatched <- stringr::str_squish(inputs$label[is.na(hit) & !is.na(inputs$label)])
unmatched <- unmatched[!duplicated(clean(unmatched))]
inputs$label <- answer_names[hit]
list(inputs = inputs, problems = sprintf("%s is not an answer", unmatched))
}
# A small JPEG copy of an image (at most 320 pixels on the long side) for the
# page; FALSE when the image cannot be read
make_thumb <- function(path, dest) {
tryCatch(
{
magick::image_read(path)[1] |>
magick::image_background("white") |>
magick::image_scale("320x320>") |>
magick::image_write(dest, format = "jpeg")
TRUE
},
error = \(e) FALSE
)
}
# Delete the files of Shiny uploads, and their folders when they are empty
remove_uploads <- function(paths) {
unlink(paths)
folders <- setdiff(
unique(normalizePath(dirname(paths), winslash = "/", mustWork = FALSE)),
normalizePath(tempdir(), winslash = "/")
)
files <- lengths(lapply(folders, list.files, all.files = TRUE, no.. = TRUE))
empty <- folders[dir.exists(folders) & files == 0]
unlink(empty, recursive = TRUE)
}