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
13
14
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
24
25
26
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
51
52
53
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 }