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}