import page jiff

This commit is contained in:
Priec
2026-08-18 01:02:05 +02:00
parent b71ed4f42f
commit c75dacc0e8
12 changed files with 297 additions and 9 deletions

View File

@@ -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 6999 na roky 19691999 a 0068 na 20002068 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

View File

@@ -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 6999 to 19691999 and 0068 to 20002068, 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

View File

@@ -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 6999 na roky 19691999 a 0068 na 20002068 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

View File

@@ -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,
})
}

View File

@@ -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))
}

View File

@@ -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]

View File

@@ -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,
}

View File

@@ -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]

View File

@@ -10,3 +10,4 @@
<input type="hidden" name="profile_name" value="{{ page.form.profile_name }}">
<input type="hidden" name="table_name" value="{{ page.form.table_name }}">
<input type="hidden" name="csv_data" value="{{ page.form.csv_data }}">
<input type="hidden" name="date_format" value="{{ page.form.date_format }}">

View File

@@ -35,6 +35,14 @@
<input type="file" accept=".csv,text/csv"
onchange="this.files[0]?.text().then(value =&gt; document.getElementById('csv-data').value = value)">
</label>
<label>{{ nav.tr("import-date-format") }}
<select name="date_format" required>
{% for option in page.date_formats %}
<option value="{{ option.value }}"{% if option.selected %} selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
<small>{{ nav.tr("import-date-format-hint") }}</small>
<label class="wide">{{ nav.tr("import-csv-data") }}<textarea id="csv-data" name="csv_data" rows="12">{{ page.form.csv_data }}</textarea></label>
<small class="wide">{{ nav.tr("import-csv-format-hint") }}</small>
</div>