View Javadoc
1   package fr.tiogars.chat.domains.task.services;
2   
3   import java.util.ArrayList;
4   import java.util.List;
5   
6   import org.springframework.stereotype.Component;
7   
8   import fr.tiogars.chat.domains.task.models.CreateTaskRequest;
9   import fr.tiogars.chat.domains.task.models.Task;
10  
11  /**
12   * Serializes and parses Tasks in the CSV import/export format.
13   *
14   * @since 0.1.0
15   */
16  @Component
17  public class TaskCsvCodec {
18  
19      private static final String LEGACY_HEADER = "id,code,label,description,createdDate,lastModifiedDate";
20      private static final String HEADER = LEGACY_HEADER + ",status,priority,dueDate,assignedTo,tagIds,nomenclatureId";
21  
22      /**
23       * Serializes records to CSV.
24       *
25       * @param records records to serialize
26       * @return CSV document
27       */
28      public String serialize(List<Task> records) {
29          List<String> lines = new ArrayList<>();
30          lines.add(HEADER);
31          for (Task template : records) {
32              lines.add(String.join(",",
33                      escape(String.valueOf(template.id())),
34                      escape(template.code()),
35                      escape(template.label()),
36                      escape(template.description() == null ? "" : template.description()),
37                      escape(template.createdDate().toString()),
38                      escape(template.lastModifiedDate().toString()),
39                      escape(template.status().name()),
40                      escape(template.priority().name()),
41                      escape(template.dueDate() == null ? "" : template.dueDate().toString()),
42                      escape(template.assignedTo() == null ? "" : template.assignedTo()),
43                      escape(template.tagIds().stream().map(String::valueOf).collect(java.util.stream.Collectors.joining(";"))),
44                      escape(template.nomenclatureId() == null ? "" : template.nomenclatureId().toString())));
45          }
46          return String.join("\n", lines) + "\n";
47      }
48  
49      /**
50       * Parses records from CSV.
51       *
52       * @param csv CSV document
53       * @return parsed records
54       */
55      public List<CreateTaskRequest> parse(String csv) {
56          List<List<String>> rows = parseRows(csv);
57          if (rows.isEmpty()) {
58              return List.of();
59          }
60          String header = String.join(",", rows.getFirst());
61          boolean legacy = LEGACY_HEADER.equals(header);
62          if (!HEADER.equals(header) && !legacy) {
63              throw new IllegalArgumentException("CSV header must be " + HEADER + ".");
64          }
65  
66          List<CreateTaskRequest> records = new ArrayList<>();
67          for (int index = 1; index < rows.size(); index++) {
68              List<String> row = rows.get(index);
69              if (row.size() == 1 && row.getFirst().isBlank()) {
70                  continue;
71              }
72              if (row.size() != (legacy ? 6 : 12)) {
73                  throw new IllegalArgumentException("CSV row " + (index + 1) + " has an invalid number of columns.");
74              }
75              try {
76                  records.add(parseRecord(row, index + 1));
77              } catch (java.time.DateTimeException exception) {
78                  throw new IllegalArgumentException("CSV row " + (index + 1) + " has an invalid dueDate.", exception);
79              }
80          }
81          return records;
82      }
83  
84      private CreateTaskRequest parseRecord(List<String> row, int lineNumber) {
85          String code = row.get(1).trim();
86          String label = row.get(2).trim();
87          if (code.isBlank() || label.isBlank()) {
88              throw new IllegalArgumentException("CSV row " + lineNumber + " must contain code and label.");
89          }
90          String description = row.get(3).isBlank() ? null : row.get(3);
91          if (row.size() == 6) {
92              return new CreateTaskRequest(code, label, description);
93          }
94          List<Long> tagIds = row.get(10).isBlank() ? List.of()
95                  : java.util.Arrays.stream(row.get(10).split(";")).map(String::trim).map(Long::valueOf).toList();
96          Long nomenclatureId = row.get(11).isBlank() ? null : Long.valueOf(row.get(11));
97          if (tagIds.stream().anyMatch(id -> id <= 0) || (nomenclatureId != null && nomenclatureId <= 0)) {
98              throw new IllegalArgumentException("Reference identifiers must be positive.");
99          }
100         return new CreateTaskRequest(code, label, description,
101                 row.get(6).isBlank() ? null
102                         : fr.tiogars.chat.domains.task.models.TaskStatus.valueOf(row.get(6)),
103                 row.get(7).isBlank() ? null
104                         : fr.tiogars.chat.domains.task.models.TaskPriority.valueOf(row.get(7)),
105                 row.get(8).isBlank() ? null : java.time.LocalDate.parse(row.get(8)),
106                 row.get(9).isBlank() ? null : row.get(9), tagIds, nomenclatureId);
107     }
108 
109     private static List<List<String>> parseRows(String csv) {
110         List<List<String>> rows = new ArrayList<>();
111         List<String> row = new ArrayList<>();
112         StringBuilder field = new StringBuilder();
113         boolean quoted = false;
114 
115         for (int index = 0; index < csv.length(); index++) {
116             char character = csv.charAt(index);
117             if (quoted) {
118                 if (character == '"') {
119                     if (index + 1 < csv.length() && csv.charAt(index + 1) == '"') {
120                         field.append('"');
121                         index++;
122                     } else {
123                         quoted = false;
124                     }
125                 } else {
126                     field.append(character);
127                 }
128             } else if (character == '"') {
129                 quoted = true;
130             } else if (character == ',') {
131                 row.add(field.toString());
132                 field.setLength(0);
133             } else if (character == '\n') {
134                 row.add(field.toString());
135                 rows.add(row);
136                 row = new ArrayList<>();
137                 field.setLength(0);
138             } else if (character != '\r') {
139                 field.append(character);
140             }
141         }
142 
143         if (quoted) {
144             throw new IllegalArgumentException("CSV contains an unterminated quoted field.");
145         }
146         if (!field.isEmpty() || !row.isEmpty()) {
147             row.add(field.toString());
148             rows.add(row);
149         }
150         return rows;
151     }
152 
153     private static String escape(String value) {
154         if (value.contains(",") || value.contains("\n") || value.contains("\r") || value.contains("\"")) {
155             return "\"" + value.replace("\"", "\"\"") + "\"";
156         }
157         return value;
158     }
159 }