commit
a47c4a0398
96 files changed
+25721
No files matched your search
+179
@@ -0,0 +1,179 @@
|
||||
//! Pure RFC 4180 CSV parsing and comparison. Preview never changes state.
|
||||
use crate::{
|
||||
domain::{Holiday, valid_country},
|
||||
error::{Result, ensure},
|
||||
};
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ImportPreview {
|
||||
pub additions: Vec<Holiday>,
|
||||
pub existing: usize,
|
||||
pub duplicates: usize,
|
||||
pub conflicts: Vec<String>,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn compare(input: &str, saved: &[Holiday]) -> Result<ImportPreview> {
|
||||
ensure(
|
||||
input.len() <= 262_144,
|
||||
crate::i18n::text("err_csv_is_limited_to_256_kib_e30936"),
|
||||
)?;
|
||||
let mut preview = ImportPreview {
|
||||
additions: vec![],
|
||||
existing: 0,
|
||||
duplicates: 0,
|
||||
conflicts: vec![],
|
||||
errors: vec![],
|
||||
};
|
||||
let mut reader = csv::ReaderBuilder::new()
|
||||
.trim(csv::Trim::All)
|
||||
.from_reader(input.trim_start_matches('\u{feff}').as_bytes());
|
||||
let headers = match reader.headers() {
|
||||
Ok(h) => h.iter().map(str::to_ascii_lowercase).collect::<Vec<_>>(),
|
||||
Err(_) => {
|
||||
preview
|
||||
.errors
|
||||
.push(crate::i18n::text("err_could_not_read_csv_headers_2bbd8d").into());
|
||||
return Ok(preview);
|
||||
}
|
||||
};
|
||||
let positions = ["country", "date", "name"].map(|key| headers.iter().position(|h| h == key));
|
||||
let [Some(country), Some(date), Some(name)] = positions else {
|
||||
preview
|
||||
.errors
|
||||
.push(crate::i18n::text("err_use_the_csv_headers_country_date_name_7d9639").into());
|
||||
return Ok(preview);
|
||||
};
|
||||
if headers.len() != 3 {
|
||||
preview.errors.push(
|
||||
crate::i18n::text("err_csv_must_have_exactly_three_columns_country_date_723e07").into(),
|
||||
);
|
||||
return Ok(preview);
|
||||
}
|
||||
let existing: BTreeMap<_, _> = saved
|
||||
.iter()
|
||||
.map(|h| ((h.country.clone(), h.date), h.name.clone()))
|
||||
.collect();
|
||||
let mut seen = BTreeMap::new();
|
||||
for (index, row) in reader.records().enumerate() {
|
||||
if index >= 2000 {
|
||||
preview
|
||||
.errors
|
||||
.push(crate::i18n::text("err_import_is_limited_to_2_000_rows_6e1c86").into());
|
||||
break;
|
||||
}
|
||||
let line = index + 2;
|
||||
let row = match row {
|
||||
Ok(row) => row,
|
||||
Err(_) => {
|
||||
preview.errors.push(crate::i18n::message(
|
||||
"err_row_line_invalid_csv_or_column_count_be7745",
|
||||
&[("line", (line).to_string())],
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let country = row[country].to_ascii_uppercase();
|
||||
let parsed = ["%Y-%m-%d", "%d-%m-%Y"].into_iter().find_map(|format| {
|
||||
NaiveDate::parse_from_str(&row[date], format)
|
||||
.ok()
|
||||
.filter(|d| {
|
||||
(2000..=2100).contains(&d.year()) && d.format(format).to_string() == row[date]
|
||||
})
|
||||
});
|
||||
if !valid_country(&country)
|
||||
|| parsed.is_none()
|
||||
|| row[name].is_empty()
|
||||
|| row[name].len() > 120
|
||||
{
|
||||
preview.errors.push(crate::i18n::message(
|
||||
"err_row_line_use_an_iso_country_code_dd_mm_yyyy_or_y_bf011e",
|
||||
&[("line", (line).to_string())],
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let holiday = Holiday {
|
||||
country,
|
||||
date: parsed.unwrap(),
|
||||
name: row[name].into(),
|
||||
};
|
||||
let key = (holiday.country.clone(), holiday.date);
|
||||
if let Some(previous) = seen.insert(key.clone(), holiday.name.clone()) {
|
||||
if previous == holiday.name {
|
||||
preview.duplicates += 1;
|
||||
} else {
|
||||
preview.errors.push(crate::i18n::message(
|
||||
"err_row_line_two_different_names_for_p0_on_p1_4c127b",
|
||||
&[
|
||||
("line", (line).to_string()),
|
||||
("p0", (holiday.country).to_string()),
|
||||
("p1", (holiday.date.format("%d-%m-%Y")).to_string()),
|
||||
],
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = existing.get(&key) {
|
||||
if name == &holiday.name {
|
||||
preview.existing += 1;
|
||||
} else {
|
||||
preview.conflicts.push(crate::i18n::message(
|
||||
"err__p0_p1_existing_p2_is_kept_p3_will_not_replace_i_053cc0",
|
||||
&[
|
||||
("p0", (holiday.country).to_string()),
|
||||
("p1", (holiday.date.format("%d-%m-%Y")).to_string()),
|
||||
("p2", (name).to_string()),
|
||||
("p3", (holiday.name).to_string()),
|
||||
],
|
||||
));
|
||||
}
|
||||
} else {
|
||||
preview.additions.push(holiday);
|
||||
}
|
||||
}
|
||||
if saved.len() + preview.additions.len() > 2000 {
|
||||
preview
|
||||
.errors
|
||||
.push(crate::i18n::text("err_the_team_would_exceed_2_000_holidays_496996").into());
|
||||
}
|
||||
preview
|
||||
.additions
|
||||
.sort_by(|a, b| (&a.country, a.date).cmp(&(&b.country, b.date)));
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn csv_handles_bom_quotes_countries_duplicates_and_preserves_existing_names() {
|
||||
let saved = vec![Holiday {
|
||||
country: "CY".into(),
|
||||
date: "2026-01-01".parse().unwrap(),
|
||||
name: "New Year".into(),
|
||||
}];
|
||||
let p=compare("\u{feff}date,name,country\r\n2026-01-01,New Year,CY\r\n2026-01-01,\"New Year, holiday\",am\r\n2026-01-01,\"New Year, holiday\",AM\r\n2026-12-25,Christmas,CY",&saved).unwrap();
|
||||
assert!(p.errors.is_empty());
|
||||
assert_eq!(p.existing, 1);
|
||||
assert_eq!(p.duplicates, 1);
|
||||
assert_eq!(p.additions.len(), 2);
|
||||
let p = compare("country,date,name\nCY,2026-01-01,Changed", &saved).unwrap();
|
||||
assert!(p.additions.is_empty());
|
||||
assert_eq!(p.conflicts.len(), 1);
|
||||
assert_eq!(saved[0].name, "New Year");
|
||||
}
|
||||
#[test]
|
||||
fn invalid_rows_and_conflicting_input_duplicates_block_import() {
|
||||
let p=compare("country,date,name\nXX,2026-01-01,Invalid country\nCY,2026-02-30,Invalid date\nAM,2026-01-01,One\nAM,2026-01-01,Two\nCY,2026-01-01",&[]).unwrap();
|
||||
assert_eq!(p.errors.len(), 4);
|
||||
assert!(
|
||||
!compare("date,label\n2026-01-01,Holiday", &[])
|
||||
.unwrap()
|
||||
.errors
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user