categories.rs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. #[cfg(feature = "telegram")]
  2. use crate::ui::input_category;
  3. use libc::isatty;
  4. use radix_trie::Trie;
  5. use serde::{Deserialize, Serialize};
  6. use std::cmp::Ordering;
  7. use std::collections::HashSet;
  8. /// Category statistics for single item
  9. #[derive(Serialize, Deserialize, Debug)]
  10. pub struct CatStat {
  11. /// Category name
  12. category: String,
  13. /// How many times did the item hit this category
  14. hits: i64,
  15. }
  16. impl Ord for CatStat {
  17. fn cmp(&self, other: &Self) -> Ordering {
  18. self.hits.cmp(&other.hits)
  19. }
  20. }
  21. impl PartialOrd for CatStat {
  22. fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
  23. Some(self.cmp(other))
  24. }
  25. }
  26. impl PartialEq for CatStat {
  27. fn eq(&self, other: &Self) -> bool {
  28. self.hits == other.hits
  29. }
  30. }
  31. impl Eq for CatStat {}
  32. pub type CatStats = Trie<String, Vec<CatStat>>;
  33. /// Insert new `cat` into statistics vector or add single usage to existing cat
  34. fn update_stat(cat: &str, stat: &mut Vec<CatStat>) {
  35. let existing = stat.iter_mut().find(|stat| stat.category == cat);
  36. match existing {
  37. Some(e) => e.hits += 1,
  38. None => stat.push(CatStat {
  39. category: String::from(cat),
  40. hits: 1,
  41. }),
  42. }
  43. stat.sort_by(|a, b| b.cmp(a));
  44. }
  45. /// Set up `cat` as category for `item`: update statistics or create new item
  46. /// in `storage`
  47. pub fn assign_category(item: &str, cat: &str, storage: &mut CatStats) {
  48. if cat.is_empty() {
  49. panic!("Do not assign empty category!")
  50. }
  51. let existing = storage.get_mut(item);
  52. match existing {
  53. Some(stat) => update_stat(cat, stat),
  54. None => {
  55. let newstat = CatStat {
  56. category: String::from(cat),
  57. hits: 1,
  58. };
  59. storage.insert(String::from(item), vec![newstat]);
  60. }
  61. }
  62. }
  63. /// Return most probable category for provided `item`
  64. pub fn get_top_category<'a>(item: &str, storage: &'a CatStats) -> Option<&'a str> {
  65. storage.get(item).map(|s| -> &'a str { &s[0].category })
  66. }
  67. /// Choose proper category or ask user
  68. pub fn get_category(item: &str, storage: &mut CatStats, accounts: &HashSet<String>) -> String {
  69. let istty = unsafe { isatty(libc::STDOUT_FILENO) } != 0;
  70. if istty {
  71. let topcat = match get_top_category(item, storage) {
  72. Some(cat) => String::from(cat),
  73. None => String::new(),
  74. };
  75. let cats: Vec<&String> = accounts
  76. .iter()
  77. .filter(|acc| acc.contains("Expenses:"))
  78. .collect();
  79. let cat = input_category(item, &topcat, &cats);
  80. if cat.is_empty() {
  81. topcat
  82. } else {
  83. assign_category(item, &cat, storage);
  84. cat
  85. }
  86. } else {
  87. match get_top_category(item, storage) {
  88. Some(cat) => String::from(cat),
  89. None => String::new(),
  90. }
  91. }
  92. }
  93. pub struct LineFilter<F>
  94. where
  95. F: Fn(&str) -> &str,
  96. {
  97. filter: F,
  98. }
  99. impl LineFilter<fn(&str) -> &str> {
  100. pub fn new() -> Self {
  101. Self {
  102. filter: |input| input,
  103. }
  104. }
  105. }
  106. impl<F> LineFilter<F>
  107. where
  108. F: Fn(&str) -> &str,
  109. {
  110. pub fn numfilter(self) -> LineFilter<impl Fn(&str) -> &str> {
  111. LineFilter {
  112. filter: move |input| {
  113. let intermediate = (self.filter)(input);
  114. intermediate
  115. .trim_start()
  116. .trim_start_matches(char::is_numeric)
  117. .trim_start()
  118. },
  119. }
  120. }
  121. pub fn perekrestok_filter(self) -> LineFilter<impl Fn(&str) -> &str> {
  122. LineFilter {
  123. filter: move |input| {
  124. let intermediate = (self.filter)(input);
  125. intermediate
  126. .trim_start()
  127. .trim_start_matches(char::is_numeric)
  128. .trim_start_matches(['*', ':', ' '])
  129. .trim_start()
  130. },
  131. }
  132. }
  133. pub fn trim_units_from_end(self) -> LineFilter<impl Fn(&str) -> &str> {
  134. LineFilter {
  135. filter: move |input| {
  136. let intermediate = (self.filter)(input);
  137. let units = ["кг", "г", "мл", "л", "шт"];
  138. let mut trimmed = intermediate;
  139. loop {
  140. let original = trimmed;
  141. for unit in &units {
  142. if trimmed.ends_with(unit) {
  143. trimmed = trimmed
  144. .trim_end_matches(unit)
  145. .trim_end_matches(',')
  146. .trim_end();
  147. }
  148. }
  149. // Trim any numeric characters and commas at the end.
  150. trimmed = trimmed
  151. .trim_end_matches(char::is_numeric)
  152. .trim_end_matches(',')
  153. .trim_end();
  154. // If no changes were made in this iteration, break the loop.
  155. if trimmed == original {
  156. break;
  157. }
  158. }
  159. trimmed
  160. },
  161. }
  162. }
  163. pub fn build(self) -> impl Fn(&str) -> &str {
  164. self.filter
  165. }
  166. }
  167. #[cfg(test)]
  168. mod tests {
  169. use super::*;
  170. #[test]
  171. fn test_update_stat() {
  172. let mut stat: Vec<CatStat> = Vec::new();
  173. update_stat("test", &mut stat);
  174. assert_eq!(stat[0].hits, 1);
  175. assert_eq!(stat[0].category, "test");
  176. update_stat("test", &mut stat);
  177. assert_eq!(stat[0].hits, 2);
  178. update_stat("test2", &mut stat);
  179. assert_eq!(stat[1].category, "test2");
  180. assert_eq!(stat[1].hits, 1);
  181. update_stat("test2", &mut stat);
  182. update_stat("test2", &mut stat);
  183. assert_eq!(stat[0].category, "test2");
  184. assert_eq!(stat[0].hits, 3);
  185. assert_eq!(stat[1].hits, 2);
  186. assert_eq!(stat[1].category, "test");
  187. }
  188. #[test]
  189. fn test_assign_category() {
  190. let mut cm: Trie<String, Vec<CatStat>> = Trie::new();
  191. assign_category("item", "category", &mut cm);
  192. let stats = cm.get("item").unwrap();
  193. assert_eq!(stats[0].category, "category");
  194. assert_eq!(stats[0].hits, 1);
  195. let topcat = get_top_category("item", &cm).unwrap();
  196. assert_eq!(topcat, "category");
  197. }
  198. #[test]
  199. fn test_new() {
  200. let filter = LineFilter::new();
  201. assert_eq!(filter.build()("Hello"), "Hello");
  202. }
  203. #[test]
  204. fn test_numfilter() {
  205. let filter = LineFilter::new().numfilter();
  206. assert_eq!(filter.build()("123Hello"), "Hello");
  207. }
  208. #[test]
  209. fn test_perekrestok_filter() {
  210. let filter = LineFilter::new().perekrestok_filter();
  211. assert_eq!(filter.build()("123: *Hello"), "Hello");
  212. }
  213. #[test]
  214. fn test_chaining() {
  215. let filter = LineFilter::new().numfilter().perekrestok_filter();
  216. assert_eq!(filter.build()("123: *Hello"), "Hello");
  217. }
  218. #[test]
  219. fn test_trim_no_unit() {
  220. let filter = LineFilter::new().trim_units_from_end().build();
  221. assert_eq!(filter("Apple Juice"), "Apple Juice");
  222. }
  223. #[test]
  224. fn test_trim_kg() {
  225. let filter = LineFilter::new().trim_units_from_end().build();
  226. assert_eq!(filter("Oranges 2кг"), "Oranges");
  227. }
  228. #[test]
  229. fn test_trim_g() {
  230. let filter = LineFilter::new().trim_units_from_end().build();
  231. assert_eq!(filter("Salt 500 г"), "Salt");
  232. }
  233. #[test]
  234. fn test_trim_ml() {
  235. let filter = LineFilter::new().trim_units_from_end().build();
  236. assert_eq!(filter("Water 150мл"), "Water");
  237. }
  238. #[test]
  239. fn test_trim_l() {
  240. let filter = LineFilter::new().trim_units_from_end().build();
  241. assert_eq!(filter("Milk 2 л"), "Milk");
  242. }
  243. #[test]
  244. fn test_trim_multiple_spaces() {
  245. let filter = LineFilter::new().trim_units_from_end().build();
  246. assert_eq!(filter("Honey 100 г"), "Honey");
  247. }
  248. #[test]
  249. fn test_trim_with_other_text() {
  250. let filter = LineFilter::new().trim_units_from_end().build();
  251. assert_eq!(filter("Bread 300г Extra"), "Bread 300г Extra");
  252. }
  253. #[test]
  254. fn test_trim_multiple_units() {
  255. let filter = LineFilter::new().trim_units_from_end().build();
  256. assert_eq!(
  257. filter(r#"Сыр "Фитнес" безлактозный, 200г,шт"#),
  258. r#"Сыр "Фитнес" безлактозный"#
  259. );
  260. }
  261. }