import page jiff
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }}">
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
<input type="file" accept=".csv,text/csv"
|
||||
onchange="this.files[0]?.text().then(value => 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>
|
||||
|
||||
Reference in New Issue
Block a user