Split out CSV into a module

AfoHT
Sep 3, 2022, 7:44 PM
5USC3DZ3KIU47LIPFHWDFJB7IXH4A7FNGTCD2LXPSIK4S4GEX6KAC

Dependencies

  • [2] 4DJWIQSI Implement cargo clippy suggestions
  • [3] J2PVPX3P Print rows in original order Change from a Vec to VecDeque
  • [4] 3HZYHDXT Trim whitespace
  • [5] V6WMVNO6 GnuCash import quotes values, expecting them to use comma (,) with Swedish SEK Achieve this by once again replacing period to comma for final output
  • [6] ZQNDNT3K Handle argument errors, provide help
  • [7] YFZX4FSZ Generate transaction numbers
  • [8] ZEUBLA35 Do not have subaccounts for Brukskonto
  • [9] Q4VYTFJ7 Add saldo/balance in the Note field
  • [10] LDUI5PR2 Change to using STDIO
  • [11] QDZOD3MN Able to parse CSV
  • [12] ZKSXZMQF Provide feedback when done
  • [13] 4BWPI66V Add first mapping attempt
  • [14] R5EWGEJK Add account actions, default to Brukskonto

Change contents

  • replacement in src/main.rs at line 1
    [6.48][6.0:24](),[6.24][3.0:32](),[3.32][6.24:38](),[6.24][6.24:38](),[6.38][6.49:72](),[6.48][6.49:72](),[6.13][6.85:103](),[6.85][6.85:103]()
    use serde::Deserialize;
    use std::collections::VecDeque;
    use std::env;
    use std::error::Error;
    use std::process;
    [6.48]
    [6.155]
    use std::{env, path::Path, process};
  • replacement in src/main.rs at line 3
    [6.156][6.156:508]()
    // By default, struct field names are deserialized based on the position of
    // a corresponding field in the CSV data's header record.
    #[derive(Debug, Deserialize)]
    #[serde(rename_all = "PascalCase")]
    struct IcaTransaction {
    datum: String,
    text: String,
    typ: String,
    budgetgrupp: String,
    belopp: String,
    saldo: Option<String>,
    }
    [6.156]
    [6.508]
    mod ica_csv;
    use crate::ica_csv::parse_csv;
  • edit in src/main.rs at line 6
    [6.509][6.509:1095](),[6.1095][6.0:29](),[6.29][6.1127:1229](),[6.1127][6.1127:1229](),[6.1229][6.39:278](),[6.278][4.0:30](),[4.30][6.278:327](),[6.278][6.278:327](),[6.327][6.1274:1370](),[6.1274][6.1274:1370](),[6.32][6.1463:1464](),[6.85][6.1463:1464](),[6.1463][6.1463:1464](),[6.1464][6.328:350](),[6.350][3.33:72](),[3.72][6.350:421](),[6.350][6.350:421](),[6.421][6.145:203](),[6.145][6.145:203](),[6.87][6.1528:1952](),[6.203][6.1528:1952](),[6.1528][6.1528:1952](),[6.1952][6.204:205](),[6.205][6.422:467](),[6.60][6.230:231](),[6.467][6.230:231](),[6.230][6.230:231](),[6.231][3.73:147](),[3.147][6.532:822](),[6.532][6.532:822](),[6.231][6.1952:2132](),[6.822][6.1952:2132](),[6.1952][6.1952:2132](),[6.85][6.85:86](),[6.86][6.124:171](),[6.254][6.124:171](),[6.124][6.124:171](),[6.171][6.0:39](),[6.39][6.171:226](),[6.171][6.171:226](),[6.226][6.87:493](),[6.493][6.40:174](),[6.174][6.493:633](),[6.493][6.493:633](),[6.633][6.226:295](),[6.891][6.226:295](),[6.226][6.226:295](),[6.295][6.175:232](),[6.232][6.523:534](),[6.523][6.523:534](),[6.534][6.0:115](),[6.147][6.147:335](),[6.335][2.0:367](),[2.367][6.534:535](),[6.806][6.534:535](),[6.1400][6.534:535](),[6.534][6.534:535](),[6.535][6.823:1203](),[6.1203][6.1203:1204](),[6.1204][3.148:187](),[3.187][6.563:661](),[6.1238][6.563:661](),[6.563][6.563:661](),[6.728][6.728:751](),[6.751][6.1239:1361](),[6.1361][6.751:824](),[6.751][6.751:824](),[6.824][6.807:901](),[6.901][5.0:68](),[5.68][6.951:1001](),[6.951][6.951:1001](),[6.1001][6.847:1252](),[6.847][6.847:1252](),[6.1252][5.69:123](),[5.123][6.1606:1764](),[6.1437][6.1606:1764](),[6.1606][6.1606:1764](),[6.1764][6.1362:1374](),[6.1374][6.2166:2172](),[6.1777][6.2166:2172](),[6.2166][6.2166:2172](),[6.2172][6.1375:1511](),[6.1511][6.2172:2173](),[6.2172][6.2172:2173](),[6.2173][6.1512:1568](),[6.1568][3.188:226](),[3.226][6.1601:1635](),[6.1601][6.1601:1635](),[6.1635][3.227:556](),[3.556][6.1762:1800](),[6.1762][6.1762:1800](),[6.1800][6.2173:2184](),[6.2173][6.2173:2184](),[6.2184][6.0:3]()
    // By default, struct field names are deserialized based on the position of
    // a corresponding field in the CSV data's header record.
    #[derive(Debug, Deserialize)]
    #[serde(rename_all = "PascalCase")]
    struct GCTransaction {
    date: String,
    transaction_id: String,
    number: String,
    description: Option<String>,
    notes: Option<String>,
    commodity_currency: Option<String>,
    void_reason: Option<String>,
    action: Option<String>,
    memo: Option<String>,
    full_account_name: Option<String>,
    account_name: Option<String>,
    amount_with_sym: Option<String>,
    amount_num: Option<f32>,
    reconcile: Option<String>,
    reconcile_date: Option<String>,
    rate_price: Option<String>,
    }
    fn parse_csv(args: Vec<String>) -> Result<(), Box<dyn Error>> {
    // Read the CSV file
    // assuming it has a header
    #[cfg(not(pipe))]
    let mut rdr = csv::ReaderBuilder::new()
    .has_headers(true)
    .delimiter(b';')
    .trim(csv::Trim::All)
    .from_path(&args[1])?;
    #[cfg(pipe)]
    let mut rdr = csv::ReaderBuilder::new()
    .has_headers(true)
    .delimiter(b';')
    #[cfg(not(pipe))]
    // Truncates any pre-existing file
    let mut wtr = csv::Writer::from_path(&args[2])?;
    #[cfg(pipe)]
    let mut wtr = csv::Writer::from_writer(io::stdout());
    // Write headers manually.
    wtr.write_record(&[
    "Date",
    "Transaction ID",
    "Number",
    "Description",
    "Notes",
    "Commodity/Currency",
    "Void Reason",
    "Action",
    "Memo",
    "Full Account Name",
    "Account Name",
    "Amount With Sym.",
    "Amount Num",
    "Reconcile",
    "Reconcile Date",
    "Rate/Price",
    ])?;
    let mut last_seen_date = "".to_string();
    let mut transaction_buffer: VecDeque<[String; 16]> = VecDeque::new();
    // The file is read from top to bottom, ICA exorts
    // the most recent transactions at the top
    // thus the deserialization goes "backwards" in time
    //
    // This is of special importance when parsing transaction_number
    // with multiple transactions during the same day
    for result in rdr.deserialize() {
    // Notice that we need to provide a type hint for automatic
    // deserialization.
    let record: IcaTransaction = result?;
    // Match based on action which account
    // Default match on Brukskonto
    let account_name = match record.typ.as_str() {
    "E-faktura" => "Tillgångar:Likvida Medel:Brukskonto",
    "Pg-bg" => "Tillgångar:Likvida Medel:Brukskonto",
    "Autogiro" => "Tillgångar:Likvida Medel:Brukskonto",
    "Korttransaktion" => "Tillgångar:Likvida Medel:Brukskonto",
    "Uttag" => "Tillgångar:Likvida Medel:Brukskonto",
    "Utlandsbetalning" => "Tillgångar:Likvida Medel:Brukskonto",
    "Försäkring" => "Tillgångar:Likvida Medel:Brukskonto",
    "Fritid" => "Tillgångar:Likvida Medel:Brukskonto",
    "Övrigt" => "Tillgångar:Likvida Medel:Brukskonto",
    "Reserverat Belopp" => "Tillgångar:Likvida Medel:Brukskonto",
    "Insättning" => "Tillgångar:Likvida Medel:Brukskonto",
    _ => "Tillgångar:Likvida Medel:Brukskonto",
    };
    let amount_num = record
    .belopp
    .replace(" kr", "")
    .replace(",", ".")
    .chars()
    .filter(|c| c.is_ascii())
    .filter(|c| !c.is_whitespace())
    .collect::<String>()
    .parse::<f32>()
    .unwrap();
    let amount_balance = record.saldo.map(|saldo| {
    saldo
    .replace(" kr", "")
    .replace(",", ".")
    .chars()
    .filter(|c| c.is_ascii())
    .filter(|c| !c.is_whitespace())
    .collect::<String>()
    .parse::<f32>()
    .unwrap()
    });
    // If this is the second time a date appears
    if last_seen_date == record.datum {
    // Store the record in write buffer
    } else {
    // A fresh new date, print all buffer contents
    write_csv(&mut wtr, &mut transaction_buffer)?;
    // Set the last seen date
    last_seen_date = record.datum.clone();
    }
    transaction_buffer.push_back([
    // Date
    record.datum,
    // Transaction ID, let GnuCash generate
    "".into(),
    // Number, 1 by default,
    // if multiple in a day this gets overwritten
    1.to_string(),
    // Description
    record.text,
    // Notes
    // Extra, the account balance stored in a note
    match amount_balance {
    Some(value) => value.to_string().replace(".", ","),
    None => "".into(),
    },
    // Currency
    "CURRENCY::SEK".into(),
    // Void Reason
    "".into(),
    // Action
    record.typ,
    // Memo
    "".into(),
    // Full Account Name",
    account_name.into(),
    // Account Name",
    "".into(),
    // Amount With Sym.",
    "".into(),
    // Amount Num",
    amount_num.to_string().replace(".", ","),
    // Reconcile",
    "n".into(),
    // Reconcile Date",
    "".into(),
    // Rate/Price",
    "1".into(),
    ]);
    }
    // Make sure any unprinted lines still in the buffer gets printed
    write_csv(&mut wtr, &mut transaction_buffer)?;
    Ok(())
    }
    fn write_csv(
    wtr: &mut csv::Writer<std::fs::File>,
    buf: &mut VecDeque<[String; 16]>,
    ) -> Result<(), Box<dyn Error>> {
    // Iterate in reverse order to assign correct transaction_number
    for (counter, row) in buf.iter_mut().rev().enumerate() {
    let transaction_number = counter + 1;
    row[2] = transaction_number.to_string();
    }
    // Write CSV in original order, most recent first
    while let Some(row) = buf.pop_front() {
    wtr.write_record(row)?;
    }
    Ok(())
    }
  • replacement in src/main.rs at line 24
    [6.1852][6.571:595]()
    if args.len() < 2 {
    [6.1852]
    [6.595]
    if args.is_empty() {
  • replacement in src/main.rs at line 31
    [6.1937][6.1937:2027](),[6.2027][6.2285:2311](),[6.2285][6.2285:2311](),[6.2311][6.0:40]()
    if let Err(err) = parse_csv(args) {
    println!("Unable to parse CSV: {}", err);
    process::exit(1);
    } else {
    println!("Done!");
    [6.1937]
    [6.2311]
    let input_path = Path::new(&args[1]);
    if let Some(extension_type) = input_path.extension() {
    match extension_type.to_str() {
    Some("csv") => {
    if let Err(err) = parse_csv(args) {
    println!("Unable to parse CSV: {}", err);
    process::exit(1);
    } else {
    println!("Done! CSV Parsed");
    }
    }
    _ => {
    println!("Unsupported file format, CSV supported. Exiting");
    process::exit(1);
    }
    }
  • edit in src/main.rs at line 49
    [6.1370][4.31:61](),[4.61][6.14:49](),[6.1370][6.14:49]()
    .trim(csv::Trim::All)
    .from_reader(io::stdin());
  • resolve order conflict in src/main.rs at line 49
    [6.2319]
  • file addition: ica_csv.rs (----------)
    [6.15]
    use serde::Deserialize;
    use std::{collections::VecDeque, error::Error};
    // By default, struct field names are deserialized based on the position of
    // a corresponding field in the CSV data's header record.
    #[derive(Debug, Deserialize)]
    #[serde(rename_all = "PascalCase")]
    struct IcaTransaction {
    datum: String,
    text: String,
    typ: String,
    budgetgrupp: String,
    belopp: String,
    saldo: Option<String>,
    }
    // By default, struct field names are deserialized based on the position of
    // a corresponding field in the CSV data's header record.
    #[derive(Debug, Deserialize)]
    #[serde(rename_all = "PascalCase")]
    struct GCTransaction {
    date: String,
    transaction_id: String,
    number: String,
    description: Option<String>,
    notes: Option<String>,
    commodity_currency: Option<String>,
    void_reason: Option<String>,
    action: Option<String>,
    memo: Option<String>,
    full_account_name: Option<String>,
    account_name: Option<String>,
    amount_with_sym: Option<String>,
    amount_num: Option<f32>,
    value_with_sym: Option<String>,
    value_num: Option<f32>,
    reconcile: Option<String>,
    reconcile_date: Option<String>,
    rate_price: Option<String>,
    }
    pub fn parse_csv(args: Vec<String>) -> Result<(), Box<dyn Error>> {
    let input_file = &args[1];
    let output_file = &args[2];
    // Read the CSV file
    // assuming it has a header
    #[cfg(not(pipe))]
    let mut rdr = csv::ReaderBuilder::new()
    .has_headers(true)
    .delimiter(b';')
    .trim(csv::Trim::All)
    .from_path(input_file)?;
    #[cfg(pipe)]
    let mut rdr = csv::ReaderBuilder::new()
    .has_headers(true)
    .delimiter(b';')
    .trim(csv::Trim::All)
    .from_reader(io::stdin());
    #[cfg(not(pipe))]
    // Truncates any pre-existing file
    let mut wtr = csv::Writer::from_path(output_file)?;
    #[cfg(pipe)]
    let mut wtr = csv::Writer::from_writer(io::stdout());
    // Write headers manually.
    wtr.write_record([
    "Date",
    "Transaction ID",
    "Number",
    "Description",
    "Notes",
    "Commodity/Currency",
    "Void Reason",
    "Action",
    "Memo",
    "Full Account Name",
    "Account Name",
    "Amount With Sym.",
    "Amount Num",
    "Value With Sym.",
    "Value Num",
    "Reconcile",
    "Reconcile Date",
    "Rate/Price",
    ])?;
    let mut last_seen_date = "".to_string();
    let mut transaction_buffer: VecDeque<[String; 18]> = VecDeque::new();
    // The file is read from top to bottom, ICA exorts
    // the most recent transactions at the top
    // thus the deserialization goes "backwards" in time
    //
    // This is of special importance when parsing transaction_number
    // with multiple transactions during the same day
    for result in rdr.deserialize() {
    // Notice that we need to provide a type hint for automatic
    // deserialization.
    let record: IcaTransaction = result?;
    // Match based on action which account
    // Default match on Brukskonto
    let account_name = match record.typ.as_str() {
    "E-faktura" => "Tillgångar:Likvida Medel:Brukskonto",
    "Pg-bg" => "Tillgångar:Likvida Medel:Brukskonto",
    "Autogiro" => "Tillgångar:Likvida Medel:Brukskonto",
    "Korttransaktion" => "Tillgångar:Likvida Medel:Brukskonto",
    "Uttag" => "Tillgångar:Likvida Medel:Brukskonto",
    "Utlandsbetalning" => "Tillgångar:Likvida Medel:Brukskonto",
    "Försäkring" => "Tillgångar:Likvida Medel:Brukskonto",
    "Fritid" => "Tillgångar:Likvida Medel:Brukskonto",
    "Övrigt" => "Tillgångar:Likvida Medel:Brukskonto",
    "Reserverat Belopp" => "Tillgångar:Likvida Medel:Brukskonto",
    "Insättning" => "Tillgångar:Likvida Medel:Brukskonto",
    _ => "Tillgångar:Likvida Medel:Brukskonto",
    };
    let amount_num = record
    .belopp
    .replace(" kr", "")
    .replace(',', ".")
    .chars()
    .filter(|c| c.is_ascii())
    .filter(|c| !c.is_whitespace())
    .collect::<String>()
    .parse::<f32>()
    .unwrap();
    let amount_balance = record.saldo.map(|saldo| {
    saldo
    .replace(" kr", "")
    .replace(',', ".")
    .chars()
    .filter(|c| c.is_ascii())
    .filter(|c| !c.is_whitespace())
    .collect::<String>()
    .parse::<f32>()
    .unwrap()
    });
    // If this is the second time a date appears
    if last_seen_date == record.datum {
    // Store the record in write buffer
    } else {
    // A fresh new date, print all buffer contents
    write_csv(&mut wtr, &mut transaction_buffer)?;
    // Set the last seen date
    last_seen_date = record.datum.clone();
    }
    transaction_buffer.push_back([
    // Date
    record.datum,
    // Transaction ID, let GnuCash generate
    "".into(),
    // Number, 1 by default,
    // if multiple in a day this gets overwritten
    1.to_string(),
    // Description
    record.text,
    // Notes
    // Extra, the account balance stored in a note
    match amount_balance {
    Some(value) => value.to_string().replace('.', ","),
    None => "".into(),
    },
    // Currency
    "CURRENCY::SEK".into(),
    // Void Reason
    "".into(),
    // Action
    record.typ,
    // Memo
    "".into(),
    // Full Account Name,
    account_name.into(),
    // Account Name,
    "".into(),
    // Amount With Sym.,
    "".into(),
    // Amount Num,
    amount_num.to_string().replace('.', ","),
    // Value With Sym.,
    "".into(),
    // Value Num,
    amount_num.to_string().replace('.', ","),
    // Reconcile,
    "n".into(),
    // Reconcile Date,
    "".into(),
    // Rate/Price,
    "1".into(),
    ]);
    }
    // Make sure any unprinted lines still in the buffer gets printed
    write_csv(&mut wtr, &mut transaction_buffer)?;
    Ok(())
    }
    fn write_csv(
    wtr: &mut csv::Writer<std::fs::File>,
    buf: &mut VecDeque<[String; 18]>,
    ) -> Result<(), Box<dyn Error>> {
    // Iterate in reverse order to assign correct transaction_number
    for (counter, row) in buf.iter_mut().rev().enumerate() {
    let transaction_number = counter + 1;
    row[2] = transaction_number.to_string();
    }
    // Write CSV in original order, most recent first
    while let Some(row) = buf.pop_front() {
    wtr.write_record(row)?;
    }
    Ok(())
    }