main
1import 'package:blood_pressure_app/logging.dart';
2import 'package:blood_pressure_app/model/export_import/column.dart';
3import 'package:blood_pressure_app/model/export_import/import_field_type.dart' show RowDataFieldType;
4import 'package:blood_pressure_app/model/export_import/record_parsing_result.dart';
5import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
6import 'package:blood_pressure_app/model/storage/export_csv_settings_store.dart';
7import 'package:collection/collection.dart';
8import 'package:csv/csv.dart';
9import 'package:health_data_store/health_data_store.dart';
10
11/// Utility class to convert between csv strings and [BloodPressureRecord]s.
12class CsvConverter with TypeLogger {
13 /// Create converter between csv strings and [BloodPressureRecord] values that respects settings.
14 CsvConverter(this.settings, this.availableColumns, this.availableMedicines) {
15 logger.fine('Creating CsvConverter with '
16 'settings=${settings.toJson()}, '
17 'availableColumns=$availableColumns, ',
18 'availableMedicines=$availableMedicines'
19 );
20 }
21
22 /// Settings that apply for ex- and import.
23 final CsvExportSettings settings;
24
25 /// Columns manager used for ex- and import.
26 final ExportColumnsManager availableColumns;
27
28 /// Medicines to choose from during import.
29 final List<Medicine> availableMedicines;
30
31 /// Create the contents of a csv file from passed records.
32 String create(List<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?)> entries) {
33 final columns = settings.exportFieldsConfiguration.getActiveColumns(availableColumns);
34 final table = entries.map(
35 (entry) => columns.map(
36 (column) => column.encode(entry.$2, entry.$3, entry.$4, entry.$5),
37 ).toList(),
38 ).toList();
39
40 if (settings.exportHeadline) table.insert(0, columns.map((c) => c.csvTitle).toList());
41
42 final csvCreator = ListToCsvConverter(
43 fieldDelimiter: settings.fieldDelimiter,
44 textDelimiter: settings.textDelimiter,
45 );
46
47 return csvCreator.convert(table);
48 }
49
50 /// Attempts to parse a csv string automatically.
51 ///
52 /// Validates that the first line of the file contains columns present
53 /// in [availableColumns]. When a column is present multiple times only
54 /// the first one counts.
55 /// A needle pin takes precedent over a color.
56 RecordParsingResult parse(String csvString) {
57 // Turn csv into lines.
58 final lines = getCsvLines(csvString);
59 if (lines.length < 2) return RecordParsingResult.err(RecordParsingErrorEmptyFile());
60
61 // Get and validate columns from csv title.
62 final List<String> titles = lines.removeAt(0).cast();
63 final List<ExportColumn?> columns = [];
64 final assumedColumns = getColumns(titles);
65 for (final csvName in titles) {
66 columns.add(assumedColumns[csvName]);
67 }
68 if (columns.none((e) => e?.restoreAbleType == RowDataFieldType.timestamp)) {
69 return RecordParsingResult.err(RecordParsingErrorTimeNotRestoreable());
70 }
71
72 // Convert data to records.
73 return parseRecords(lines, columns);
74 }
75
76 /// Parses lines from csv files according to settings.
77 ///
78 /// Works around different EOL types n
79 List<List<String>> getCsvLines(String csvString) {
80 final converter = CsvToListConverter(
81 fieldDelimiter: settings.fieldDelimiter,
82 textDelimiter: settings.textDelimiter,
83 shouldParseNumbers: false,
84 );
85 final csvLines = converter.convert<String>(csvString, eol: '\r\n');
86 if (csvLines.length < 2) return converter.convert<String>(csvString, eol: '\n');
87 return csvLines;
88 }
89
90 /// Map column names in the first csv-line to matching [ExportColumn].
91 Map<String, ExportColumn> getColumns(List<String> headline) {
92 final Map<String, ExportColumn> columns = {};
93 for (final titleText in headline) {
94 final formattedTitleText = titleText.trim();
95 final column = availableColumns.firstWhere(
96 (c) => c.csvTitle == formattedTitleText
97 && c.restoreAbleType != null,);
98 if (column != null) columns[titleText] = column;
99 }
100 return columns;
101 }
102
103 /// Parse csv data in [dataLines] using [parsers].
104 ///
105 /// [dataLines] contains all lines of the csv file without the headline and
106 /// [parsers] must have the same length as every line in [dataLines]
107 /// for parsing to succeed.
108 ///
109 /// [assumeHeadline] controls whether the line number should be offset by one
110 /// in case of error.
111 RecordParsingResult parseRecords(
112 List<List<String>> dataLines,
113 List<ExportColumn?> parsers, [
114 bool assumeHeadline = true,
115 ]) {
116 final List<FullEntry> entries = [];
117 int currentLineNumber = assumeHeadline ? 1 : 0;
118 for (final currentLine in dataLines) {
119 if (currentLine.length < parsers.length) {
120 return RecordParsingResult.err(RecordParsingErrorExpectedMoreFields(currentLineNumber));
121 }
122
123 final List<(RowDataFieldType, dynamic)> recordPieces = [];
124 for (int fieldIndex = 0; fieldIndex < parsers.length; fieldIndex++) {
125 final parser = parsers[fieldIndex];
126 final (RowDataFieldType, dynamic)? piece = parser?.decode(currentLine[fieldIndex]);
127 // Validate that the column parsed the expected type.
128 // Null can be the result of empty fields.
129 if (piece?.$1 != parser?.restoreAbleType
130 && piece != null) {
131 return RecordParsingResult.err(RecordParsingErrorUnparsableField(currentLineNumber, currentLine[fieldIndex]));
132 }
133 if (piece != null) recordPieces.add(piece);
134 }
135
136 final DateTime? timestamp = recordPieces.firstWhereOrNull(
137 (piece) => piece.$1 == RowDataFieldType.timestamp,)?.$2;
138 if (timestamp == null) {
139 return RecordParsingResult.err(RecordParsingErrorTimeNotRestoreable());
140 }
141
142 final int? sys = recordPieces.firstWhereOrNull(
143 (piece) => piece.$1 == RowDataFieldType.sys,)?.$2;
144 final int? dia = recordPieces.firstWhereOrNull(
145 (piece) => piece.$1 == RowDataFieldType.dia,)?.$2;
146 final int? pul = recordPieces.firstWhereOrNull(
147 (piece) => piece.$1 == RowDataFieldType.pul,)?.$2;
148 String noteText = recordPieces.firstWhereOrNull(
149 (piece) => piece.$1 == RowDataFieldType.notes,)?.$2 ?? '';
150 final int? color = recordPieces.firstWhereOrNull(
151 (piece) => piece.$1 == RowDataFieldType.color,)?.$2;
152 final List<dynamic>? intakesData = recordPieces.firstWhereOrNull(
153 (piece) => piece.$1 == RowDataFieldType.intakes,)?.$2;
154
155 // manually trim quotes after https://pub.dev/packages/csv/changelog#600
156 noteText = noteText.trim();
157 if (noteText.endsWith('"')) {
158 noteText = noteText.substring(0, noteText.length - 1);
159 }
160 if (noteText.startsWith('"')) {
161 noteText = noteText.substring(1, noteText.length);
162 }
163
164 final record = BloodPressureRecord(
165 time: timestamp,
166 sys: sys?.asMMHg,
167 dia: dia?.asMMHg,
168 pul: pul,
169 );
170 final note = Note(
171 time: timestamp,
172 note: noteText,
173 color: color,
174 );
175 final intakes = intakesData
176 ?.map((s) {
177 assert(s is (String, double));
178 final med = availableMedicines.firstWhereOrNull((med) => med.designation == s.$1);
179 if (med == null) return null;
180 return MedicineIntake(time: timestamp, medicine: med, dosis: Weight.mg(s.$2));
181 })
182 .nonNulls
183 .toList();
184 entries.add((record, note, intakes ?? []));
185 currentLineNumber++;
186 }
187
188 assert(entries.length == dataLines.length, 'every line should have been parse');
189 return RecordParsingResult.ok(entries);
190 }
191}
192
193extension _AsMMHg on int {
194 /// Interprets the value as a Pressure in mmHg.
195 Pressure get asMMHg => Pressure.mmHg(this);
196}