beatrina 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
package/kernel/sniff.R
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
# sniff.R — format and column-type detection for the import wizard.
|
|
2
|
+
#
|
|
3
|
+
# Pure functions, no I/O side effects beyond reading the file it is asked
|
|
4
|
+
# about, and no `emit`. worker.R sources this into its private scope; the test
|
|
5
|
+
# sources it into a plain environment. Nothing here touches globalenv().
|
|
6
|
+
#
|
|
7
|
+
# THE PRINCIPLE: read every column as `character`, then decide. R's own
|
|
8
|
+
# `read.csv` decides during the read, where a wrong guess is silent and
|
|
9
|
+
# unrecoverable — a column of "03/04/2024" becomes a factor or a string and
|
|
10
|
+
# the date is gone; a column of "1,5" becomes character and the number is
|
|
11
|
+
# gone. Detection on raw text is inspectable, overridable, and reversible.
|
|
12
|
+
#
|
|
13
|
+
# THE OTHER PRINCIPLE: a format is accepted only if it explains EVERY value.
|
|
14
|
+
# strptime ignores trailing characters — as.Date("2024-03-04 10:00", "%Y-%m-%d")
|
|
15
|
+
# returns a valid date and throws the time away without a word. So every
|
|
16
|
+
# candidate format is first shape-gated by an anchored regex built from the
|
|
17
|
+
# format itself, and only then parsed. That single rule is the difference
|
|
18
|
+
# between detection you can trust and RStudio's guess.
|
|
19
|
+
|
|
20
|
+
# ── the NA vocabulary ───────────────────────────────────────────────────────
|
|
21
|
+
# The tokens that mean "missing" in files people actually receive. Detected,
|
|
22
|
+
# reported, and written into the generated code as `na.strings` — never
|
|
23
|
+
# applied invisibly.
|
|
24
|
+
NA_TOKENS <- c("", "NA", "N/A", "n/a", "#N/A", "#NA", "NULL", "null", "None",
|
|
25
|
+
"none", "nan", "NaN", ".", "-", "--", "?", "missing", "MISSING",
|
|
26
|
+
"Missing", "unknown", "Unknown", "<NA>")
|
|
27
|
+
|
|
28
|
+
LOGICAL_TRUE <- c("TRUE", "true", "True", "T", "YES", "yes", "Yes", "Y")
|
|
29
|
+
LOGICAL_FALSE <- c("FALSE", "false", "False", "F", "NO", "no", "No", "N")
|
|
30
|
+
|
|
31
|
+
DELIMITERS <- list(
|
|
32
|
+
list(char = ",", name = "comma"),
|
|
33
|
+
list(char = ";", name = "semicolon"),
|
|
34
|
+
list(char = "\t", name = "tab"),
|
|
35
|
+
list(char = "|", name = "pipe"),
|
|
36
|
+
list(char = " ", name = "space")
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
MAX_SNIFF_LINES <- 500L # lines read to decide format
|
|
40
|
+
MAX_SNIFF_VALUES <- 5000L # values per column examined for type
|
|
41
|
+
MAX_PREVIEW_ROWS <- 30L # rows returned to the wizard
|
|
42
|
+
MAX_PREVIEW_COLS <- 60L
|
|
43
|
+
MAX_EXAMPLES <- 4L
|
|
44
|
+
|
|
45
|
+
`%||%` <- function(a, b) if (is.null(a)) b else a
|
|
46
|
+
|
|
47
|
+
REGEX_META <- c(".", "^", "$", "|", "(", ")", "[", "]", "{", "}", "*", "+", "?", "\\")
|
|
48
|
+
|
|
49
|
+
# ── format → anchored regex ────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
#' The regex that a string must match before a format is even tried.
|
|
52
|
+
#'
|
|
53
|
+
#' strptime accepts trailing garbage, so "2024-03-04 10:00:00" parses cleanly
|
|
54
|
+
#' as "%Y-%m-%d" and the time vanishes. Anchoring a shape built from the same
|
|
55
|
+
#' format string closes that hole, and it closes it for every format at once
|
|
56
|
+
#' rather than one special case at a time.
|
|
57
|
+
#'
|
|
58
|
+
#' @param fmt A strptime format string.
|
|
59
|
+
#' @return A single anchored regex, or NA when the format uses a directive
|
|
60
|
+
#' this builder does not model (the caller then declines the candidate).
|
|
61
|
+
format_regex <- function(fmt) {
|
|
62
|
+
stopifnot(is.character(fmt), length(fmt) == 1L)
|
|
63
|
+
pieces <- list(
|
|
64
|
+
"%Y" = "[0-9]{4}", "%y" = "[0-9]{2}",
|
|
65
|
+
"%m" = "[0-9]{1,2}", "%d" = "[0-9]{1,2}", "%e" = "[ 0-9][0-9]",
|
|
66
|
+
"%H" = "[0-9]{1,2}", "%I" = "[0-9]{1,2}",
|
|
67
|
+
"%M" = "[0-9]{2}", "%S" = "[0-9]{2}",
|
|
68
|
+
"%OS" = "[0-9]{2}(?:[.,][0-9]+)?",
|
|
69
|
+
"%j" = "[0-9]{3}",
|
|
70
|
+
"%b" = "[A-Za-z]{3,9}[.]?", "%B" = "[A-Za-z]{3,9}",
|
|
71
|
+
"%p" = "[AaPp][.]?[Mm][.]?",
|
|
72
|
+
"%z" = "(?:Z|[+-][0-9]{2}:?[0-9]{2})",
|
|
73
|
+
"%Z" = "[A-Za-z/_+-]{1,32}"
|
|
74
|
+
)
|
|
75
|
+
# Longest directives first: %OS must win over %O/%S, %B over %b's shape.
|
|
76
|
+
keys <- names(pieces)[order(-nchar(names(pieces)))]
|
|
77
|
+
out <- ""
|
|
78
|
+
rest <- fmt
|
|
79
|
+
while (nzchar(rest)) {
|
|
80
|
+
if (substr(rest, 1L, 1L) == "%") {
|
|
81
|
+
hit <- Find(function(k) startsWith(rest, k), keys)
|
|
82
|
+
if (is.null(hit)) return(NA_character_) # unmodelled directive
|
|
83
|
+
out <- paste0(out, pieces[[hit]])
|
|
84
|
+
rest <- substring(rest, nchar(hit) + 1L)
|
|
85
|
+
} else {
|
|
86
|
+
ch <- substr(rest, 1L, 1L)
|
|
87
|
+
# A literal space in a format matches run-of-whitespace in real files.
|
|
88
|
+
# Escaping by MEMBERSHIP rather than by a regex over regex metacharacters:
|
|
89
|
+
# the pattern that quotes `{` is itself invalid in TRE, which is exactly
|
|
90
|
+
# the kind of nested-escaping bug that hides until a "%B %d, %Y" shows up.
|
|
91
|
+
out <- paste0(out, if (ch == " ") "[[:space:]]+"
|
|
92
|
+
else if (ch %in% REGEX_META) paste0("\\", ch)
|
|
93
|
+
else ch)
|
|
94
|
+
rest <- substring(rest, 2L)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
paste0("^", out, "$")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#' Does this format explain every value in `x`?
|
|
101
|
+
#'
|
|
102
|
+
#' All-or-nothing by design: a format that parses 97% of a column is not the
|
|
103
|
+
#' column's format, it is a coincidence plus three rows of damage.
|
|
104
|
+
#'
|
|
105
|
+
#' @param x Character vector, already stripped of NA tokens.
|
|
106
|
+
#' @param fmt strptime format.
|
|
107
|
+
#' @param kind "Date", "POSIXct" or "time".
|
|
108
|
+
#' @param tz Timezone used for the POSIXct trial parse.
|
|
109
|
+
#' @return TRUE when the shape matches everywhere AND every value parses.
|
|
110
|
+
format_explains <- function(x, fmt, kind = "Date", tz = "UTC") {
|
|
111
|
+
if (!length(x)) return(FALSE)
|
|
112
|
+
rx <- format_regex(fmt)
|
|
113
|
+
if (is.na(rx)) return(FALSE)
|
|
114
|
+
if (!all(grepl(rx, x, perl = TRUE))) return(FALSE)
|
|
115
|
+
parsed <- tryCatch({
|
|
116
|
+
if (identical(kind, "Date")) as.Date(x, format = fmt)
|
|
117
|
+
else as.POSIXct(strptime(x, format = fmt, tz = tz), tz = tz)
|
|
118
|
+
}, error = function(e) NULL, warning = function(w) NULL)
|
|
119
|
+
!is.null(parsed) && !anyNA(parsed)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# ── the format battery ──────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
DATE_FORMATS <- c(
|
|
125
|
+
# ISO and other year-first shapes: unambiguous, so they go first.
|
|
126
|
+
"%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y%m%d", "%Y-%j",
|
|
127
|
+
# Day-first and month-first are listed as a PAIR; when both survive the
|
|
128
|
+
# column is genuinely ambiguous and the caller is told so.
|
|
129
|
+
"%d/%m/%Y", "%m/%d/%Y",
|
|
130
|
+
"%d-%m-%Y", "%m-%d-%Y",
|
|
131
|
+
"%d.%m.%Y", "%m.%d.%Y",
|
|
132
|
+
"%d/%m/%y", "%m/%d/%y", "%y-%m-%d", "%y/%m/%d",
|
|
133
|
+
# Month names — unambiguous whichever side they sit on.
|
|
134
|
+
"%d %b %Y", "%d %B %Y", "%b %d %Y", "%B %d %Y",
|
|
135
|
+
"%b %d, %Y", "%B %d, %Y", "%d-%b-%Y", "%d-%b-%y", "%b-%Y", "%B %Y"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
TIME_SUFFIXES <- c(
|
|
139
|
+
" %H:%M:%S", "T%H:%M:%S", " %H:%M", "T%H:%M",
|
|
140
|
+
" %H:%M:%OS", "T%H:%M:%OS",
|
|
141
|
+
" %H:%M:%SZ", "T%H:%M:%SZ", "T%H:%M:%OSZ",
|
|
142
|
+
" %H:%M:%S%z", "T%H:%M:%S%z", "T%H:%M:%OS%z",
|
|
143
|
+
" %H:%M:%S %Z", " %I:%M %p", " %I:%M:%S %p"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
TIME_ONLY_FORMATS <- c("%H:%M:%S", "%H:%M", "%H:%M:%OS", "%I:%M %p", "%I:%M:%S %p")
|
|
147
|
+
|
|
148
|
+
#' Every datetime format worth trying, cheapest-shape-first.
|
|
149
|
+
#' The cross product is large, so callers shape-gate on ONE value before
|
|
150
|
+
#' spending an all-or-nothing pass over the sample.
|
|
151
|
+
datetime_formats <- function() {
|
|
152
|
+
as.vector(t(outer(DATE_FORMATS, TIME_SUFFIXES, paste0)))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#' The pair a day/month ambiguity is made of, if this format is half of one.
|
|
156
|
+
#' @return The other format, or NA when the format cannot be ambiguous.
|
|
157
|
+
ambiguous_twin <- function(fmt) {
|
|
158
|
+
pairs <- c("%d/%m/%Y" = "%m/%d/%Y", "%m/%d/%Y" = "%d/%m/%Y",
|
|
159
|
+
"%d-%m-%Y" = "%m-%d-%Y", "%m-%d-%Y" = "%d-%m-%Y",
|
|
160
|
+
"%d.%m.%Y" = "%m.%d.%Y", "%m.%d.%Y" = "%d.%m.%Y",
|
|
161
|
+
"%d/%m/%y" = "%m/%d/%y", "%m/%d/%y" = "%d/%m/%y")
|
|
162
|
+
base_fmt <- sub("[ T].*$", "", fmt)
|
|
163
|
+
# match(), not `[[`: a named character vector THROWS on a missing name
|
|
164
|
+
# rather than returning NULL, so `%||%` never gets the chance to catch it.
|
|
165
|
+
idx <- match(base_fmt, names(pairs))
|
|
166
|
+
if (is.na(idx)) return(NA_character_)
|
|
167
|
+
twin <- unname(pairs[idx])
|
|
168
|
+
sub(base_fmt, twin, fmt, fixed = TRUE)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# ── numbers ─────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
#' Strip the decoration real spreadsheets put on numbers.
|
|
174
|
+
#'
|
|
175
|
+
#' @param x Character vector.
|
|
176
|
+
#' @param decimal "." or "," — the mark that survives; the other is grouping.
|
|
177
|
+
#' @return Character vector ready for as.numeric().
|
|
178
|
+
undecorate_number <- function(x, decimal = ".") {
|
|
179
|
+
out <- trimws(x)
|
|
180
|
+
out <- gsub("^[(]([^)]*)[)]$", "-\\1", out) # (1 234) accounting negative
|
|
181
|
+
out <- gsub("[ [:space:]]", "", out) # thin/no-break spaces
|
|
182
|
+
out <- gsub("[$€£¥%]", "", out) # currency and percent
|
|
183
|
+
if (identical(decimal, ",")) {
|
|
184
|
+
out <- gsub(".", "", out, fixed = TRUE)
|
|
185
|
+
out <- gsub(",", ".", out, fixed = TRUE)
|
|
186
|
+
} else {
|
|
187
|
+
out <- gsub(",", "", out, fixed = TRUE)
|
|
188
|
+
}
|
|
189
|
+
out
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#' Is every value a number once the decoration is removed?
|
|
193
|
+
#' @return list(ok, decimal, decorated) — `decorated` says the plain read
|
|
194
|
+
#' would have failed, which is what the generated code has to handle.
|
|
195
|
+
numeric_reading <- function(x) {
|
|
196
|
+
plain <- suppressWarnings(as.numeric(x))
|
|
197
|
+
if (!anyNA(plain)) return(list(ok = TRUE, decimal = ".", decorated = FALSE))
|
|
198
|
+
try_mark <- function(mark) {
|
|
199
|
+
v <- suppressWarnings(as.numeric(undecorate_number(x, mark)))
|
|
200
|
+
!anyNA(v)
|
|
201
|
+
}
|
|
202
|
+
# Comma-decimal is tried first only when a comma is actually present as the
|
|
203
|
+
# LAST separator — otherwise "1,234" (a US thousand) would read as 1.234.
|
|
204
|
+
comma_decimal <- all(grepl("^[^,]*,[0-9]+$", trimws(x)))
|
|
205
|
+
if (comma_decimal && try_mark(",")) return(list(ok = TRUE, decimal = ",", decorated = TRUE))
|
|
206
|
+
if (try_mark(".")) return(list(ok = TRUE, decimal = ".", decorated = TRUE))
|
|
207
|
+
if (try_mark(",")) return(list(ok = TRUE, decimal = ",", decorated = TRUE))
|
|
208
|
+
list(ok = FALSE, decimal = ".", decorated = FALSE)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# ── one column ──────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
#' Guess what a column of raw text actually is.
|
|
214
|
+
#'
|
|
215
|
+
#' @param x Character vector as read from the file (NA tokens still present).
|
|
216
|
+
#' @param name The column's name, used only for date-ish hints on numbers.
|
|
217
|
+
#' @param na_strings Tokens treated as missing.
|
|
218
|
+
#' @param tz Timezone assumed for naive timestamps.
|
|
219
|
+
#' @return A list describing the column: `type`, `format`, `tz`, `decimal`,
|
|
220
|
+
#' `ambiguous`, `alternatives`, counts and examples. Every field the wizard
|
|
221
|
+
#' shows and every field the code generator needs.
|
|
222
|
+
guess_column <- function(x, name = "", na_strings = NA_TOKENS, tz = "UTC") {
|
|
223
|
+
x <- as.character(x)
|
|
224
|
+
n_total <- length(x)
|
|
225
|
+
is_na <- is.na(x) | trimws(x) %in% na_strings
|
|
226
|
+
seen_na <- unique(trimws(x[is_na & !is.na(x)]))
|
|
227
|
+
xs <- trimws(x[!is_na])
|
|
228
|
+
if (length(xs) > MAX_SNIFF_VALUES) xs <- xs[seq_len(MAX_SNIFF_VALUES)]
|
|
229
|
+
|
|
230
|
+
out <- list(
|
|
231
|
+
name = name, type = "character", format = NULL, tz = NULL, decimal = NULL,
|
|
232
|
+
decorated = FALSE, strip = FALSE, pin = FALSE, ambiguous = FALSE,
|
|
233
|
+
alternatives = I(list()),
|
|
234
|
+
n_total = n_total, n_missing = sum(is_na), n_checked = length(xs),
|
|
235
|
+
n_distinct = length(unique(xs)),
|
|
236
|
+
# I(): jsonlite's auto_unbox turns a length-1 vector into a SCALAR, and a
|
|
237
|
+
# client doing `examples.join(...)` then gets a string and throws. The
|
|
238
|
+
# worker already learned this once (emit_dataframe marks columns/types
|
|
239
|
+
# the same way). Every field the wire promises as an array is marked, so
|
|
240
|
+
# the promise holds at length 0, 1 and many.
|
|
241
|
+
na_tokens = I(as.character(sort(seen_na))),
|
|
242
|
+
examples = I(as.character(utils::head(unique(xs), MAX_EXAMPLES))),
|
|
243
|
+
note = NULL
|
|
244
|
+
)
|
|
245
|
+
if (!length(xs)) {
|
|
246
|
+
out$note <- "every value is missing"
|
|
247
|
+
return(as_wire_column(out))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
# 1. logical — spelled words only. 0/1 is a number until someone says
|
|
251
|
+
# otherwise; silently turning a count into TRUE/FALSE is not a guess a
|
|
252
|
+
# reader can undo.
|
|
253
|
+
if (all(xs %in% c(LOGICAL_TRUE, LOGICAL_FALSE))) {
|
|
254
|
+
out$type <- "logical"
|
|
255
|
+
return(as_wire_column(out))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
# 2. identifiers that merely LOOK numeric. Two ways a number destroys an id,
|
|
259
|
+
# both silent and both common:
|
|
260
|
+
# * leading zeros carry meaning — 007, postal codes, gene ids, Finnish
|
|
261
|
+
# municipality codes — and reading them as numbers deletes them;
|
|
262
|
+
# * a digit string longer than a double holds exactly (2^53) comes back
|
|
263
|
+
# rounded, so the last digits of a long accession or account number
|
|
264
|
+
# quietly change.
|
|
265
|
+
# Both stay text, with the numeric reading offered rather than taken.
|
|
266
|
+
digits_only <- gsub("[^0-9]", "", xs)
|
|
267
|
+
looks_id <- any(grepl("^[+-]?0[0-9]", xs)) || any(nchar(digits_only) > 15L)
|
|
268
|
+
if (looks_id && !anyNA(suppressWarnings(as.numeric(xs)))) {
|
|
269
|
+
out$type <- "character"
|
|
270
|
+
# A STRUCTURED flag, not a sentence. The code generator has to decide
|
|
271
|
+
# whether to pin this column's class, and deciding it by matching the
|
|
272
|
+
# wording of a human-readable note is how "007" quietly became 7 again.
|
|
273
|
+
out$pin <- TRUE
|
|
274
|
+
out$note <- if (any(grepl("^[+-]?0[0-9]", xs))) {
|
|
275
|
+
"leading zeros — kept as text, because reading it as a number deletes them"
|
|
276
|
+
} else {
|
|
277
|
+
"too many digits for a number to hold exactly — kept as text"
|
|
278
|
+
}
|
|
279
|
+
out$alternatives <- c(out$alternatives, list(list(
|
|
280
|
+
type = "numeric", label = "number (drops leading zeros)", suggested = FALSE)))
|
|
281
|
+
return(as_wire_column(out))
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
# 3. numbers, including the decorated ones a plain read would drop.
|
|
285
|
+
num <- numeric_reading(xs)
|
|
286
|
+
if (num$ok) {
|
|
287
|
+
values <- suppressWarnings(as.numeric(undecorate_number(xs, num$decimal)))
|
|
288
|
+
whole <- all(values == floor(values)) && all(abs(values) <= .Machine$integer.max)
|
|
289
|
+
out$type <- if (whole && !any(grepl("[.]", xs))) "integer" else "numeric"
|
|
290
|
+
out$decimal <- num$decimal
|
|
291
|
+
out$decorated <- num$decorated
|
|
292
|
+
# Does `dec = ","` alone rescue this column, or does it need characters
|
|
293
|
+
# stripped out? The distinction decides whether the generated code is one
|
|
294
|
+
# clean read argument or a visible repair step, so it is settled here
|
|
295
|
+
# rather than guessed by the code generator.
|
|
296
|
+
out$strip <- num$decorated && anyNA(suppressWarnings(as.numeric(
|
|
297
|
+
if (identical(num$decimal, ",")) sub(",", ".", xs, fixed = TRUE) else xs)))
|
|
298
|
+
# A number that is really a date, offered but never imposed: Excel ships
|
|
299
|
+
# dates as days since 1899-12-30 and the column looks like 45000.
|
|
300
|
+
date_ish <- grepl("date|day|dob|birth|time|stamp", name, ignore.case = TRUE)
|
|
301
|
+
if (whole && all(values >= 1) && all(values <= 80000)) {
|
|
302
|
+
out$alternatives <- c(out$alternatives, list(list(
|
|
303
|
+
type = "excel_date", label = "Excel serial date (days since 1899-12-30)",
|
|
304
|
+
suggested = date_ish)))
|
|
305
|
+
}
|
|
306
|
+
if (all(values >= 5e8) && all(values <= 4e9)) {
|
|
307
|
+
out$alternatives <- c(out$alternatives, list(list(
|
|
308
|
+
type = "epoch_seconds", label = "Unix time (seconds since 1970-01-01)",
|
|
309
|
+
suggested = date_ish)))
|
|
310
|
+
}
|
|
311
|
+
if (all(values >= 5e11) && all(values <= 4e12)) {
|
|
312
|
+
out$alternatives <- c(out$alternatives, list(list(
|
|
313
|
+
type = "epoch_millis", label = "Unix time (milliseconds since 1970-01-01)",
|
|
314
|
+
suggested = date_ish)))
|
|
315
|
+
}
|
|
316
|
+
return(as_wire_column(out))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
# 4. dates and timestamps. Shape-gate on one value, then demand the format
|
|
320
|
+
# explain the whole sample.
|
|
321
|
+
probe <- xs[1L]
|
|
322
|
+
shape_ok <- function(fmt) {
|
|
323
|
+
rx <- format_regex(fmt)
|
|
324
|
+
!is.na(rx) && grepl(rx, probe, perl = TRUE)
|
|
325
|
+
}
|
|
326
|
+
accept <- function(fmt, kind) {
|
|
327
|
+
out$type <<- kind
|
|
328
|
+
out$format <<- fmt
|
|
329
|
+
if (identical(kind, "POSIXct")) {
|
|
330
|
+
# A naive timestamp has no timezone in it, so one is CHOSEN here and
|
|
331
|
+
# written into the generated code where the reader can see it. R's
|
|
332
|
+
# default (tz = "") silently means "this machine, today" — the same file
|
|
333
|
+
# then reads differently in Helsinki and in Boston.
|
|
334
|
+
out$tz <<- if (grepl("%z|Z$", fmt)) "UTC" else tz
|
|
335
|
+
}
|
|
336
|
+
twin <- ambiguous_twin(fmt)
|
|
337
|
+
if (!is.na(twin) && format_explains(xs, twin, kind, tz)) {
|
|
338
|
+
out$ambiguous <<- TRUE
|
|
339
|
+
out$alternatives <<- c(out$alternatives, list(list(
|
|
340
|
+
type = kind, format = twin, label = describe_format(twin), suggested = FALSE)))
|
|
341
|
+
out$note <<- sprintf(
|
|
342
|
+
"Ambiguous: every value fits both %s and %s. No value has a day above 12, so the file itself cannot settle it — choose.",
|
|
343
|
+
describe_format(fmt), describe_format(twin))
|
|
344
|
+
}
|
|
345
|
+
as_wire_column(out)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
dt_candidates <- Filter(shape_ok, datetime_formats())
|
|
349
|
+
hit <- Find(function(f) format_explains(xs, f, "POSIXct", tz), dt_candidates)
|
|
350
|
+
if (!is.null(hit)) return(accept(hit, "POSIXct"))
|
|
351
|
+
|
|
352
|
+
d_candidates <- Filter(shape_ok, DATE_FORMATS)
|
|
353
|
+
hit <- Find(function(f) format_explains(xs, f, "Date", tz), d_candidates)
|
|
354
|
+
if (!is.null(hit)) return(accept(hit, "Date"))
|
|
355
|
+
|
|
356
|
+
t_candidates <- Filter(shape_ok, TIME_ONLY_FORMATS)
|
|
357
|
+
hit <- Find(function(f) format_explains(xs, f, "POSIXct", tz), t_candidates)
|
|
358
|
+
if (!is.null(hit)) {
|
|
359
|
+
out$type <- "time"
|
|
360
|
+
out$format <- hit
|
|
361
|
+
out$note <- "clock time with no date — read as a duration since midnight"
|
|
362
|
+
return(as_wire_column(out))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
# 5. character. A short, repeating vocabulary is offered as a factor, never
|
|
366
|
+
# imposed: stringsAsFactors bit a generation of R users precisely because
|
|
367
|
+
# it was the default rather than a decision.
|
|
368
|
+
if (out$n_distinct <= 25L && length(xs) >= 20L && out$n_distinct / length(xs) < 0.5) {
|
|
369
|
+
out$alternatives <- c(out$alternatives, list(list(
|
|
370
|
+
type = "factor", label = sprintf("factor (%d levels)", out$n_distinct),
|
|
371
|
+
suggested = FALSE)))
|
|
372
|
+
}
|
|
373
|
+
as_wire_column(out)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#' Re-assert the array shape of every list field after the appends above.
|
|
377
|
+
#'
|
|
378
|
+
#' `c(x, list(y))` drops the AsIs class, so marking `alternatives` once at
|
|
379
|
+
#' construction is not enough — it has to be re-marked at every exit.
|
|
380
|
+
as_wire_column <- function(col) {
|
|
381
|
+
col$alternatives <- I(unname(as.list(col$alternatives)))
|
|
382
|
+
col$examples <- I(as.character(col$examples))
|
|
383
|
+
col$na_tokens <- I(as.character(col$na_tokens))
|
|
384
|
+
col
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#' A format string, in words — the wizard shows this, not "%d/%m/%Y".
|
|
388
|
+
describe_format <- function(fmt) {
|
|
389
|
+
if (is.null(fmt) || is.na(fmt)) return("")
|
|
390
|
+
words <- fmt
|
|
391
|
+
subs <- c("%Y" = "YYYY", "%y" = "YY", "%m" = "MM", "%d" = "DD", "%e" = "D",
|
|
392
|
+
"%H" = "hh", "%I" = "hh", "%M" = "mm", "%S" = "ss", "%OS" = "ss.s",
|
|
393
|
+
"%b" = "Mon", "%B" = "Month", "%p" = "AM/PM", "%z" = "+ZZZZ",
|
|
394
|
+
"%Z" = "TZ", "%j" = "DDD")
|
|
395
|
+
keys <- names(subs)[order(-nchar(names(subs)))]
|
|
396
|
+
Reduce(function(acc, k) gsub(k, subs[[k]], acc, fixed = TRUE), keys, words)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
# ── the file ────────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
#' Encoding, by evidence rather than hope.
|
|
402
|
+
#' @return list(encoding, bom) — `bom` is the byte count to skip.
|
|
403
|
+
sniff_encoding <- function(path) {
|
|
404
|
+
raw_head <- readBin(path, "raw", n = 65536L)
|
|
405
|
+
if (length(raw_head) >= 3L && identical(as.integer(raw_head[1:3]), c(239L, 187L, 191L))) {
|
|
406
|
+
return(list(encoding = "UTF-8", bom = 3L))
|
|
407
|
+
}
|
|
408
|
+
if (length(raw_head) >= 2L && identical(as.integer(raw_head[1:2]), c(255L, 254L))) {
|
|
409
|
+
return(list(encoding = "UTF-16LE", bom = 2L))
|
|
410
|
+
}
|
|
411
|
+
if (length(raw_head) >= 2L && identical(as.integer(raw_head[1:2]), c(254L, 255L))) {
|
|
412
|
+
return(list(encoding = "UTF-16BE", bom = 2L))
|
|
413
|
+
}
|
|
414
|
+
txt <- tryCatch(rawToChar(raw_head), error = function(e) "")
|
|
415
|
+
Encoding(txt) <- "UTF-8"
|
|
416
|
+
# validUTF8 on the whole blob: a single invalid byte means this is not UTF-8,
|
|
417
|
+
# and latin1 is the overwhelmingly likely alternative for CSVs in the wild.
|
|
418
|
+
list(encoding = if (validUTF8(txt)) "UTF-8" else "latin1", bom = 0L)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
#' Which delimiter makes this file rectangular?
|
|
422
|
+
#'
|
|
423
|
+
#' Scored on CONSISTENCY, not frequency: the right delimiter is the one that
|
|
424
|
+
#' gives every line the same field count, which is a property a comma inside
|
|
425
|
+
#' quoted prose cannot fake. count.fields does the quote-aware counting — the
|
|
426
|
+
#' same parser read.table itself uses, so agreement is guaranteed.
|
|
427
|
+
#'
|
|
428
|
+
#' @return list(delim, name, fields, confidence)
|
|
429
|
+
sniff_delimiter <- function(lines, quote = "\"") {
|
|
430
|
+
score_one <- function(d) {
|
|
431
|
+
counts <- tryCatch(
|
|
432
|
+
utils::count.fields(textConnection(lines), sep = d$char, quote = quote,
|
|
433
|
+
blank.lines.skip = TRUE, comment.char = ""),
|
|
434
|
+
error = function(e) NULL, warning = function(w) NULL)
|
|
435
|
+
if (is.null(counts) || !length(counts)) return(NULL)
|
|
436
|
+
modal <- as.integer(names(sort(table(counts), decreasing = TRUE))[1L])
|
|
437
|
+
if (is.na(modal) || modal < 2L) return(NULL)
|
|
438
|
+
list(delim = d$char, name = d$name, fields = modal,
|
|
439
|
+
confidence = mean(counts == modal))
|
|
440
|
+
}
|
|
441
|
+
scored <- Filter(Negate(is.null), lapply(DELIMITERS, score_one))
|
|
442
|
+
if (!length(scored)) {
|
|
443
|
+
return(list(delim = ",", name = "comma", fields = 1L, confidence = 0))
|
|
444
|
+
}
|
|
445
|
+
# More columns breaks a tie between two perfectly consistent delimiters:
|
|
446
|
+
# a semicolon file also reads "consistently" as one comma-free column.
|
|
447
|
+
best <- scored[[which.max(vapply(scored, function(s)
|
|
448
|
+
s$confidence * 1000 + min(s$fields, 50L), numeric(1)))]]
|
|
449
|
+
best
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#' Does the first row name the columns, or is it data?
|
|
453
|
+
#'
|
|
454
|
+
#' The test that actually discriminates: a header row is all text, while the
|
|
455
|
+
#' body has at least one field that is NOT text. A file that is character
|
|
456
|
+
#' throughout falls back to "distinct, non-empty, no leading digits" — weaker,
|
|
457
|
+
#' and reported as such so the wizard can show it as a guess.
|
|
458
|
+
sniff_header <- function(first, rest) {
|
|
459
|
+
if (!length(first)) return(TRUE)
|
|
460
|
+
looks_data <- function(v) {
|
|
461
|
+
v <- trimws(v[!is.na(v) & nzchar(trimws(v))])
|
|
462
|
+
if (!length(v)) return(FALSE)
|
|
463
|
+
num <- suppressWarnings(as.numeric(undecorate_number(v)))
|
|
464
|
+
any(!is.na(num)) || any(vapply(DATE_FORMATS, function(f)
|
|
465
|
+
format_explains(v, f, "Date"), logical(1)))
|
|
466
|
+
}
|
|
467
|
+
if (!length(rest) || !nrow(rest)) return(!looks_data(first))
|
|
468
|
+
body_typed <- any(vapply(seq_along(first), function(i)
|
|
469
|
+
looks_data(rest[, i]), logical(1)))
|
|
470
|
+
if (looks_data(first)) return(FALSE)
|
|
471
|
+
if (body_typed) return(TRUE)
|
|
472
|
+
all(nzchar(trimws(first))) && !anyDuplicated(first)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
#' Everything the wizard needs to describe a delimited file.
|
|
476
|
+
#' @return A list: format, settings, columns, preview, and how it was decided.
|
|
477
|
+
sniff_delimited <- function(path, opts = list()) {
|
|
478
|
+
enc <- sniff_encoding(path)
|
|
479
|
+
encoding <- opts$encoding %||% enc$encoding
|
|
480
|
+
con <- file(path, open = "r", encoding = encoding)
|
|
481
|
+
on.exit(close(con), add = TRUE)
|
|
482
|
+
lines <- readLines(con, n = MAX_SNIFF_LINES, warn = FALSE)
|
|
483
|
+
if (enc$bom > 0L && length(lines)) {
|
|
484
|
+
lines[1L] <- sub("^", "", lines[1L])
|
|
485
|
+
}
|
|
486
|
+
lines <- lines[nzchar(lines)]
|
|
487
|
+
if (!length(lines)) {
|
|
488
|
+
# A second attempt with no declared encoding, because a wrong guess must
|
|
489
|
+
# not be the reason a perfectly good file "has no lines". Then, if it is
|
|
490
|
+
# still empty, say WHICH kind of empty — a zero-byte file and a cloud
|
|
491
|
+
# placeholder that has not downloaded yet are different problems.
|
|
492
|
+
con2 <- file(path, open = "r")
|
|
493
|
+
lines <- tryCatch(readLines(con2, n = MAX_SNIFF_LINES, warn = FALSE),
|
|
494
|
+
error = function(e) character(0))
|
|
495
|
+
close(con2)
|
|
496
|
+
lines <- lines[nzchar(lines)]
|
|
497
|
+
}
|
|
498
|
+
if (!length(lines)) {
|
|
499
|
+
size <- as.numeric(file.info(path)$size)
|
|
500
|
+
stop(if (is.na(size) || size == 0) "the file is empty (0 bytes)"
|
|
501
|
+
else sprintf("no readable text in %.0f KB - if this lives in iCloud or Google Drive it may not be downloaded yet",
|
|
502
|
+
size / 1024))
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
# Leading commentary — the "# exported from ..." banner spreadsheets add.
|
|
506
|
+
comment_lines <- which(grepl("^\\s*#", lines))
|
|
507
|
+
skip <- if (length(comment_lines) && all(comment_lines == seq_along(comment_lines))) {
|
|
508
|
+
length(comment_lines)
|
|
509
|
+
} else 0L
|
|
510
|
+
skip <- opts$skip %||% skip
|
|
511
|
+
body <- if (skip > 0L) lines[-seq_len(skip)] else lines
|
|
512
|
+
|
|
513
|
+
quote <- opts$quote %||% "\""
|
|
514
|
+
det <- if (!is.null(opts$delim)) {
|
|
515
|
+
list(delim = opts$delim, name = "chosen", fields = NA_integer_, confidence = 1)
|
|
516
|
+
} else sniff_delimiter(body, quote)
|
|
517
|
+
|
|
518
|
+
raw <- utils::read.table(
|
|
519
|
+
text = paste(body, collapse = "\n"), sep = det$delim, quote = quote,
|
|
520
|
+
header = FALSE, colClasses = "character", stringsAsFactors = FALSE,
|
|
521
|
+
na.strings = character(0), comment.char = "", check.names = FALSE,
|
|
522
|
+
fill = TRUE, blank.lines.skip = TRUE)
|
|
523
|
+
|
|
524
|
+
header <- opts$header %||% sniff_header(as.character(unlist(raw[1L, ])),
|
|
525
|
+
if (nrow(raw) > 1L) raw[-1L, , drop = FALSE] else raw[0, , drop = FALSE])
|
|
526
|
+
if (isTRUE(header)) {
|
|
527
|
+
nms <- as.character(unlist(raw[1L, ]))
|
|
528
|
+
data <- raw[-1L, , drop = FALSE]
|
|
529
|
+
} else {
|
|
530
|
+
nms <- paste0("V", seq_len(ncol(raw)))
|
|
531
|
+
data <- raw
|
|
532
|
+
}
|
|
533
|
+
nms[is.na(nms) | !nzchar(trimws(nms))] <- ""
|
|
534
|
+
blank <- !nzchar(nms)
|
|
535
|
+
nms[blank] <- paste0("V", which(blank))
|
|
536
|
+
|
|
537
|
+
na_strings <- opts$naStrings %||% NA_TOKENS
|
|
538
|
+
tz <- opts$tz %||% "UTC"
|
|
539
|
+
keep <- seq_len(min(ncol(data), MAX_PREVIEW_COLS))
|
|
540
|
+
cols <- lapply(keep, function(i)
|
|
541
|
+
guess_column(data[[i]], nms[i], na_strings = na_strings, tz = tz))
|
|
542
|
+
|
|
543
|
+
list(
|
|
544
|
+
format = "delimited",
|
|
545
|
+
settings = list(
|
|
546
|
+
delim = det$delim, delimName = det$name, quote = quote,
|
|
547
|
+
encoding = encoding, header = isTRUE(header), skip = skip,
|
|
548
|
+
naStrings = I(as.character(na_strings)), tz = tz,
|
|
549
|
+
# What DETECTION used, and what the generated code should say. Keeping
|
|
550
|
+
# them apart stops a re-sniff from narrowing its own vocabulary.
|
|
551
|
+
naObserved = I(observed_na_tokens(cols)),
|
|
552
|
+
confidence = det$confidence
|
|
553
|
+
),
|
|
554
|
+
columns = I(unname(cols)),
|
|
555
|
+
names = I(as.character(nms[keep])),
|
|
556
|
+
preview = I(preview_rows(data[keep], nms[keep])),
|
|
557
|
+
nrow = nrow(data), ncol = ncol(data),
|
|
558
|
+
# `nrow` is what was SAMPLED. A 400 KB file sampled at 500 lines was
|
|
559
|
+
# reporting "499 rows" in the badge, which is a lie about the file rather
|
|
560
|
+
# than a description of the sample — so the true count travels with it.
|
|
561
|
+
totalRows = count_data_rows(path, header = isTRUE(header), skip = skip),
|
|
562
|
+
truncated = length(lines) >= MAX_SNIFF_LINES
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
#' How many data rows the file actually has.
|
|
567
|
+
#'
|
|
568
|
+
#' Counted in chunks so a large file costs a scan and not its size in memory,
|
|
569
|
+
#' and capped so a pathological one cannot become an unbounded wait. NA means
|
|
570
|
+
#' "more than the cap", which the caller renders as "500,000+".
|
|
571
|
+
#'
|
|
572
|
+
#' @return Integer row count excluding the header, or NA past the cap.
|
|
573
|
+
COUNT_ROW_CAP <- 5e6
|
|
574
|
+
count_data_rows <- function(path, header = TRUE, skip = 0L) {
|
|
575
|
+
con <- file(path, open = "r")
|
|
576
|
+
on.exit(close(con), add = TRUE)
|
|
577
|
+
n <- 0
|
|
578
|
+
repeat {
|
|
579
|
+
chunk <- tryCatch(readLines(con, n = 50000L, warn = FALSE),
|
|
580
|
+
error = function(e) character(0))
|
|
581
|
+
if (!length(chunk)) break
|
|
582
|
+
n <- n + sum(nzchar(chunk))
|
|
583
|
+
if (n > COUNT_ROW_CAP) return(NA_integer_)
|
|
584
|
+
}
|
|
585
|
+
as.integer(max(0, n - skip - (if (isTRUE(header)) 1L else 0L)))
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
#' The first rows, as plain strings, for the wizard\'s grid.
|
|
589
|
+
preview_rows <- function(data, nms) {
|
|
590
|
+
if (!ncol(data)) return(list())
|
|
591
|
+
head_df <- utils::head(data, MAX_PREVIEW_ROWS)
|
|
592
|
+
lapply(seq_len(nrow(head_df)), function(i)
|
|
593
|
+
as.list(stats::setNames(as.character(unlist(head_df[i, ], use.names = FALSE)), nms)))
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
#' An Excel workbook: which sheets it has, and what the chosen one holds.
|
|
597
|
+
sniff_excel <- function(path, opts = list()) {
|
|
598
|
+
if (!requireNamespace("readxl", quietly = TRUE)) {
|
|
599
|
+
stop("readxl is not installed - install.packages(\"readxl\") to import Excel files")
|
|
600
|
+
}
|
|
601
|
+
sheets <- readxl::excel_sheets(path)
|
|
602
|
+
sheet <- opts$sheet %||% sheets[1L]
|
|
603
|
+
skip <- opts$skip %||% 0L
|
|
604
|
+
# col_types = "text" is the same discipline as colClasses = "character":
|
|
605
|
+
# readxl's own guessing is what turns a mixed column into NAs.
|
|
606
|
+
raw <- as.data.frame(readxl::read_excel(path, sheet = sheet, skip = skip,
|
|
607
|
+
col_types = "text", .name_repair = "minimal"),
|
|
608
|
+
stringsAsFactors = FALSE)
|
|
609
|
+
nms <- names(raw)
|
|
610
|
+
nms[is.na(nms) | !nzchar(trimws(nms))] <- ""
|
|
611
|
+
blank <- !nzchar(nms)
|
|
612
|
+
nms[blank] <- paste0("V", which(blank))
|
|
613
|
+
na_strings <- opts$naStrings %||% NA_TOKENS
|
|
614
|
+
tz <- opts$tz %||% "UTC"
|
|
615
|
+
keep <- seq_len(min(ncol(raw), MAX_PREVIEW_COLS))
|
|
616
|
+
cols <- lapply(keep, function(i)
|
|
617
|
+
guess_column(raw[[i]], nms[i], na_strings = na_strings, tz = tz))
|
|
618
|
+
list(
|
|
619
|
+
format = "excel",
|
|
620
|
+
settings = list(sheet = sheet, sheets = as.list(sheets), skip = skip,
|
|
621
|
+
naStrings = I(as.character(na_strings)), tz = tz, header = TRUE),
|
|
622
|
+
columns = I(unname(cols)),
|
|
623
|
+
names = I(as.character(nms[keep])),
|
|
624
|
+
preview = I(preview_rows(raw[keep], nms[keep])),
|
|
625
|
+
nrow = nrow(raw), ncol = ncol(raw), truncated = FALSE
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
#' A serialized R object — nothing to detect, everything already typed.
|
|
630
|
+
sniff_native <- function(path, format) {
|
|
631
|
+
list(format = format, settings = list(), columns = I(list()), names = I(character(0)),
|
|
632
|
+
preview = I(list()), nrow = NA_integer_, ncol = NA_integer_,
|
|
633
|
+
truncated = FALSE,
|
|
634
|
+
note = "R keeps the column types inside the file - there is nothing to guess.")
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
#' Which reader this file needs, by extension and then by content.
|
|
638
|
+
#'
|
|
639
|
+
#' Extension first because it is right almost always and costs nothing; magic
|
|
640
|
+
#' bytes second because a .txt holding a zip header is an .xlsx someone
|
|
641
|
+
#' renamed, and reading it as text produces line noise rather than an error.
|
|
642
|
+
detect_format <- function(path, name = path) {
|
|
643
|
+
# Named off the SOURCE (a URL keeps its extension; its temp copy may not),
|
|
644
|
+
# sniffed off the bytes actually downloaded.
|
|
645
|
+
ext <- tolower(tools::file_ext(sub("[?#].*$", "", name)))
|
|
646
|
+
magic <- tryCatch(readBin(path, "raw", n = 8L), error = function(e) raw(0))
|
|
647
|
+
is_zip <- length(magic) >= 4L && identical(as.integer(magic[1:2]), c(80L, 75L))
|
|
648
|
+
is_gz <- length(magic) >= 2L && identical(as.integer(magic[1:2]), c(31L, 139L))
|
|
649
|
+
if (ext %in% c("xlsx", "xlsm")) return("excel")
|
|
650
|
+
if (ext == "xls") return("excel")
|
|
651
|
+
if (ext == "rds" || (is_gz && ext == "rds")) return("rds")
|
|
652
|
+
if (ext %in% c("rdata", "rda")) return("rdata")
|
|
653
|
+
if (ext %in% c("sav", "zsav", "por")) return("spss")
|
|
654
|
+
if (ext == "dta") return("stata")
|
|
655
|
+
if (ext %in% c("sas7bdat", "xpt")) return("sas")
|
|
656
|
+
if (ext == "json") return("json")
|
|
657
|
+
if (ext == "parquet") return("parquet")
|
|
658
|
+
if (ext == "fst") return("fst")
|
|
659
|
+
if (is_zip) return("excel")
|
|
660
|
+
"delimited"
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
#' The missing-value tokens this file actually uses.
|
|
664
|
+
#'
|
|
665
|
+
#' The DETECTION vocabulary is deliberately broad — 22 tokens — because a
|
|
666
|
+
#' sniffer should recognise every spelling of "missing" it might meet. Writing
|
|
667
|
+
#' all 22 into the generated code is a different matter: it is a wall of noise
|
|
668
|
+
#' in the one artefact the user has to read, and it is WRONG, because it
|
|
669
|
+
#' silently converts a legitimate "-" or "." value into NA in some future file
|
|
670
|
+
#' the same chunk is pointed at. So the code gets the tokens actually observed,
|
|
671
|
+
#' plus the two nobody argues about.
|
|
672
|
+
#'
|
|
673
|
+
#' @param cols The per-column guesses.
|
|
674
|
+
#' @return Character vector for `na.strings`.
|
|
675
|
+
observed_na_tokens <- function(cols) {
|
|
676
|
+
seen <- unlist(lapply(cols, function(cl) as.character(cl$na_tokens)), use.names = FALSE)
|
|
677
|
+
unique(c("", "NA", sort(seen)))
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
#' Which reader packages this session actually has.
|
|
681
|
+
#'
|
|
682
|
+
#' `system.file()`, not `requireNamespace()`: the question is "is it
|
|
683
|
+
#' installed", and requireNamespace answers it by LOADING the package —
|
|
684
|
+
#' which for arrow or rio is seconds of work and a namespace the user never
|
|
685
|
+
#' asked to load, every time the wizard opens.
|
|
686
|
+
#'
|
|
687
|
+
#' @return Named list of logicals, one per reader package.
|
|
688
|
+
reader_packages <- function() {
|
|
689
|
+
want <- c("rio", "readxl", "haven", "data.table", "arrow", "fst", "jsonlite", "readr")
|
|
690
|
+
as.list(stats::setNames(
|
|
691
|
+
vapply(want, function(p) nzchar(system.file(package = p)), logical(1)), want))
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
# ── remote sources ──────────────────────────────────────────────────────────
|
|
695
|
+
# A URL is a first-class source. Detection needs BYTES, so the file is fetched
|
|
696
|
+
# once to a temp copy and sniffed there — but the generated code keeps the
|
|
697
|
+
# URL, because a chunk that reads a temp path is reproducible for nobody.
|
|
698
|
+
#
|
|
699
|
+
# Cached per session: the wizard re-sniffs on every settings change, and
|
|
700
|
+
# re-downloading a 40 MB CSV each time somebody flips a delimiter is not a
|
|
701
|
+
# design, it is an accident.
|
|
702
|
+
URL_CACHE <- new.env(parent = emptyenv())
|
|
703
|
+
URL_TIMEOUT <- 60
|
|
704
|
+
|
|
705
|
+
is_url <- function(path) {
|
|
706
|
+
is.character(path) && length(path) == 1L &&
|
|
707
|
+
grepl("^(https?|ftps?)://", path, ignore.case = TRUE)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
#' The local bytes for a source, downloading a URL once.
|
|
711
|
+
#' @return list(local, display, remote)
|
|
712
|
+
resolve_source <- function(path) {
|
|
713
|
+
if (!is_url(path)) return(list(local = path.expand(path), display = path, remote = FALSE))
|
|
714
|
+
hit <- URL_CACHE[[path]]
|
|
715
|
+
if (!is.null(hit) && file.exists(hit)) {
|
|
716
|
+
return(list(local = hit, display = path, remote = TRUE))
|
|
717
|
+
}
|
|
718
|
+
# The extension is kept so detect_format() can still read it off the name.
|
|
719
|
+
ext <- tools::file_ext(sub("[?#].*$", "", path))
|
|
720
|
+
dest <- tempfile("carmar-url-", fileext = if (nzchar(ext)) paste0(".", ext) else "")
|
|
721
|
+
old <- options(timeout = URL_TIMEOUT)
|
|
722
|
+
on.exit(options(old), add = TRUE)
|
|
723
|
+
ok <- tryCatch({
|
|
724
|
+
utils::download.file(path, dest, quiet = TRUE, mode = "wb")
|
|
725
|
+
file.exists(dest) && file.info(dest)$size > 0
|
|
726
|
+
}, error = function(e) structure(class = "carmar_fail", list(msg = conditionMessage(e))),
|
|
727
|
+
warning = function(w) structure(class = "carmar_fail", list(msg = conditionMessage(w))))
|
|
728
|
+
if (inherits(ok, "carmar_fail")) stop("could not download: ", ok$msg)
|
|
729
|
+
if (!isTRUE(ok)) stop("could not download that URL")
|
|
730
|
+
assign(path, dest, envir = URL_CACHE)
|
|
731
|
+
list(local = dest, display = path, remote = TRUE)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
#' The whole detection pass for one file.
|
|
735
|
+
#'
|
|
736
|
+
#' @param path File to inspect.
|
|
737
|
+
#' @param opts Overrides from the wizard — any setting the user has changed is
|
|
738
|
+
#' respected and everything else is re-detected around it, which is what
|
|
739
|
+
#' makes the panel feel live rather than one-shot.
|
|
740
|
+
#' @return The description the wizard renders and the code generator consumes.
|
|
741
|
+
sniff_file <- function(path, opts = list()) {
|
|
742
|
+
stopifnot(is.character(path), length(path) == 1L, nzchar(path))
|
|
743
|
+
src <- resolve_source(path)
|
|
744
|
+
local <- src$local
|
|
745
|
+
if (!file.exists(local) || dir.exists(local)) stop("not a readable file")
|
|
746
|
+
fmt <- opts$format %||% detect_format(local, name = src$display)
|
|
747
|
+
out <- switch(fmt,
|
|
748
|
+
excel = sniff_excel(local, opts),
|
|
749
|
+
delimited = sniff_delimited(local, opts),
|
|
750
|
+
rds = sniff_native(path, "rds"),
|
|
751
|
+
rdata = sniff_native(path, "rdata"),
|
|
752
|
+
spss = sniff_native(path, "spss"),
|
|
753
|
+
stata = sniff_native(path, "stata"),
|
|
754
|
+
sas = sniff_native(path, "sas"),
|
|
755
|
+
json = sniff_native(path, "json"),
|
|
756
|
+
parquet = sniff_native(path, "parquet"),
|
|
757
|
+
fst = sniff_native(path, "fst"),
|
|
758
|
+
stop("unsupported file type: ", fmt))
|
|
759
|
+
# The DISPLAY path travels onward — the wizard shows it and the code
|
|
760
|
+
# generator writes it — so a URL import produces a chunk anyone can re-run.
|
|
761
|
+
out$path <- src$display
|
|
762
|
+
out$remote <- src$remote
|
|
763
|
+
out$size <- as.numeric(file.info(local)$size)
|
|
764
|
+
# Reported with every sniff so the wizard can offer only engines this
|
|
765
|
+
# session can actually run — generating rio code for a session without rio
|
|
766
|
+
# is a chunk that fails on the first line.
|
|
767
|
+
out$packages <- reader_packages()
|
|
768
|
+
out
|
|
769
|
+
}
|