Jelajahi Sumber

Add comb command.

Kestrel 1 hari lalu
induk
melakukan
633e3e5a58
4 mengubah file dengan 185 tambahan dan 12 penghapusan
  1. 19 1
      src/cmd.rs
  2. 142 0
      src/cmd/comb.rs
  3. 3 0
      src/data/spec.rs
  4. 21 11
      src/import.rs

+ 19 - 1
src/cmd.rs

@@ -6,6 +6,7 @@ use itertools::Itertools;
 use crate::prelude::*;
 
 mod infer;
+mod comb;
 
 #[derive(clap::Parser)]
 pub struct Invocation {
@@ -57,6 +58,9 @@ pub enum Command {
     Match {
         account: data::AccountName,
     },
+    Comb {
+        account: Option<data::AccountName>,
+    },
 }
 
 fn summarize(data: &data::Hoard) {
@@ -177,9 +181,14 @@ impl Command {
                         .into_os_string()
                         .into();
 
+                    let source_span = io::Span::null_for_file(new_source);
+
                     let new_data = imported
                         .into_iter()
-                        .map(|txn| data::RawLedgerEntry::Transaction(txn))
+                        .map(|mut txn| {
+                            txn.source = Some(source_span);
+                            data::RawLedgerEntry::Transaction(txn)
+                        })
                         .collect_vec();
 
                     data::format_ledger(
@@ -219,6 +228,15 @@ impl Command {
                     *account,
                     txns.into_iter()
                 );
+            },
+            Self::Comb { account } => {
+                let data = load_data(&mut fsdata, inv, check::CheckLevel::Consistent)?;
+
+                let account = account.unwrap_or(data.spec_root().placeholder_account);
+
+                if comb::do_comb(&data, account)? {
+                    data::format_entire_ledger(&mut fsdata, &data)?;
+                }
             }
         }
         Ok(())

+ 142 - 0
src/cmd/comb.rs

@@ -0,0 +1,142 @@
+use std::collections::HashSet;
+
+use crate::prelude::*;
+
+use crate::{show, data::{AccountName, Hoard}};
+
+fn prompt_account(data: &Hoard) -> anyhow::Result<Option<AccountName>> {
+    println!("Enter new account:");
+
+    let mut acc = String::default();
+
+    let stdout = console::Term::stdout();
+
+    println!();
+
+    stdout.move_cursor_up(1)?;
+
+    loop {
+        stdout.clear_line()?;
+        stdout.write_line(format!("{}", console::style(&acc).yellow()).as_str())?;
+        stdout.move_cursor_up(1)?;
+        stdout.move_cursor_right(acc.len())?;
+
+        match console::Term::stdout().read_key()? {
+            console::Key::Backspace => {
+                acc.pop();
+            },
+            console::Key::Enter => {
+                if let Some(r) = data.account_names().find(|an| **an == *acc) {
+                    return Ok(Some(r))
+                }
+            },
+            console::Key::Char(c) => {
+                if !c.is_control() {
+                    acc.push(c);
+                }
+            },
+            console::Key::Tab => {
+                if acc.is_empty() { continue }
+
+                let possibles = data.account_names().filter(|an| an.starts_with(acc.as_str())).collect::<Vec<_>>();
+
+                if possibles.is_empty() { continue }
+
+                let longest = possibles.iter().map(|s| s.len()).min().unwrap();
+
+                let mut longest_prefix = 0;
+                for plen in 1..=longest {
+                    if possibles.iter().all(|p| p.starts_with(&possibles[0][0..plen])) {
+                        longest_prefix = plen;
+                    }
+                }
+
+                acc.clear();
+                acc.push_str(&possibles[0][0..longest_prefix]);
+
+                stdout.clear_to_end_of_screen()?;
+
+                if possibles.len() == 1 {
+                    continue
+                }
+
+                stdout.move_cursor_down(1)?;
+                stdout.clear_line()?;
+
+                let mut line = String::new();
+                let len = possibles.len();
+                for possible in possibles.iter().take(5) {
+                    line.push_str(format!("{} ", console::style(possible).white().dim()).as_str());
+                }
+                if len > 5 {
+                    line.push_str("...");
+                }
+
+                stdout.write_line(line.as_str())?;
+                stdout.move_cursor_up(2)?;
+
+            },
+            console::Key::Escape => {
+                return Ok(None)
+            },
+            _key => (),
+        }
+    }
+}
+
+pub fn do_comb(data: &Hoard, account: AccountName) -> anyhow::Result<bool> {
+    let Some(txns) = data.ledger_data_for(account) else {
+        log::error!("No ledger data available for account {account}");
+        return Ok(false)
+    };
+
+    let mut aborted = false;
+    let mut changed = false;
+
+    let mut ignore = HashSet::<Option<String>>::default();
+
+    for txn_ref in txns {
+        let txn = txn_ref.borrow();
+        if txn.changes.len() != 2 {
+            continue
+        }
+
+        if ignore.contains(&txn.title) {
+            continue
+        }
+
+        println!();
+        show::show_transaction(Some(&data), &txn);
+
+        println!();
+        println!("(I)gnore (S)kip all (E)dit (F)inish (A)bort: ");
+        let action = loop {
+            match console::Term::stdout().read_char().unwrap().to_lowercase().next() {
+                Some('s') => break 's',
+                Some('e') => break 'e',
+                Some('i') => break 'i',
+                Some('f') => break 'f',
+                Some('a') => break 'a',
+                _ => (),
+            }
+        };
+
+        if action == 'i' { () }
+        else if action == 's' {
+            ignore.insert(txn.title.clone());
+        } else if action == 'a' {
+            aborted = true;
+            break 
+        } else if action == 'e' {
+            let Some(new_account) = prompt_account(data)? else { continue };
+            drop(txn);
+            let mut txn = txn_ref.borrow_mut();
+            txn.changes.iter_mut().find(|e| *e.account == account).iter_mut().for_each(|ch| *ch.account = new_account);
+            changed = true;
+        } else if action == 'f' {
+            break
+        }
+    }
+
+    Ok(!aborted && changed)
+}

+ 3 - 0
src/data/spec.rs

@@ -22,6 +22,9 @@ pub struct CsvImportSpec {
     pub skip_end: Option<usize>,
     pub cols: Vec<CsvColumnSpec>,
     pub date_format: String,
+
+    #[serde(default)]
+    pub reverse: bool,
 }
 
 #[derive(Debug, serde::Deserialize)]

+ 21 - 11
src/import.rs

@@ -2,11 +2,7 @@ use std::collections::BTreeSet;
 
 use crate::prelude::*;
 
-/*use crate::data::{
-    AccountName, Datestamp, Decimal, UnitName,
-    ledger::{Change, Transaction},
-    spec::{AccountSpec, CsvColumnSpec, CsvImportSpec, ImportFileFormat},
-};*/
+use crate::data::Decimal;
 
 #[derive(Debug)]
 pub enum ImportError {
@@ -54,10 +50,15 @@ fn import_from_csv(
     csv_spec: &data::spec::CsvImportSpec,
     aspec: &data::spec::AccountSpec,
     account: data::AccountName,
-    target: data::AccountName,
+    transaction_target: data::AccountName,
     reader: impl std::io::Read,
 ) -> Result<Vec<data::Transaction>, ImportError> {
-    let mut csv_reader = csv::Reader::from_reader(reader);
+    let mut csv_reader = csv::ReaderBuilder::new()
+        .has_headers(false)
+        .trim(csv::Trim::All)
+        .from_reader(reader);
+
+    // let mut csv_reader = csv::Reader::from_reader(reader);
 
     // validate CSV spec
     if !csv_spec
@@ -84,7 +85,7 @@ fn import_from_csv(
 
     let mut txns = vec![];
 
-    for record in csv_reader.records() {
+    for record in csv_reader.records().skip(csv_spec.skip_start.unwrap_or(0)) {
         let record = record?;
 
         let mut txn_datestamp: Option<data::Datestamp> = None;
@@ -142,6 +143,15 @@ fn import_from_csv(
             }
         }
 
+        if csv_spec.reverse {
+            if let Some(bal) = txn_balance.as_mut() {
+                *bal = Decimal::ZERO - *bal;
+            }
+            if let Some(amount) = txn_change.as_mut() {
+                *amount = Decimal::ZERO - *amount;
+            }
+        }
+
         txns.push(data::Transaction {
             datestamp: txn_datestamp.unwrap(),
             title: txn_title,
@@ -155,7 +165,7 @@ fn import_from_csv(
                     source: None,
                 },
                 data::Change {
-                    account: target.into(),
+                    account: transaction_target.into(),
                     amount: data::Decimal::ZERO
                         .checked_sub(txn_change.unwrap())
                         .unwrap()
@@ -255,14 +265,14 @@ fn postprocess(account: data::AccountName, transactions: &mut Vec<data::Transact
 pub fn import_from(
     aspec: &data::spec::AccountSpec,
     account: data::AccountName,
-    target: data::AccountName,
+    transaction_target: data::AccountName,
     path: &std::path::Path,
 ) -> Result<Vec<data::Transaction>, ImportError> {
     let reader = std::fs::File::open(path)?;
 
     let mut output = match &aspec.import {
         Some(data::spec::ImportFileFormat::Csv(csv)) => {
-            import_from_csv(csv, aspec, account, target, reader)
+            import_from_csv(csv, aspec, account, transaction_target, reader)
         }
         None => Err(ImportError::ConfigError(format!(
             "no import configuration for {account}"