diff --git a/client b/client index faeca44d..9d6b35a7 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit faeca44d40d0bdd5b3688fbd3d6871c2a3ff61f3 +Subproject commit 9d6b35a7871dd11e2ea90439b8298694e6071018 diff --git a/server b/server index d8b4bf2e..2bea8c15 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit d8b4bf2eebe38540318a0520cebca6de8b1cb638 +Subproject commit 2bea8c151f04b2ff302908ef0a9122578263d607 diff --git a/web/locales/cs/main.ftl b/web/locales/cs/main.ftl index 9c879753..442a47b5 100644 --- a/web/locales/cs/main.ftl +++ b/web/locales/cs/main.ftl @@ -579,6 +579,12 @@ import-choose-scope = Vyberte globální nebo profil import-target-tables = Cílová tabulka import-choose-table = Vyberte tabulku import-csv-file = Soubor CSV +import-date-format = Formát data +import-date-format-yy-mm-dd = YY-MM-DD (26-08-18) +import-date-format-dd-mm-yy = DD-MM-YY (18-08-26) +import-date-format-yyyy-mm-dd = YYYY-MM-DD (2026-08-18) +import-date-format-dd-mm-yyyy = DD-MM-YYYY (18-08-2026) +import-date-format-hint = Používá se pro sloupce DATE. Jiff rozvine hodnoty YY 69–99 na roky 1969–1999 a 00–68 na 2000–2068 a potom každé datum před importem normalizuje. import-csv-data = Data CSV import-csv-format-hint = První řádek se obvykle použije jako názvy sloupců souboru. Použijte CSV v UTF-8 s oddělovačem čárka a každé pole uzavřete do dvojitých uvozovek, například: "hl","he","s" import-continue = Pokračovat @@ -590,6 +596,7 @@ import-failure-message = Před zastavením importu se importovalo { $inserted } Již importované řádky zůstávají v tabulce. Aby nevznikly duplicity, neimportujte znovu celý soubor: nejprve tyto řádky odstraňte nebo importujte pouze chybný a zbývající řádky. import-failure-no-rows-message = Nic se neimportovalo. Selhal řádek CSV { $row }. Backend: { $error } +import-err-bad-date = Řádek CSV { $row }, sloupec { $column }: „{ $value }“ není platné datum ve formátu { $format }. import-success-title = Import dokončen import-success-message = Vloženo { $inserted -> [one] { $inserted } řádek diff --git a/web/locales/en/main.ftl b/web/locales/en/main.ftl index 8ac1500f..f17e6911 100644 --- a/web/locales/en/main.ftl +++ b/web/locales/en/main.ftl @@ -569,6 +569,12 @@ import-choose-scope = Choose global or a profile import-target-tables = Target table import-choose-table = Choose a table import-csv-file = CSV file +import-date-format = Date format +import-date-format-yy-mm-dd = YY-MM-DD (26-08-18) +import-date-format-dd-mm-yy = DD-MM-YY (18-08-26) +import-date-format-yyyy-mm-dd = YYYY-MM-DD (2026-08-18) +import-date-format-dd-mm-yyyy = DD-MM-YYYY (18-08-2026) +import-date-format-hint = Used for DATE columns. Jiff expands YY values 69–99 to 1969–1999 and 00–68 to 2000–2068, then normalizes every date before import. import-csv-data = CSV data import-csv-format-hint = The first row is normally used as the file's column names. Use UTF-8 CSV with a comma separator and every field in double quotes, for example: "hl","he","s" import-continue = Continue @@ -580,6 +586,7 @@ import-failure-message = Imported { $inserted } of { $source_rows } rows before Rows already imported remain in the table. To avoid duplicates, do not retry the whole file: remove those rows first, or import only the failed and remaining rows. import-failure-no-rows-message = Nothing was imported. CSV row { $row } failed. Backend: { $error } +import-err-bad-date = CSV row { $row }, column { $column }: “{ $value }” is not a valid { $format } date. import-success-title = Import complete import-success-message = Inserted { $inserted -> [one] { $inserted } row diff --git a/web/locales/sk/main.ftl b/web/locales/sk/main.ftl index a5593a91..7fbdd0de 100644 --- a/web/locales/sk/main.ftl +++ b/web/locales/sk/main.ftl @@ -579,6 +579,12 @@ import-choose-scope = Vyberte globálne alebo profil import-target-tables = Cieľová tabuľka import-choose-table = Vyberte tabuľku import-csv-file = Súbor CSV +import-date-format = Formát dátumu +import-date-format-yy-mm-dd = YY-MM-DD (26-08-18) +import-date-format-dd-mm-yy = DD-MM-YY (18-08-26) +import-date-format-yyyy-mm-dd = YYYY-MM-DD (2026-08-18) +import-date-format-dd-mm-yyyy = DD-MM-YYYY (18-08-2026) +import-date-format-hint = Používa sa pre stĺpce DATE. Jiff rozvinie hodnoty YY 69–99 na roky 1969–1999 a 00–68 na 2000–2068 a potom každý dátum pred importom normalizuje. import-csv-data = Údaje CSV import-csv-format-hint = Prvý riadok sa zvyčajne použije ako názvy stĺpcov súboru. Použite CSV v UTF-8 s oddeľovačom čiarka a každé pole uzavrite do dvojitých úvodzoviek, napríklad: "hl","he","s" import-continue = Pokračovať @@ -590,6 +596,7 @@ import-failure-message = Pred zastavením importu sa importovalo { $inserted } z Už importované riadky zostávajú v tabuľke. Aby nevznikli duplicity, neimportujte znova celý súbor: najprv tieto riadky odstráňte alebo importujte iba chybný a zostávajúce riadky. import-failure-no-rows-message = Nič sa neimportovalo. Zlyhal riadok CSV { $row }. Backend: { $error } +import-err-bad-date = Riadok CSV { $row }, stĺpec { $column }: „{ $value }“ nie je platný dátum vo formáte { $format }. import-success-title = Import dokončený import-success-message = Vložený { $inserted -> [one] { $inserted } riadok diff --git a/web/src/pages/import_export/import/loader.rs b/web/src/pages/import_export/import/loader.rs index 38f6c365..31c5e67c 100644 --- a/web/src/pages/import_export/import/loader.rs +++ b/web/src/pages/import_export/import/loader.rs @@ -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 { 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, }) } diff --git a/web/src/pages/import_export/import/logic.rs b/web/src/pages/import_export/import/logic.rs index a75f384d..e2cdac54 100644 --- a/web/src/pages/import_export/import/logic.rs +++ b/web/src/pages/import_export/import/logic.rs @@ -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)) } diff --git a/web/src/pages/import_export/import/prepare.rs b/web/src/pages/import_export/import/prepare.rs index d861d10c..483db505 100644 --- a/web/src/pages/import_export/import/prepare.rs +++ b/web/src/pages/import_export/import/prepare.rs @@ -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, + 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::>(); + + 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] diff --git a/web/src/pages/import_export/import/state.rs b/web/src/pages/import_export/import/state.rs index b051f3dc..7b7e3867 100644 --- a/web/src/pages/import_export/import/state.rs +++ b/web/src/pages/import_export/import/state.rs @@ -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 { + 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, pub step: Step, } diff --git a/web/src/pages/import_export/import/ui.rs b/web/src/pages/import_export/import/ui.rs index 84660662..f60c9ee7 100644 --- a/web/src/pages/import_export/import/ui.rs +++ b/web/src/pages/import_export/import/ui.rs @@ -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 { 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] diff --git a/web/templates/pages/import_export/import/carried.html b/web/templates/pages/import_export/import/carried.html index bf01f5ce..9505047f 100644 --- a/web/templates/pages/import_export/import/carried.html +++ b/web/templates/pages/import_export/import/carried.html @@ -10,3 +10,4 @@ + diff --git a/web/templates/pages/import_export/import/step.html b/web/templates/pages/import_export/import/step.html index b73bf6ff..7397748a 100644 --- a/web/templates/pages/import_export/import/step.html +++ b/web/templates/pages/import_export/import/step.html @@ -35,6 +35,14 @@ + + {{ nav.tr("import-date-format-hint") }} {{ nav.tr("import-csv-format-hint") }}