import page jiff
This commit is contained in:
@@ -4,7 +4,7 @@ use crate::AppState;
|
||||
|
||||
use super::{
|
||||
super::common::loader::{LoadError, load_catalog},
|
||||
state::{ImportForm, ImportPageState, Step},
|
||||
state::{DateFormat, ImportForm, ImportPageState, Step},
|
||||
};
|
||||
|
||||
pub(crate) async fn load_page(
|
||||
@@ -14,10 +14,15 @@ pub(crate) async fn load_page(
|
||||
step: Step,
|
||||
) -> Result<ImportPageState, LoadError> {
|
||||
let catalog = load_catalog(state, headers, crate::authz::IMPORT, None).await?;
|
||||
let date_formats = DateFormat::options(
|
||||
crate::i18n::Locale::from_headers(headers),
|
||||
form.date_format,
|
||||
);
|
||||
Ok(ImportPageState {
|
||||
nav: crate::ui::Nav::from_authorization(headers, "", &catalog.authorization),
|
||||
catalog,
|
||||
form,
|
||||
date_formats,
|
||||
step,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ use super::{
|
||||
},
|
||||
destination::{DestinationColumn, destination_columns, resolve},
|
||||
loader::load_page,
|
||||
prepare::{Prepared, Source, canonical_csv, prepare, read_mapping, read_source},
|
||||
prepare::{
|
||||
Prepared, Source, canonical_csv, normalize_dates, prepare, read_mapping, read_source,
|
||||
},
|
||||
state::{ImportForm, MappingRow, MappingStep, PreviewStep, SourceOption, Step},
|
||||
ui,
|
||||
};
|
||||
@@ -408,7 +410,14 @@ async fn prepared(
|
||||
|
||||
let assignments =
|
||||
read_mapping(locale, &chosen, &source).map_err(|message| reject(headers, message))?;
|
||||
let prepared = prepare(&assignments, &source, &destination.names());
|
||||
let mut prepared = prepare(&assignments, &source, &destination.names());
|
||||
normalize_dates(
|
||||
locale,
|
||||
&mut prepared,
|
||||
&destination.types,
|
||||
form.date_format,
|
||||
)
|
||||
.map_err(|message| reject(headers, message))?;
|
||||
Ok((destination, source, prepared))
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,12 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::{i18n::Locale, tr};
|
||||
|
||||
use super::super::common::{
|
||||
csv::{parse_csv, write_record},
|
||||
schema::is_system_column,
|
||||
use super::{
|
||||
super::common::{
|
||||
csv::{parse_csv, write_record},
|
||||
schema::is_system_column,
|
||||
},
|
||||
state::DateFormat,
|
||||
};
|
||||
|
||||
/// The uploaded file: its header, and its data.
|
||||
@@ -234,6 +237,55 @@ pub(crate) fn prepare(
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses source DATE cells using the order selected for this import and
|
||||
/// rewrites them to Jiff's canonical `YYYY-MM-DD` representation.
|
||||
///
|
||||
/// Doing this on the prepared data keeps the preview, downloaded CSV and bulk
|
||||
/// request identical. A downloaded prepared file can consequently be imported
|
||||
/// later using the default order without carrying hidden page state with it.
|
||||
pub(crate) fn normalize_dates(
|
||||
locale: Locale,
|
||||
prepared: &mut Prepared,
|
||||
column_types: &HashMap<String, String>,
|
||||
date_format: DateFormat,
|
||||
) -> Result<(), String> {
|
||||
let date_columns = prepared
|
||||
.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, column)| {
|
||||
column_types
|
||||
.get(*column)
|
||||
.is_some_and(|data_type| data_type.eq_ignore_ascii_case("DATE"))
|
||||
})
|
||||
.map(|(index, column)| (index, column.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (row_index, row) in prepared.rows.iter_mut().enumerate() {
|
||||
for (column_index, column) in &date_columns {
|
||||
let Some(value) = row.get_mut(*column_index) else {
|
||||
continue;
|
||||
};
|
||||
let raw = value.trim();
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let date = jiff::civil::Date::strptime(date_format.pattern(), raw).map_err(|_| {
|
||||
tr!(
|
||||
locale,
|
||||
"import-err-bad-date",
|
||||
"value" => raw.to_string(),
|
||||
"column" => column.clone(),
|
||||
"row" => (row_index + 2) as i64,
|
||||
"format" => date_format.value().to_string(),
|
||||
)
|
||||
})?;
|
||||
*value = date.to_string();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The prepared import as text: exactly what the importer reads, and exactly
|
||||
/// what "Download prepared CSV" hands over, so inspecting the file and
|
||||
/// importing it cannot disagree.
|
||||
@@ -344,6 +396,93 @@ mod tests {
|
||||
assert_eq!(prepared.empty, strings(&["b"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_four_digit_date_format_is_normalized_in_the_prepared_csv() {
|
||||
let mut prepared = Prepared {
|
||||
columns: strings(&["issued_on", "note"]),
|
||||
rows: vec![strings(&["18-08-2026", "keep me"])],
|
||||
ignored: Vec::new(),
|
||||
empty: Vec::new(),
|
||||
};
|
||||
let types = HashMap::from([
|
||||
("issued_on".to_string(), "DATE".to_string()),
|
||||
("note".to_string(), "TEXT".to_string()),
|
||||
]);
|
||||
|
||||
normalize_dates(
|
||||
Locale::English,
|
||||
&mut prepared,
|
||||
&types,
|
||||
DateFormat::DayMonthYearFourDigit,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(prepared.rows[0], strings(&["2026-08-18", "keep me"]));
|
||||
assert_eq!(
|
||||
canonical_csv(&prepared),
|
||||
"\"issued_on\",\"note\"\n\"2026-08-18\",\"keep me\"\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_two_digit_year_formats_are_normalized_by_jiff() {
|
||||
let types = HashMap::from([("issued_on".to_string(), "DATE".to_string())]);
|
||||
let mut year_first = Prepared {
|
||||
columns: strings(&["issued_on"]),
|
||||
rows: vec![strings(&["26-08-18"])],
|
||||
ignored: Vec::new(),
|
||||
empty: Vec::new(),
|
||||
};
|
||||
let mut day_first = Prepared {
|
||||
columns: strings(&["issued_on"]),
|
||||
rows: vec![strings(&["18-08-26"]), strings(&["01-01-69"])],
|
||||
ignored: Vec::new(),
|
||||
empty: Vec::new(),
|
||||
};
|
||||
|
||||
normalize_dates(
|
||||
Locale::English,
|
||||
&mut year_first,
|
||||
&types,
|
||||
DateFormat::YearMonthDayTwoDigit,
|
||||
)
|
||||
.unwrap();
|
||||
normalize_dates(
|
||||
Locale::English,
|
||||
&mut day_first,
|
||||
&types,
|
||||
DateFormat::DayMonthYearTwoDigit,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(year_first.rows[0], strings(&["2026-08-18"]));
|
||||
assert_eq!(day_first.rows[0], strings(&["2026-08-18"]));
|
||||
assert_eq!(day_first.rows[1], strings(&["1969-01-01"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_date_that_does_not_match_the_selected_format_is_refused() {
|
||||
let mut prepared = Prepared {
|
||||
columns: strings(&["issued_on"]),
|
||||
rows: vec![strings(&["2026-08-18"])],
|
||||
ignored: Vec::new(),
|
||||
empty: Vec::new(),
|
||||
};
|
||||
let types = HashMap::from([("issued_on".to_string(), "DATE".to_string())]);
|
||||
|
||||
let error = normalize_dates(
|
||||
Locale::English,
|
||||
&mut prepared,
|
||||
&types,
|
||||
DateFormat::DayMonthYearFourDigit,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.contains("2026-08-18"), "{error}");
|
||||
assert!(error.contains("issued_on"), "{error}");
|
||||
assert!(error.contains('2'), "{error}");
|
||||
}
|
||||
|
||||
/// `deleted` is not a normal form field. When it is not mapped, leaving it
|
||||
/// out lets the server use the same FALSE default as an ordinary form post.
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,82 @@
|
||||
use crate::{i18n::Locale, tr};
|
||||
|
||||
/// The exact date format used by the source CSV.
|
||||
///
|
||||
/// Keep the accepted spellings, parser pattern and selector options together:
|
||||
/// adding another supported input format should mean adding one variant here,
|
||||
/// rather than teaching each import step about it separately.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize)]
|
||||
pub(crate) enum DateFormat {
|
||||
#[default]
|
||||
#[serde(rename = "yyyy-mm-dd")]
|
||||
YearMonthDayFourDigit,
|
||||
#[serde(rename = "dd-mm-yyyy")]
|
||||
DayMonthYearFourDigit,
|
||||
#[serde(rename = "yy-mm-dd")]
|
||||
YearMonthDayTwoDigit,
|
||||
#[serde(rename = "dd-mm-yy")]
|
||||
DayMonthYearTwoDigit,
|
||||
}
|
||||
|
||||
impl DateFormat {
|
||||
const ALL: [Self; 4] = [
|
||||
Self::YearMonthDayTwoDigit,
|
||||
Self::DayMonthYearTwoDigit,
|
||||
Self::YearMonthDayFourDigit,
|
||||
Self::DayMonthYearFourDigit,
|
||||
];
|
||||
|
||||
pub(crate) const fn value(self) -> &'static str {
|
||||
match self {
|
||||
Self::YearMonthDayFourDigit => "yyyy-mm-dd",
|
||||
Self::DayMonthYearFourDigit => "dd-mm-yyyy",
|
||||
Self::YearMonthDayTwoDigit => "yy-mm-dd",
|
||||
Self::DayMonthYearTwoDigit => "dd-mm-yy",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn pattern(self) -> &'static str {
|
||||
match self {
|
||||
Self::YearMonthDayFourDigit => "%Y-%m-%d",
|
||||
Self::DayMonthYearFourDigit => "%d-%m-%Y",
|
||||
Self::YearMonthDayTwoDigit => "%y-%m-%d",
|
||||
Self::DayMonthYearTwoDigit => "%d-%m-%y",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self, locale: Locale) -> String {
|
||||
match self {
|
||||
Self::YearMonthDayFourDigit => tr!(locale, "import-date-format-yyyy-mm-dd"),
|
||||
Self::DayMonthYearFourDigit => tr!(locale, "import-date-format-dd-mm-yyyy"),
|
||||
Self::YearMonthDayTwoDigit => tr!(locale, "import-date-format-yy-mm-dd"),
|
||||
Self::DayMonthYearTwoDigit => tr!(locale, "import-date-format-dd-mm-yy"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn options(locale: Locale, selected: Self) -> Vec<DateFormatOption> {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.map(|format| DateFormatOption {
|
||||
value: format.value(),
|
||||
label: format.label(locale),
|
||||
selected: format == selected,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DateFormat {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(self.value())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct DateFormatOption {
|
||||
pub value: &'static str,
|
||||
pub label: String,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
/// Every field the import form carries, at every step.
|
||||
///
|
||||
/// The whole preparation is one form posted back and forth: the page keeps no
|
||||
@@ -16,6 +93,9 @@ pub(crate) struct ImportForm {
|
||||
/// The uploaded file, header row and all.
|
||||
#[serde(default)]
|
||||
pub csv_data: String,
|
||||
/// The format used to interpret non-empty values mapped into DATE columns.
|
||||
#[serde(default)]
|
||||
pub date_format: DateFormat,
|
||||
/// One entry per table-column row: the destination's stable identity. See
|
||||
/// [`DestinationKey`](super::destination::DestinationKey).
|
||||
///
|
||||
@@ -150,6 +230,7 @@ pub(crate) struct ImportPageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub catalog: super::super::common::loader::Catalog,
|
||||
pub form: ImportForm,
|
||||
pub date_formats: Vec<DateFormatOption>,
|
||||
pub step: Step,
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,9 @@ mod tests {
|
||||
use crate::auth::AuthorizationSnapshot;
|
||||
use crate::pages::import_export::common::loader::{Catalog, Profile};
|
||||
|
||||
use super::super::state::{ImportForm, MappingRow, MappingStep, PreviewStep, SourceOption};
|
||||
use super::super::state::{
|
||||
DateFormat, ImportForm, MappingRow, MappingStep, PreviewStep, SourceOption,
|
||||
};
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| value.to_string()).collect()
|
||||
@@ -124,9 +126,14 @@ mod tests {
|
||||
profile_name: "acme".to_string(),
|
||||
table_name: "customers".to_string(),
|
||||
csv_data: "\"hl\",\"he\"\n\"value-a\",\"value-b\"\n".to_string(),
|
||||
date_format: DateFormat::YearMonthDayFourDigit,
|
||||
destination: strings(&["id:42", "id:57"]),
|
||||
source_position: strings(&["1", "2"]),
|
||||
},
|
||||
date_formats: DateFormat::options(
|
||||
Locale::English,
|
||||
DateFormat::YearMonthDayFourDigit,
|
||||
),
|
||||
step,
|
||||
}
|
||||
}
|
||||
@@ -179,9 +186,26 @@ mod tests {
|
||||
assert!(html.contains(r#"action="/admin/import""#), "{html}");
|
||||
assert!(html.contains(r#"name="table_name""#), "{html}");
|
||||
assert!(html.contains(r#"name="csv_data""#), "{html}");
|
||||
assert!(html.contains(r#"name="date_format""#), "{html}");
|
||||
for value in ["yy-mm-dd", "dd-mm-yy", "yyyy-mm-dd", "dd-mm-yyyy"] {
|
||||
assert!(html.contains(&format!(r#"value="{value}""#)), "{html}");
|
||||
}
|
||||
assert!(html.contains(r#"value="yyyy-mm-dd" selected"#), "{html}");
|
||||
assert!(!html.contains(r#"name="header_mode""#), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_date_format_is_carried_through_later_steps() {
|
||||
let mut page = page(mapping());
|
||||
page.form.date_format = DateFormat::DayMonthYearTwoDigit;
|
||||
let html = render_step(&page);
|
||||
|
||||
assert!(
|
||||
html.contains(r#"name="date_format" value="dd-mm-yy""#),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The table columns stay fixed while parsed CSV columns can be connected
|
||||
/// to them or deliberately left unused.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user