|
@@ -0,0 +1,153 @@
|
|
|
+use crate::data::{AccountName, Datestamp, Decimal, spec::{AccountSpec, ImportFileFormat, CSVImportSpec, CSVColumnSpec}, ledger::{Change, Transaction}, Span, Spanned, UnitName};
|
|
|
+
|
|
|
+#[derive(Debug)]
|
|
|
+pub enum ImportError {
|
|
|
+ IOError(std::io::Error),
|
|
|
+ ConfigError(String),
|
|
|
+ InputError(String),
|
|
|
+ CSVError(csv::Error),
|
|
|
+}
|
|
|
+
|
|
|
+impl From<std::io::Error> for ImportError {
|
|
|
+ fn from(value: std::io::Error) -> Self {
|
|
|
+ Self::IOError(value)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+impl From<csv::Error> for ImportError {
|
|
|
+ fn from(value: csv::Error) -> Self {
|
|
|
+ Self::CSVError(value)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+impl From<strptime::ParseError> for ImportError {
|
|
|
+ fn from(value: strptime::ParseError) -> Self {
|
|
|
+ Self::InputError(value.to_string())
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+impl From<rust_decimal::Error> for ImportError {
|
|
|
+ fn from(value: rust_decimal::Error) -> Self {
|
|
|
+ Self::InputError(value.to_string())
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+fn try_parse_decimal(from: &str) -> Result<Decimal, ImportError> {
|
|
|
+ // remove any '$' or units from the string
|
|
|
+ let filtered = from.chars().filter(|f| char::is_digit(*f, 10) || *f == '.' || *f == '-').collect::<String>();
|
|
|
+
|
|
|
+ Decimal::from_str_radix(filtered.as_str(), 10).map_err(Into::into)
|
|
|
+}
|
|
|
+
|
|
|
+fn import_from_csv(csv_spec: &CSVImportSpec, aspec: &AccountSpec, target: AccountName, mut reader: impl std::io::Read) -> Result<Vec<Transaction>, ImportError> {
|
|
|
+ let mut csv_reader = csv::Reader::from_reader(reader);
|
|
|
+
|
|
|
+ // validate CSV spec
|
|
|
+ if !csv_spec.cols.contains(&CSVColumnSpec::Datestamp) {
|
|
|
+ return Err(ImportError::ConfigError("CSV config does not have a datestamp column".into()))
|
|
|
+ }
|
|
|
+
|
|
|
+ if !csv_spec.cols.contains(&CSVColumnSpec::Change) {
|
|
|
+ if !csv_spec.cols.contains(&CSVColumnSpec::Withdraw) || !csv_spec.cols.contains(&CSVColumnSpec::Deposit) {
|
|
|
+ return Err(ImportError::ConfigError("CSV config needs either a change column or both withdraw and deposit columns!".into()))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // strptime is silly and wants a &'static format string
|
|
|
+ let date_format = Box::leak(csv_spec.date_format.clone().into_boxed_str());
|
|
|
+ let date_parser = strptime::Parser::new(date_format);
|
|
|
+
|
|
|
+ let unbalanced = AccountName::new("unbalanced");
|
|
|
+
|
|
|
+ let mut txns = vec![];
|
|
|
+
|
|
|
+ for record in csv_reader.records() {
|
|
|
+ let record = record?;
|
|
|
+
|
|
|
+ let mut txn_datestamp : Option<Datestamp> = None;
|
|
|
+ let mut txn_title : Option<String> = None;
|
|
|
+ let mut txn_change : Option<Decimal> = None;
|
|
|
+ let mut txn_balance : Option<Decimal> = None;
|
|
|
+ let mut txn_unit : Option<UnitName> = None;
|
|
|
+
|
|
|
+ for (record, spec) in record.iter().zip(csv_spec.cols.iter()) {
|
|
|
+ match spec {
|
|
|
+ CSVColumnSpec::Ignore => (),
|
|
|
+ CSVColumnSpec::Datestamp => {
|
|
|
+ let date = date_parser.parse(record)?.date()?;
|
|
|
+ txn_datestamp = Some(Datestamp { year: date.year() as u16, month: date.month(), day: date.day() });
|
|
|
+ },
|
|
|
+ CSVColumnSpec::Title => {
|
|
|
+ txn_title = Some(record.into());
|
|
|
+ },
|
|
|
+ CSVColumnSpec::Deposit => {
|
|
|
+ if record.trim().is_empty() {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ txn_change = Some(try_parse_decimal(record)?);
|
|
|
+ },
|
|
|
+ CSVColumnSpec::Withdraw => {
|
|
|
+ if record.trim().is_empty() {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ let mut dec = try_parse_decimal(record)?;
|
|
|
+ dec.set_sign_negative(true);
|
|
|
+ txn_change = Some(dec);
|
|
|
+ },
|
|
|
+ CSVColumnSpec::Change => {
|
|
|
+ if record.trim().is_empty() {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ txn_change = Some(try_parse_decimal(record)?);
|
|
|
+ },
|
|
|
+ CSVColumnSpec::Balance => {
|
|
|
+ if record.trim().is_empty() {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ txn_balance = Some(try_parse_decimal(record)?);
|
|
|
+ },
|
|
|
+ CSVColumnSpec::Unit => {
|
|
|
+ txn_unit = Some(UnitName::new(record));
|
|
|
+ },
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ txns.push(Transaction {
|
|
|
+ datestamp: txn_datestamp.unwrap(),
|
|
|
+ title: txn_title,
|
|
|
+ annotations: vec![],
|
|
|
+ changes: vec![
|
|
|
+ Change {
|
|
|
+ account: target.into(),
|
|
|
+ amount: txn_change.unwrap().into(),
|
|
|
+ balance: txn_balance.map(Into::into),
|
|
|
+ unit: txn_unit.or(aspec.unit).map(Into::into).unwrap(),
|
|
|
+ }.into(),
|
|
|
+
|
|
|
+ Change {
|
|
|
+ account: unbalanced.into(),
|
|
|
+ amount: txn_change.unwrap().into(),
|
|
|
+ balance: txn_balance.map(Into::into),
|
|
|
+ unit: txn_unit.or(aspec.unit).map(Into::into).unwrap(),
|
|
|
+ }.into(),
|
|
|
+ ]
|
|
|
+ });
|
|
|
+
|
|
|
+ // println!("{txn_datestamp:?}: {txn_title:?}");
|
|
|
+ // println!("- account: {txn_change:?} = {txn_balance:?} {txn_unit:?}");
|
|
|
+ }
|
|
|
+
|
|
|
+ Ok(txns)
|
|
|
+}
|
|
|
+
|
|
|
+pub fn import_from(aspec: &AccountSpec, target: AccountName, path: &std::path::Path) -> Result<Vec<Transaction>, ImportError> {
|
|
|
+ let reader = std::fs::File::open(path)?;
|
|
|
+
|
|
|
+ match &aspec.import {
|
|
|
+ Some(ImportFileFormat::CSV(csv)) => import_from_csv(csv, aspec, target, reader),
|
|
|
+ None => Err(ImportError::ConfigError(format!("no import configuration for {target}"))),
|
|
|
+ }
|
|
|
+}
|