Repository navigation
Expand file tree
/
Copy pathapp.R
More file actions
187 lines (162 loc) · 7.74 KB
/
Copy pathapp.R
File metadata and controls
187 lines (162 loc) · 7.74 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# =============================================================================
# Recode Studio - standalone Shiny app
# =============================================================================
# A dataset-agnostic tool for cleaning string variables: load any CSV/Excel,
# pick a text column, normalize its case / whitespace, browse values
# alphabetically or as similarity clusters, spellcheck them, build recode
# rules, and export rule CSVs + an R script.
#
# Order of operations everywhere: normalizations, then recodes, then recat.
#
# Run with:
# shiny::runApp() # from the repo root
# =============================================================================
library(shiny)
library(bslib)
library(DT)
library(dplyr)
library(stringr)
library(readr)
library(tibble)
library(purrr)
# Source all helpers + modules.
for (f in list.files("R", pattern = "\\.R$", full.names = TRUE)) source(f)
# --- UI ----------------------------------------------------------------------
ui <- bslib::page_navbar(
id = "main_nav",
title = "Recode Studio",
theme = bslib::bs_theme(version = 5, preset = "flatly"),
navbar_options = bslib::navbar_options(bg = "#2c3e50", underline = TRUE),
bslib::nav_panel(
title = "Recode Studio",
mod_variable_banner_ui("variable_banner"),
bslib::navset_card_tab(
id = "studio_tabs",
# Normalize sits before the value tabs because that is the order rules
# are APPLIED in (normalize, recode, recat): Browse values, Clusters and
# Spellcheck show normalized values, so recodes are written against them.
bslib::nav_panel("1. Data", value = "data", mod_data_input_ui("data_input")),
bslib::nav_panel("2. Variable", value = "variable", mod_variable_picker_ui("variable_picker")),
bslib::nav_panel("3. Normalize", value = "normalize", mod_normalize_ui("normalize")),
bslib::nav_panel("4. Browse values", value = "browse", mod_value_table_ui("value_table")),
bslib::nav_panel("5. Clusters", value = "clusters", mod_cluster_view_ui("cluster_view")),
bslib::nav_panel("6. Spellcheck", value = "spellcheck", mod_spellcheck_view_ui("spellcheck_view")),
bslib::nav_panel("7. Text analysis", value = "text", mod_text_analysis_ui("text_analysis")),
bslib::nav_panel("8. Recodes", value = "recodes", mod_recode_editor_ui("recode_editor")),
bslib::nav_panel("9. Recategorize", value = "recat", mod_recategorize_ui("recategorize")),
bslib::nav_panel("10. Preview & export", value = "export",
mod_preview_export_ui("preview_export"),
br(),
mod_import_recodes_ui("import_recodes")
)
)
),
bslib::nav_spacer(),
bslib::nav_item(
tags$a("GitHub", href = "https://github.com/amelia-m/recode-studio", target = "_blank",
style = "color:#fff;")
)
)
# --- Server ------------------------------------------------------------------
server <- function(input, output, session) {
# App-wide state. mod_data_input writes data/meta/dataset_name/source_meta;
# everything else reads. `source_meta` describes the loaded file (base name,
# sheet, size, SHA-256, load time; source_file_meta()) for the export
# bundle's provenance record.
shared_state <- shiny::reactiveValues(
data = NULL,
meta = NULL,
dataset_name = NULL,
source_meta = NULL
)
# In-session rule sets + proxies. Recategorization rules live in their OWN
# store: they derive a new column instead of rewriting values, so they share
# neither the schema nor the export path with the old->new recodes.
# Normalizations are a third store: whole-column case / whitespace tidying,
# applied before both of the others.
rv <- shiny::reactiveValues(
rules = empty_recodes_tibble(),
recat = empty_recat_tibble(),
norms = empty_normalizations_tibble()
)
norms_proxy <- make_norms_proxy(rv)
# add() replaces a colliding rule IN PLACE (see R/rule_proxies.R): rules are
# first-match-wins by row order, so moving an edited rule to the end could
# change the output. add() also stamps each new rule's norm_spec with the
# normalization in force for its columns, so a later change is detectable.
rules_proxy <- make_rules_proxy(rv,
norms_r = norms_proxy$get,
data_cols_r = function() names(shared_state$data))
recat_proxy <- make_recat_proxy(rv)
mod_data_input_server("data_input", shared_state)
# The variable being recoded: one shared selection, changed from the table on
# tab 2 or the "Recoding:" banner above the tabs (R/mod_variable_picker.R).
# Every per-variable module reads selected_var_r.
selected_var_r <- variable_selection_server(shared_state,
picker_id = "variable_picker", banner_id = "variable_banner")
mod_normalize_server("normalize", shared_state,
selected_var_r = selected_var_r, norms_proxy = norms_proxy)
# The loaded data with the session's normalizations applied: the frame the
# recodes are written against and run on. Falls back to the raw data if the
# normalizations cannot be applied.
normalized_r <- shiny::reactive({
d <- shared_state$data
if (is.null(d)) return(NULL)
nn <- norms_proxy$get()
if (nrow(nn) == 0) return(d)
tryCatch(apply_normalizations(d, nn)$df, error = function(e) d)
})
# Distinct values of the selected column AFTER normalization, so Browse
# values, Clusters and Spellcheck show (and mint rules against) normalized
# values.
unique_values_r <- shiny::reactive({
v <- selected_var_r()
d <- normalized_r()
if (is.null(v) || is.null(d) || !(v %in% names(d))) return(NULL)
x <- d[[v]]
x <- x[!is.na(x) & nzchar(x)]
if (length(x) == 0) return(NULL)
tibble::tibble(value = x) |>
dplyr::count(value, name = "n") |>
dplyr::arrange(dplyr::desc(n))
})
mod_value_table_server("value_table", shared_state,
selected_var_r = selected_var_r, unique_values_r = unique_values_r,
rules_proxy = rules_proxy)
mod_cluster_view_server("cluster_view", shared_state,
selected_var_r = selected_var_r, unique_values_r = unique_values_r,
rules_proxy = rules_proxy)
mod_spellcheck_view_server("spellcheck_view", shared_state,
selected_var_r = selected_var_r, unique_values_r = unique_values_r,
rules_proxy = rules_proxy)
# Text analysis deliberately reads the RAW values (shared_state$data), not
# normalized_r(): it is descriptive (lengths, tokens, n-grams, keyword in
# context) and should describe the file as delivered.
mod_text_analysis_server("text_analysis", shared_state, selected_var_r)
mod_recode_editor_server("recode_editor", shared_state, rules_proxy)
# The exported recat script is meant to run AFTER the recode script (which
# itself normalizes first), so the Recategorize tab previews against the
# normalized-then-recoded frame, not the raw one. `df_before_recodes` is the
# normalized frame, used to spot rows the recodes took away.
recode_basis_r <- shiny::reactive({
d <- shared_state$data
if (is.null(d)) return(NULL)
r <- rules_proxy$get()
dn <- normalized_r()
n_norms <- nrow(norms_proxy$get())
if (nrow(r) == 0) {
return(list(df = dn, n_rules = 0L, n_norms = n_norms,
df_before_recodes = dn))
}
df <- tryCatch(apply_recodes(dn, r)$df, error = function(e) dn)
list(df = df, n_rules = nrow(r), n_norms = n_norms,
df_before_recodes = dn)
})
mod_recategorize_server("recategorize", shared_state, recat_proxy,
seed_var_r = selected_var_r, recode_basis_r = recode_basis_r)
mod_preview_export_server("preview_export", shared_state, rules_proxy,
recat_proxy = recat_proxy, norms_proxy = norms_proxy)
mod_import_recodes_server("import_recodes", shared_state, rules_proxy,
norms_proxy = norms_proxy)
}
shinyApp(ui, server)