import either strict or allows for normalization

This commit is contained in:
Priec
2026-08-15 20:58:23 +02:00
parent 6c303b5b6f
commit 08f25efb67
16 changed files with 486 additions and 64 deletions

2
Cargo.lock generated
View File

@@ -1200,6 +1200,7 @@ dependencies = [
"tokio", "tokio",
"tokio-test", "tokio-test",
"toml", "toml",
"toml_edit",
"tonic", "tonic",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -8295,6 +8296,7 @@ dependencies = [
"indexmap 2.14.0", "indexmap 2.14.0",
"toml_datetime", "toml_datetime",
"toml_parser", "toml_parser",
"toml_writer",
"winnow 1.0.3", "winnow 1.0.3",
] ]

View File

@@ -50,6 +50,7 @@ regex = "1.12.4"
ratatui = { version = "0.30.1", features = ["crossterm"] } ratatui = { version = "0.30.1", features = ["crossterm"] }
crossterm = "0.29.0" crossterm = "0.29.0"
toml = "1.1.2" toml = "1.1.2"
toml_edit = "0.25.12"
unicode-width = "0.2.2" unicode-width = "0.2.2"
# Fuzzy matching (picker) # Fuzzy matching (picker)

2
client

Submodule client updated: 429600a181...d2b70f94ad

View File

@@ -603,6 +603,13 @@ import-err-first-row = První řádek CSV musí obsahovat jen vybrané názvy ta
import-err-header-lengths = Řádek hlavičky tabulek a sloupců mají různou délku. import-err-header-lengths = Řádek hlavičky tabulek a sloupců mají různou délku.
import-err-row-width = Řádek dat CSV má jiný počet polí než hlavička. import-err-row-width = Řádek dat CSV má jiný počet polí než hlavička.
import-err-no-data-rows = CSV obsahuje hlavičky, ale žádné řádky dat. import-err-no-data-rows = CSV obsahuje hlavičky, ale žádné řádky dat.
import-err-header-padded = Hlavička '{ $header }' má kolem sebe mezery, takže není sloupcem '{ $trimmed }'. Odstraňte je, nebo použijte „Normalizovat hlavičky“.
import-err-duplicate-header = Hlavička '{ $header }' se u téže tabulky vyskytuje dvakrát, takže není jasné, do kterého sloupce hodnoty patří.
import-err-column-near-match = Hlavička '{ $header }' není sloupcem tabulky '{ $table }', ale '{ $column }' ano - liší se jen mezerami nebo velikostí písmen. Opravte hlavičku, nebo použijte „Normalizovat hlavičky“ a výsledek si před importem prohlédněte.
import-normalize = Normalizovat hlavičky
import-normalize-hint = Přepíše řádky hlaviček na názvy sloupců tabulky. Nic se neimportuje - změněné CSV nejprve uvidíte.
import-normalized-message = Hlavičky přepsány. Než CSV naimportujete, přečtěte si ho níže.
import-normalized-unchanged = Hlavičky už tabulce přesně odpovídají; nic se nezměnilo.
import-err-missing-type = Chybí typ sloupce '{ $column }' import-err-missing-type = Chybí typ sloupce '{ $column }'
import-err-permission = Vyžaduje se oprávnění k importu. Tabulka navíc potřebuje oprávnění k vkládání, aby do ní šlo načítat. import-err-permission = Vyžaduje se oprávnění k importu. Tabulka navíc potřebuje oprávnění k vkládání, aby do ní šlo načítat.
import-err-unterminated-quote = CSV obsahuje neuzavřenou uvozovkovou hodnotu. import-err-unterminated-quote = CSV obsahuje neuzavřenou uvozovkovou hodnotu.

View File

@@ -590,6 +590,13 @@ import-err-first-row = The first CSV row must contain only selected table names.
import-err-header-lengths = Table and column header rows have different lengths. import-err-header-lengths = Table and column header rows have different lengths.
import-err-row-width = A CSV data row has a different number of fields than the header. import-err-row-width = A CSV data row has a different number of fields than the header.
import-err-no-data-rows = CSV contains headers but no data rows. import-err-no-data-rows = CSV contains headers but no data rows.
import-err-header-padded = Header '{ $header }' has spaces around it, so it is not the column '{ $trimmed }'. Remove them, or use “Normalize headers”.
import-err-duplicate-header = Header '{ $header }' appears twice for the same table, so it is not clear which column the values belong to.
import-err-column-near-match = Header '{ $header }' is not a column of table '{ $table }', but '{ $column }' is - they differ only in spacing or capitalisation. Fix the header, or use “Normalize headers” to rewrite it and see the result before importing.
import-normalize = Normalize headers
import-normalize-hint = Rewrites the header rows to the table's own column names. Nothing is imported - you see the changed CSV first.
import-normalized-message = Headers rewritten. Read the CSV below before importing it.
import-normalized-unchanged = Headers already match the table exactly; nothing was changed.
import-err-missing-type = Missing type for column '{ $column }' import-err-missing-type = Missing type for column '{ $column }'
import-err-permission = Import permission is required. A table also needs insert permission before it can be loaded. import-err-permission = Import permission is required. A table also needs insert permission before it can be loaded.
import-err-unterminated-quote = CSV contains an unterminated quoted value. import-err-unterminated-quote = CSV contains an unterminated quoted value.

View File

@@ -603,6 +603,13 @@ import-err-first-row = Prvý riadok CSV musí obsahovať len vybrané názvy tab
import-err-header-lengths = Riadok hlavičky tabuliek a stĺpcov majú rôznu dĺžku. import-err-header-lengths = Riadok hlavičky tabuliek a stĺpcov majú rôznu dĺžku.
import-err-row-width = Riadok údajov CSV má iný počet polí ako hlavička. import-err-row-width = Riadok údajov CSV má iný počet polí ako hlavička.
import-err-no-data-rows = CSV obsahuje hlavičky, ale žiadne riadky údajov. import-err-no-data-rows = CSV obsahuje hlavičky, ale žiadne riadky údajov.
import-err-header-padded = Hlavička '{ $header }' má okolo seba medzery, takže nie je stĺpcom '{ $trimmed }'. Odstráňte ich, alebo použite „Normalizovať hlavičky“.
import-err-duplicate-header = Hlavička '{ $header }' sa pri tej istej tabuľke vyskytuje dvakrát, takže nie je jasné, do ktorého stĺpca hodnoty patria.
import-err-column-near-match = Hlavička '{ $header }' nie je stĺpcom tabuľky '{ $table }', ale '{ $column }' áno - líšia sa len medzerami alebo veľkosťou písmen. Opravte hlavičku, alebo použite „Normalizovať hlavičky“ a výsledok si pred importom pozrite.
import-normalize = Normalizovať hlavičky
import-normalize-hint = Prepíše riadky hlavičiek na názvy stĺpcov tabuľky. Nič sa neimportuje - zmenené CSV najprv uvidíte.
import-normalized-message = Hlavičky prepísané. Kým CSV naimportujete, prečítajte si ho nižšie.
import-normalized-unchanged = Hlavičky už tabuľke presne zodpovedajú; nič sa nezmenilo.
import-err-missing-type = Chýba typ stĺpca '{ $column }' import-err-missing-type = Chýba typ stĺpca '{ $column }'
import-err-permission = Vyžaduje sa oprávnenie na import. Tabuľka navyše potrebuje oprávnenie na vkladanie, aby sa do nej dalo načítať. import-err-permission = Vyžaduje sa oprávnenie na import. Tabuľka navyše potrebuje oprávnenie na vkladanie, aby sa do nej dalo načítať.
import-err-unterminated-quote = CSV obsahuje neuzavretú úvodzovkovú hodnotu. import-err-unterminated-quote = CSV obsahuje neuzavretú úvodzovkovú hodnotu.

View File

@@ -1,6 +1,12 @@
use crate::{i18n::Locale, tr}; use crate::{i18n::Locale, tr};
pub(crate) fn parse_csv(locale: Locale, input: &str) -> Result<Vec<Vec<String>>, String> { pub(crate) fn parse_csv(locale: Locale, input: &str) -> Result<Vec<Vec<String>>, String> {
// The byte-order mark Excel writes at the head of a "CSV UTF-8" file is
// encoding metadata, not a character of the first field, so decoding
// consumes it. Nothing else about the file is touched here: what the
// fields contain is the user's, and the import validates it rather than
// tidying it up.
let input = input.strip_prefix('\u{feff}').unwrap_or(input);
let mut rows = Vec::new(); let mut rows = Vec::new();
let mut row = Vec::new(); let mut row = Vec::new();
let mut field = String::new(); let mut field = String::new();

View File

@@ -66,6 +66,27 @@ fn is_read_omitted_column(name: &str) -> bool {
.any(|column| column.name == name) .any(|column| column.name == name)
} }
/// Column names keyed by their lowercased form, for the one caller allowed to
/// match loosely: the normalizer, which rewrites a header into the column's own
/// spelling and shows the user the result before anything is imported.
///
/// Two columns that fold to the same key leave that key out entirely. There is
/// no answer to "which one did the header mean", and inventing one would be the
/// normalizer quietly choosing for the user.
pub(crate) fn folded_column_lookup(columns: &[String]) -> HashMap<String, String> {
let mut lookup: HashMap<String, Option<String>> = HashMap::new();
for column in columns {
lookup
.entry(column.to_lowercase())
.and_modify(|entry| *entry = None)
.or_insert_with(|| Some(column.clone()));
}
lookup
.into_iter()
.filter_map(|(folded, column)| Some((folded, column?)))
.collect()
}
pub(crate) fn column_types(schema: &TableStructureResponse) -> HashMap<String, String> { pub(crate) fn column_types(schema: &TableStructureResponse) -> HashMap<String, String> {
schema schema
.columns .columns

View File

@@ -21,9 +21,11 @@ use crate::{
use super::{ use super::{
super::common::{ super::common::{
csv::parse_csv, csv::{parse_csv, write_record},
loader::LoadError, loader::LoadError,
schema::{column_types, csv_value, exportable_columns, is_system_column}, schema::{
column_types, csv_value, exportable_columns, folded_column_lookup, is_system_column,
},
}, },
loader::load_page, loader::load_page,
state::ImportForm, state::ImportForm,
@@ -32,8 +34,38 @@ use super::{
struct ImportTable { struct ImportTable {
name: String, name: String,
columns: HashSet<String>,
types: HashMap<String, String>, types: HashMap<String, String>,
/// The columns an import may write, matched exactly. A header is an
/// identifier the file states, not something to be interpreted: it either
/// is the column's name or it is not.
columns: HashSet<String>,
/// The same columns keyed by their lowercased name. Never used to accept a
/// header — only to tell the user that the header they wrote is one
/// "Normalize headers" would turn into a real column.
by_folded_name: HashMap<String, String>,
}
impl ImportTable {
fn new(name: String, columns: Vec<String>, types: HashMap<String, String>) -> Self {
Self {
name,
types,
by_folded_name: folded_column_lookup(&columns),
columns: columns.into_iter().collect(),
}
}
fn has_column(&self, header: &str) -> bool {
self.columns.contains(header)
}
/// The column a header would name once its spacing and capitalisation were
/// normalized, for the error message that offers to do exactly that.
fn near_match(&self, header: &str) -> Option<&str> {
self.by_folded_name
.get(&header.trim().to_lowercase())
.map(String::as_str)
}
} }
pub(crate) async fn import_page(State(state): State<AppState>, headers: HeaderMap) -> Response { pub(crate) async fn import_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
@@ -105,23 +137,24 @@ pub(crate) async fn import_csv(
), ),
); );
}; };
tables.push(ImportTable { tables.push(ImportTable::new(
name: table_name.clone(), table_name.clone(),
columns: exportable_columns(&structure).into_iter().collect(), exportable_columns(&structure),
types: column_types(&structure), column_types(&structure),
}); ));
} }
let mut inserted = 0usize; let mut inserted = 0usize;
for table in &tables { for table in &tables {
let belongs_to_table = |index: usize| {
table_headers.as_ref().map_or(table_names.len() == 1, |headers| {
headers.get(index).is_some_and(|name| name == &table.name)
})
};
let positions = columns let positions = columns
.iter() .iter()
.enumerate() .enumerate()
.filter(|(index, column)| { .filter(|(index, column)| belongs_to_table(*index) && table.has_column(column))
table_headers.as_ref().map_or(table_names.len() == 1, |headers| {
headers.get(*index).is_some_and(|name| name == &table.name)
}) && table.columns.contains(*column)
})
.map(|(index, column)| (index, column.clone())) .map(|(index, column)| (index, column.clone()))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if positions.is_empty() { if positions.is_empty() {
@@ -135,23 +168,33 @@ pub(crate) async fn import_csv(
); );
} }
for (index, column) in columns.iter().enumerate() { for (index, column) in columns.iter().enumerate() {
let belongs = table_headers.as_ref().map_or(table_names.len() == 1, |headers| headers.get(index).is_some_and(|name| name == &table.name)); let belongs = belongs_to_table(index);
// A system column is the server's to write, so a file that carries // A system column is the server's to write, so a file that carries
// one -- an export taken with the system columns included -- loads // one -- an export taken with the system columns included -- loads
// with that column left where it is, not refused. // with that column left where it is, not refused.
if belongs && is_system_column(column) { if belongs && is_system_column(column) {
continue; continue;
} }
if belongs && !table.columns.contains(column) { if belongs && !table.has_column(column) {
return reject( // A header that is only a normalization away from a real column
&headers, // says so, instead of accusing a name the user can see in the
tr!( // table of not existing.
let message = match table.near_match(column) {
Some(near) => tr!(
Locale::from_headers(&headers),
"import-err-column-near-match",
"header" => column.clone(),
"column" => near.to_string(),
"table" => table.name.clone(),
),
None => tr!(
Locale::from_headers(&headers), Locale::from_headers(&headers),
"import-err-column-not-importable", "import-err-column-not-importable",
"column" => column.clone(), "column" => column.clone(),
"table" => table.name.clone(), "table" => table.name.clone(),
), ),
); };
return reject(&headers, message);
} }
} }
let converted = match data_rows let converted = match data_rows
@@ -200,6 +243,172 @@ pub(crate) async fn import_csv(
.into_response() .into_response()
} }
/// A header cell is an identifier the file states, so it is checked rather
/// than tidied: a name with spaces around it is a different name, and the file
/// is refused with the cell quoted so the difference is visible. "Normalize
/// headers" is how a user asks for the tidying, and it rewrites the CSV in
/// front of them instead of happening in here.
fn reject_padded_headers(locale: Locale, row: &[String]) -> Result<(), String> {
match row.iter().find(|cell| cell.trim() != cell.as_str()) {
Some(cell) => Err(tr!(
locale,
"import-err-header-padded",
"header" => cell.clone(),
"trimmed" => cell.trim().to_string(),
)),
None => Ok(()),
}
}
/// Two headers naming the same column of the same table make the file
/// ambiguous: whichever one is read second decides the value, silently. In a
/// multi-table file the pair is (table, column), since two tables may each have
/// a `name` column.
fn reject_duplicate_headers(
locale: Locale,
table_headers: Option<&[String]>,
columns: &[String],
) -> Result<(), String> {
let mut seen = HashSet::new();
for (index, column) in columns.iter().enumerate() {
let table = table_headers.and_then(|headers| headers.get(index)).cloned();
if !seen.insert((table.clone(), column.clone())) {
return Err(tr!(
locale,
"import-err-duplicate-header",
"header" => column.clone(),
"table" => table.unwrap_or_default(),
));
}
}
Ok(())
}
/// POST /admin/import/normalize — rewrites the header rows and hands the CSV
/// back for the user to look at. Nothing is imported.
///
/// This is the one place allowed to change what the user pasted, and it is
/// explicit: the button says what it does, the result goes back into the
/// textarea, and the import that follows reads that text as strictly as it
/// reads any other. A header keeps its own spelling unless it matches a column
/// exactly once the spacing and capitalisation are taken out, so nothing is
/// renamed on a guess.
pub(crate) async fn normalize_headers(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<ImportForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let locale = Locale::from_headers(&headers);
let (profile_name, table_names) = match form.targets(locale) {
Ok(targets) => targets,
Err(message) => return reject(&headers, message),
};
let mut rows = match parse_csv(locale, &form.csv_data) {
Ok(rows) => rows,
Err(message) => return reject(&headers, message),
};
// The columns of every selected table, so a header can be rewritten into
// the spelling the table actually declares.
let mut lookups = Vec::new();
for table_name in &table_names {
let request = GetTableStructureRequest {
profile_name: profile_name.clone(),
table_names: vec![table_name.clone()],
};
let mut structures = state.structures.clone();
let structure = match structures
.get_table_structure(match authenticated_request(&headers, request) {
Ok(request) => request,
Err(_) => return Redirect::to("/login").into_response(),
})
.await
{
Ok(response) => response.into_inner().table_structures.remove(table_name),
Err(error) => return grpc_error(&headers, &error),
};
let Some(structure) = structure else {
return unavailable(&headers, tr!(locale, "import-err-missing-structure"));
};
lookups.push((
table_name.clone(),
folded_column_lookup(&exportable_columns(&structure)),
));
}
let multi_table = table_names.len() > 1;
let table_row = multi_table.then(|| rows.first().cloned()).flatten();
if multi_table && rows.len() < 2 {
return reject(&headers, tr!(locale, "import-err-multi-headers"));
}
let column_row_index = usize::from(multi_table);
// The table-name row first: a column header is looked up in the table its
// own cell names, so that row has to be settled before the other one.
let table_row = table_row.map(|row| {
row.iter()
.map(|cell| normalized_name(cell, table_names.iter().map(String::as_str)))
.collect::<Vec<_>>()
});
if let Some(row) = table_row.clone() {
rows[0] = row;
}
if let Some(columns) = rows.get(column_row_index).cloned() {
rows[column_row_index] = columns
.iter()
.enumerate()
.map(|(index, cell)| {
let table = table_row
.as_ref()
.and_then(|row| row.get(index))
.cloned()
.unwrap_or_else(|| table_names.first().cloned().unwrap_or_default());
lookups
.iter()
.find(|(name, _)| name == &table)
.and_then(|(_, lookup)| lookup.get(&cell.trim().to_lowercase()))
.cloned()
.unwrap_or_else(|| cell.trim().to_string())
})
.collect();
}
let mut csv = String::new();
for row in &rows {
write_record(&mut csv, row);
}
let changed = csv != form.csv_data;
let page = match load_page(
state,
&headers,
ImportForm {
csv_data: csv,
..form
},
None,
)
.await
{
Ok(page) => page,
Err(error) => return load_error(&headers, error),
};
Html(ui::render_normalized(&page, changed)).into_response()
}
/// A cell rewritten to the one candidate it matches once spacing and
/// capitalisation are set aside, or trimmed and left alone when it matches none.
fn normalized_name<'a>(cell: &str, candidates: impl Iterator<Item = &'a str>) -> String {
let folded = cell.trim().to_lowercase();
candidates
.filter(|candidate| candidate.to_lowercase() == folded)
.map(str::to_string)
.next()
.unwrap_or_else(|| cell.trim().to_string())
}
fn split_headers( fn split_headers(
locale: Locale, locale: Locale,
rows: Vec<Vec<String>>, rows: Vec<Vec<String>>,
@@ -210,17 +419,22 @@ fn split_headers(
return Err(tr!(locale, "import-err-multi-headers")); return Err(tr!(locale, "import-err-multi-headers"));
} }
let table_headers = rows[0].clone(); let table_headers = rows[0].clone();
reject_padded_headers(locale, &table_headers)?;
if table_headers.iter().any(|name| !tables.contains(name)) { if table_headers.iter().any(|name| !tables.contains(name)) {
return Err(tr!(locale, "import-err-first-row")); return Err(tr!(locale, "import-err-first-row"));
} }
let columns = rows[1].clone(); let columns = rows[1].clone();
reject_padded_headers(locale, &columns)?;
if columns.len() != table_headers.len() { if columns.len() != table_headers.len() {
return Err(tr!(locale, "import-err-header-lengths")); return Err(tr!(locale, "import-err-header-lengths"));
} }
reject_duplicate_headers(locale, Some(&table_headers), &columns)?;
validate_width(locale, &rows[2..], columns.len())?; validate_width(locale, &rows[2..], columns.len())?;
Ok((Some(table_headers), columns, rows[2..].to_vec())) Ok((Some(table_headers), columns, rows[2..].to_vec()))
} else { } else {
let columns = rows[0].clone(); let columns = rows[0].clone();
reject_padded_headers(locale, &columns)?;
reject_duplicate_headers(locale, None, &columns)?;
validate_width(locale, &rows[1..], columns.len())?; validate_width(locale, &rows[1..], columns.len())?;
Ok((None, columns, rows[1..].to_vec())) Ok((None, columns, rows[1..].to_vec()))
} }
@@ -334,4 +548,109 @@ mod tests {
assert_eq!(columns, vec!["number", "name"]); assert_eq!(columns, vec!["number", "name"]);
assert_eq!(data.len(), 1); assert_eq!(data.len(), 1);
} }
fn table(columns: &[&str]) -> ImportTable {
ImportTable::new(
"invoice".to_string(),
columns.iter().map(|column| column.to_string()).collect(),
HashMap::new(),
)
}
/// The byte-order mark is encoding metadata, so decoding consumes it and
/// the first header is the name the user typed. Everything else about the
/// header is left exactly as it arrived.
#[test]
fn the_byte_order_mark_is_decoded_away_and_nothing_else_is() {
let rows = parse_csv(Locale::default(), "\u{feff}label,amount\nAcme,10\n").unwrap();
assert_eq!(rows[0], vec!["label", "amount"]);
let rows = parse_csv(Locale::default(), "LABEL, amount \nAcme,10\n").unwrap();
assert_eq!(rows[0], vec!["LABEL", " amount "]);
}
/// A padded header is a different name, and the file says so. It is
/// refused with the cell quoted, because the difference between `amount`
/// and ` amount` is invisible everywhere else.
#[test]
fn a_padded_header_is_refused_and_the_message_shows_the_cell() {
let rows = vec![
vec!["label".to_string(), " amount".to_string()],
vec!["Acme".to_string(), "10".to_string()],
];
let error = split_headers(Locale::default(), rows, &["invoice".to_string()])
.expect_err("a padded header is not the column's name");
assert!(error.contains(" amount"), "{error}");
}
/// Two headers naming one column let the second silently win. The file is
/// refused instead.
#[test]
fn a_repeated_header_is_refused() {
let rows = vec![
vec!["label".to_string(), "label".to_string()],
vec!["Acme".to_string(), "Other".to_string()],
];
let error = split_headers(Locale::default(), rows, &["invoice".to_string()])
.expect_err("one column cannot be given twice");
assert!(error.contains("label"), "{error}");
// Two tables may each have their own `name`, so the pair is what has
// to be unique.
let rows = vec![
vec!["invoice".to_string(), "customer".to_string()],
vec!["name".to_string(), "name".to_string()],
vec!["I-1".to_string(), "Acme".to_string()],
];
let targets = vec!["invoice".to_string(), "customer".to_string()];
assert!(split_headers(Locale::default(), rows, &targets).is_ok());
}
/// Matching stays exact. The folded lookup exists only so the refusal can
/// name the column the header nearly is, and point at the button that
/// would rewrite it.
#[test]
fn a_near_miss_is_refused_but_recognised() {
let table = table(&["label", "amount"]);
assert!(table.has_column("label"));
assert!(!table.has_column("LABEL"));
assert!(!table.has_column(" label"));
assert_eq!(table.near_match("LABEL"), Some("label"));
assert_eq!(table.near_match(" amount "), Some("amount"));
assert_eq!(table.near_match("total"), None);
}
/// A table-header row names tables exactly too, padding included.
#[test]
fn the_table_header_row_is_checked_as_strictly_as_the_columns() {
let targets = vec!["invoice".to_string(), "customer".to_string()];
let rows = vec![
vec![" invoice".to_string(), "customer".to_string()],
vec!["number".to_string(), "name".to_string()],
vec!["I-1".to_string(), "Acme".to_string()],
];
assert!(split_headers(Locale::default(), rows, &targets).is_err());
let rows = vec![
vec!["Invoice".to_string(), "customer".to_string()],
vec!["number".to_string(), "name".to_string()],
vec!["I-1".to_string(), "Acme".to_string()],
];
assert!(split_headers(Locale::default(), rows, &targets).is_err());
}
/// The normalizer rewrites a cell only when exactly one candidate matches
/// it once spacing and capitalisation are set aside.
#[test]
fn normalizing_rewrites_a_recognised_name_and_leaves_the_rest_alone() {
let tables = ["invoice", "customer"];
assert_eq!(normalized_name(" Invoice ", tables.into_iter()), "invoice");
assert_eq!(normalized_name("orders", tables.into_iter()), "orders");
// An unknown name still loses its padding, so the strict import that
// follows complains about the name rather than the spaces.
assert_eq!(normalized_name(" orders ", tables.into_iter()), "orders");
}
} }

View File

@@ -11,5 +11,6 @@ pub(crate) fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/admin/import", get(logic::import_page)) .route("/admin/import", get(logic::import_page))
.route("/admin/import", post(logic::import_csv)) .route("/admin/import", post(logic::import_csv))
.route("/admin/import/normalize", post(logic::normalize_headers))
.layer(DefaultBodyLimit::max(128 * 1024 * 1024)) .layer(DefaultBodyLimit::max(128 * 1024 * 1024))
} }

View File

@@ -11,12 +11,36 @@ use super::state::ImportPageState;
struct ImportPage<'a> { struct ImportPage<'a> {
nav: Nav, nav: Nav,
page: &'a ImportPageState, page: &'a ImportPageState,
/// `None` on a fresh page: the included fields template shows its
/// "normalized" line only after a rewrite has actually run.
changed: Option<bool>,
}
/// POST /admin/import/normalize — the form comes back with the rewritten CSV
/// in it, so the user reads what changed before importing anything.
#[derive(Template)]
#[template(path = "pages/import_export/import/fields.html")]
struct ImportFields<'a> {
nav: Nav,
page: &'a ImportPageState,
/// Whether the rewrite actually changed the text, so a file that was
/// already exact says so instead of looking like it was edited.
changed: Option<bool>,
} }
pub(crate) fn render_page(page: &ImportPageState) -> String { pub(crate) fn render_page(page: &ImportPageState) -> String {
render(&ImportPage { render(&ImportPage {
nav: page.nav.clone(), nav: page.nav.clone(),
page, page,
changed: None,
})
}
pub(crate) fn render_normalized(page: &ImportPageState, changed: bool) -> String {
render(&ImportFields {
nav: page.nav.clone(),
page,
changed: Some(changed),
}) })
} }

View File

@@ -0,0 +1,34 @@
{#
The form's fields, on their own so that "Normalize headers" can hand them
back with the rewritten CSV in the textarea — see
crate::pages::import_export::import::ui::ImportFields. `changed` is `None`
when the page first renders and `Some` once a rewrite has run.
#}
<div id="import-fields">
<div class="form-grid">
<label>{{ nav.tr("import-scope") }}
<select name="profile_name" data-profile-select required>
<option value="">{{ nav.tr("import-choose-scope") }}</option>
{% for profile in page.catalog.profiles %}
<option value="{{ profile.name }}"{% if page.form.profile_name == profile.name %} selected{% endif %}>{{ profile.label }}</option>
{% endfor %}
</select>
</label>
<label>{{ nav.tr("import-target-tables") }}
<select name="table_names" data-table-select required>
<option value="">{{ nav.tr("import-choose-table") }}</option>
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}
<option value="{{ table }}" data-profile="{{ profile.name }}"{% if page.form.table_names.contains(table) %} selected{% endif %}>{{ table }}</option>
{%- endfor %}{% endfor %}
</select>
</label>
<label class="wide">{{ nav.tr("import-csv-file") }}
<input type="file" accept=".csv,text/csv"
onchange="this.files[0]?.text().then(value =&gt; document.getElementById('csv-data').value = value)">
</label>
<label class="wide">{{ nav.tr("import-csv-data") }}<textarea id="csv-data" name="csv_data" rows="14" required>{{ page.form.csv_data }}</textarea></label>
</div>
{%- if let Some(changed) = changed %}
<p class="hint" role="status">{% if changed %}{{ nav.tr("import-normalized-message") }}{% else %}{{ nav.tr("import-normalized-unchanged") }}{% endif %}</p>
{%- endif %}
</div>

View File

@@ -10,34 +10,18 @@
{% block form %} {% block form %}
<form hx-post="/admin/import" hx-target="#submission-status" hx-swap="innerHTML" <form hx-post="/admin/import" hx-target="#submission-status" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]"> hx-disabled-elt="button[type=submit]">
<div class="form-grid"> {% include "pages/import_export/import/fields.html" %}
<label>{{ nav.tr("import-scope") }}
<select name="profile_name" data-profile-select required>
<option value="">{{ nav.tr("import-choose-scope") }}</option>
{% for profile in page.catalog.profiles %}
<option value="{{ profile.name }}"{% if page.form.profile_name == profile.name %} selected{% endif %}>{{ profile.label }}</option>
{% endfor %}
</select>
</label>
<label>{{ nav.tr("import-target-tables") }}
<select name="table_names" data-table-select required>
<option value="">{{ nav.tr("import-choose-table") }}</option>
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}
<option value="{{ table }}" data-profile="{{ profile.name }}"{% if page.form.table_names.contains(table) %} selected{% endif %}>{{ table }}</option>
{%- endfor %}{% endfor %}
</select>
</label>
<label class="wide">{{ nav.tr("import-csv-file") }}
<input type="file" accept=".csv,text/csv"
onchange="this.files[0]?.text().then(value =&gt; document.getElementById('csv-data').value = value)">
</label>
<label class="wide">{{ nav.tr("import-csv-data") }}<textarea id="csv-data" name="csv_data" rows="14" required>{{ page.form.csv_data }}</textarea></label>
</div>
<div id="submission-status" aria-live="polite"> <div id="submission-status" aria-live="polite">
{%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("import-error-title"), message) %}{% endcall %}{% endif -%} {%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("import-error-title"), message) %}{% endcall %}{% endif -%}
</div> </div>
<div class="form-actions"> <div class="form-actions">
<a href="/">{{ nav.tr("common-cancel") }}</a> <a href="/">{{ nav.tr("common-cancel") }}</a>
{# Rewrites the header rows and puts the result back in the textarea. It
imports nothing: the file that gets imported is the one the user can see
and has had the chance to read. #}
<button type="button" hx-post="/admin/import/normalize" hx-include="closest form"
hx-target="#import-fields" hx-swap="outerHTML"
title="{{ nav.tr("import-normalize-hint") }}">{{ nav.tr("import-normalize") }}</button>
<button type="submit">{{ nav.tr("import-import-rows") }}</button> <button type="submit">{{ nav.tr("import-import-rows") }}</button>
</div> </div>
</form> </form>

View File

@@ -4,13 +4,18 @@
The options are rebuilt rather than hidden: `hidden` on an `<option>` is not The options are rebuilt rather than hidden: `hidden` on an `<option>` is not
honoured everywhere, and a hidden option is still selectable with the honoured everywhere, and a hidden option is still selectable with the
keyboard. With scripting off the dropdown lists every table it was rendered keyboard. With scripting off the dropdown lists every table it was rendered
with, and the server still refuses a table that is not in the profile. with, and the server still refuses a table that is not in the scope.
Bound again after every HTMX swap, because "Normalize headers" replaces the
fields — and with them both selects — with fresh nodes.
#} #}
<script> <script>
(function () { (function () {
function bind() {
const profileSelect = document.querySelector("[data-profile-select]"); const profileSelect = document.querySelector("[data-profile-select]");
const tableSelect = document.querySelector("[data-table-select]"); const tableSelect = document.querySelector("[data-table-select]");
if (!profileSelect || !tableSelect) return; if (!profileSelect || !tableSelect || tableSelect.dataset.bound) return;
tableSelect.dataset.bound = "true";
const placeholder = tableSelect.options[0]; const placeholder = tableSelect.options[0];
const all = Array.from(tableSelect.options).slice(1); const all = Array.from(tableSelect.options).slice(1);
function sync() { function sync() {
@@ -29,5 +34,9 @@
} }
profileSelect.addEventListener("change", sync); profileSelect.addEventListener("change", sync);
sync(); sync();
}
bind();
document.body.addEventListener("htmx:afterSwap", bind);
})(); })();
</script> </script>