import either strict or allows for normalization
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
2
client
Submodule client updated: 429600a181...d2b70f94ad
Submodule tui-canvas updated: 7c3775469e...1251d22406
Submodule tui-pages updated: 50e1145a9a...29e1e6a42e
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
web/templates/pages/import_export/import/fields.html
Normal file
34
web/templates/pages/import_export/import/fields.html
Normal 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 => 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>
|
||||||
@@ -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 => 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user