Line data Source code
1 : // Copyright 2023 The terCAD team. All rights reserved.
2 : // Use of this source code is governed by a CC BY-NC-ND 4.0 license that can be found in the LICENSE file.
3 :
4 : import 'dart:typed_data';
5 :
6 : import 'package:app_finance/_classes/herald/app_locale.dart';
7 : import 'package:app_finance/_classes/storage/file_parser.dart';
8 : import 'package:app_finance/_classes/storage/statement_parser.dart';
9 : import 'package:app_finance/_mixins/file/file_import_mixin.dart';
10 : import 'package:csv/csv.dart';
11 :
12 : typedef FileScope = List<List<dynamic>>;
13 :
14 : class FilePicker with FileImportMixin {
15 : static const csvFormat = 'csv';
16 : static const qifFormat = 'qif';
17 : static const ofxFormat = 'ofx';
18 : static const fileFormats = [csvFormat, qifFormat, ofxFormat, ...StatementParser.formats];
19 : final List<String> ext;
20 : List<String> columnMap = [
21 : FileParser.attrBillUuid,
22 : FileParser.attrBillAmount,
23 : FileParser.attrBillComment,
24 : FileParser.attrCategoryName,
25 : FileParser.attrBillDate,
26 : FileParser.attrBillType,
27 : ];
28 : final List<String> header = [
29 : AppLocale.labels.uuid,
30 : AppLocale.labels.expense,
31 : AppLocale.labels.description,
32 : AppLocale.labels.budget,
33 : AppLocale.labels.balanceDate,
34 : AppLocale.labels.flowTypeTooltip,
35 : ];
36 :
37 : // Pending transactions a statement file holds, left out of the import
38 : int pending = 0;
39 :
40 1 : FilePicker(this.ext);
41 :
42 1 : FileScope parseStatement(String format, Uint8List bytes) {
43 1 : final (:rows, :pending) = StatementParser.parse(format, bytes);
44 1 : this.pending = pending;
45 2 : columnMap = [
46 : FileParser.attrBillUuid,
47 : FileParser.attrBillAmount,
48 : FileParser.attrBillComment,
49 : FileParser.attrBillDate,
50 : FileParser.attrBillType,
51 : FileParser.attrBillCurrency,
52 : '',
53 : ];
54 : return rows;
55 : }
56 :
57 0 : FileScope _parseCsv(String content, String splitter) {
58 0 : final result = CsvToListConverter(eol: splitter).convert(content);
59 0 : columnMap = List<String>.filled(result.first.length, '');
60 : return result;
61 : }
62 :
63 0 : FileScope _parseQif(String content, String splitter) {
64 0 : final scope = content.split(splitter);
65 : int idx = 1;
66 0 : Map<String, int> mapping = {
67 : 'N': 0, // "Number" for the transaction
68 : 'T': 1, // "Total" amount
69 : 'P': 2, // "Payee"
70 : 'L': 3, // "Category/Account Line"
71 : 'D': 4, // "Date"
72 : };
73 : int billType = 5;
74 0 : FileScope result = [header];
75 0 : result.add(List<dynamic>.filled(header.length, null));
76 0 : for (int i = 0; i < scope.length; i++) {
77 0 : if (scope[i].isEmpty) {
78 : continue;
79 : }
80 0 : final key = scope[i].substring(0, 1);
81 0 : final value = scope[i].substring(1);
82 0 : if (key == '^') {
83 0 : idx++;
84 0 : result.add(List<dynamic>.filled(header.length, null));
85 : continue;
86 : }
87 0 : int? pos = mapping[key];
88 : if (pos != null) {
89 0 : if (key == 'T') {
90 0 : result[idx][pos] = (double.tryParse(value) ?? 0.0).abs().toString();
91 0 : result[idx][billType] = (double.tryParse(value) ?? 0) > 0 ? AppLocale.labels.flowTypeInvoice : '';
92 : } else {
93 0 : result[idx][pos] = value;
94 : }
95 : }
96 : }
97 0 : if (result.last.every((element) => element == null)) {
98 0 : result.removeLast();
99 : }
100 : return result;
101 : }
102 :
103 0 : FileScope _parseOfx(String content) {
104 0 : FileScope result = [header];
105 : int amountType = 1;
106 : int dateType = 4;
107 : int billType = 5;
108 0 : Map<String, int> mapping = {
109 : 'FITID': 0, // Unique ID
110 : 'TRNAMT': amountType, // Amount
111 : 'NAME': 2, // Description
112 : 'DTPOSTED': dateType, // Date
113 : };
114 :
115 0 : final data = content.split('<STMTTRN>');
116 0 : for (int i = 1; i < data.length; i++) {
117 0 : final tmp = List<dynamic>.filled(7, null);
118 0 : for (var key in mapping.keys) {
119 0 : final regexp = RegExp(r'(?<=<' + key + r'>)(.*?)(?=<)');
120 0 : final match = regexp.firstMatch(data[i]);
121 : if (match != null) {
122 0 : int pos = mapping[key]!;
123 0 : tmp[pos] = match.group(0)!;
124 0 : if (pos == dateType) {
125 0 : List<String> date = tmp[pos].split('');
126 0 : if (date.length > 8) {
127 0 : date.insert(8, 'T');
128 0 : tmp[pos] = date.join('');
129 : }
130 0 : } else if (pos == amountType) {
131 0 : double nm = double.tryParse(tmp[pos]) ?? 0.0;
132 0 : tmp[pos] = nm.abs().toString();
133 0 : tmp[billType] = nm > 0 ? AppLocale.labels.flowTypeInvoice : AppLocale.labels.bill;
134 : }
135 : }
136 : }
137 0 : result.add(tmp);
138 : }
139 : return result;
140 : }
141 :
142 0 : Future<FileScope?> pickFile() async {
143 0 : final format = ext.first;
144 0 : if (StatementParser.isStatement(format)) {
145 0 : final bytes = await importFileBytes(StatementParser.extensions[format]!);
146 : if (bytes == null) {
147 0 : throw Exception(AppLocale.labels.missingContent);
148 : }
149 0 : return parseStatement(format, bytes);
150 : }
151 0 : String? content = await importFile(ext);
152 : if (content != null) {
153 0 : final splitter = content.contains('\r\n') ? '\r\n' : '\n';
154 0 : switch (ext.first) {
155 0 : case csvFormat:
156 0 : return _parseCsv(content, splitter);
157 0 : case qifFormat:
158 0 : return _parseQif(content, splitter);
159 0 : case ofxFormat:
160 0 : return _parseOfx(content.replaceAll(splitter, ''));
161 : }
162 : } else {
163 0 : throw Exception(AppLocale.labels.missingContent);
164 : }
165 : return null;
166 : }
167 : }
|