|
1 | 1 | package com.apexgrid.transformertracker.web; |
2 | 2 |
|
| 3 | +import com.apexgrid.transformertracker.ai.ParameterTuningService; |
| 4 | +import com.apexgrid.transformertracker.ai.PythonAnalyzerService; |
3 | 5 | import com.apexgrid.transformertracker.model.Inspection; |
4 | 6 | import com.apexgrid.transformertracker.model.Transformer; |
5 | 7 | import com.apexgrid.transformertracker.repo.InspectionRepo; |
6 | 8 | import com.apexgrid.transformertracker.repo.TransformerRepo; |
7 | | -import com.apexgrid.transformertracker.ai.PythonAnalyzerService; |
8 | | -import com.apexgrid.transformertracker.ai.ParameterTuningService; |
| 9 | +import com.fasterxml.jackson.databind.JsonNode; |
| 10 | +import com.fasterxml.jackson.databind.ObjectMapper; |
| 11 | +import com.fasterxml.jackson.databind.node.ArrayNode; |
| 12 | +import com.fasterxml.jackson.databind.node.NullNode; |
| 13 | +import com.fasterxml.jackson.databind.node.ObjectNode; |
| 14 | +import org.springframework.http.ContentDisposition; |
| 15 | +import org.springframework.http.HttpHeaders; |
| 16 | +import org.springframework.http.MediaType; |
9 | 17 | import org.springframework.http.ResponseEntity; |
10 | 18 | import org.springframework.web.bind.annotation.*; |
11 | 19 | import org.springframework.web.multipart.MultipartFile; |
12 | 20 |
|
13 | | -import java.time.Instant; |
| 21 | +import javax.imageio.ImageIO; |
14 | 22 | import java.awt.Graphics2D; |
15 | 23 | import java.awt.RenderingHints; |
16 | 24 | import java.awt.image.BufferedImage; |
17 | 25 | import java.io.ByteArrayInputStream; |
18 | | -import java.util.List; |
| 26 | +import java.io.ByteArrayOutputStream; |
| 27 | +import java.nio.charset.StandardCharsets; |
| 28 | +import java.time.Instant; |
19 | 29 | import java.util.Base64; |
| 30 | +import java.util.List; |
20 | 31 | import java.util.Map; |
21 | | -import javax.imageio.ImageIO; |
22 | | -import com.fasterxml.jackson.databind.JsonNode; |
23 | | -import com.fasterxml.jackson.databind.ObjectMapper; |
24 | | -import com.fasterxml.jackson.databind.node.ArrayNode; |
| 32 | +import java.util.zip.ZipEntry; |
| 33 | +import java.util.zip.ZipOutputStream; |
25 | 34 |
|
26 | 35 | @RestController |
27 | 36 | @RequestMapping("/api/inspections") |
@@ -54,6 +63,149 @@ public ResponseEntity<Inspection> getOne(@PathVariable String id) { |
54 | 63 | .orElse(ResponseEntity.notFound().build()); |
55 | 64 | } |
56 | 65 |
|
| 66 | + @GetMapping("/{id}/export") |
| 67 | + public ResponseEntity<?> exportInspection(@PathVariable String id) { |
| 68 | + return repo.findById(id).map(inspection -> { |
| 69 | + try { |
| 70 | + ObjectMapper mapper = new ObjectMapper(); |
| 71 | + Instant generatedAt = Instant.now(); |
| 72 | + |
| 73 | + ObjectNode root = mapper.createObjectNode(); |
| 74 | + root.put("exportGeneratedAt", generatedAt.toString()); |
| 75 | + |
| 76 | + ObjectNode inspectionNode = root.putObject("inspection"); |
| 77 | + putNullable(inspectionNode, "id", inspection.getId()); |
| 78 | + putNullable(inspectionNode, "inspectionNumber", inspection.getInspectionNumber()); |
| 79 | + putNullable(inspectionNode, "branch", inspection.getBranch()); |
| 80 | + putNullable(inspectionNode, "status", inspection.getStatus()); |
| 81 | + putNullable(inspectionNode, "inspectedDate", inspection.getInspectedDate()); |
| 82 | + putNullable(inspectionNode, "maintainanceDate", inspection.getMaintainanceDate()); |
| 83 | + putNullable(inspectionNode, "uploadedBy", inspection.getUploadedBy()); |
| 84 | + putNullable(inspectionNode, "imageUploadedBy", inspection.getImageUploadedBy()); |
| 85 | + putNullable(inspectionNode, "imageUploadedAt", |
| 86 | + inspection.getImageUploadedAt() != null ? inspection.getImageUploadedAt().toString() : null); |
| 87 | + putNullable(inspectionNode, "weather", inspection.getWeather()); |
| 88 | + putNullable(inspectionNode, "lastAnalysisWeather", inspection.getLastAnalysisWeather()); |
| 89 | + inspectionNode.put("favourite", inspection.isFavourite()); |
| 90 | + |
| 91 | + Transformer transformer = inspection.getTransformer(); |
| 92 | + if (transformer != null) { |
| 93 | + ObjectNode transformerNode = inspectionNode.putObject("transformer"); |
| 94 | + putNullable(transformerNode, "id", transformer.getId()); |
| 95 | + putNullable(transformerNode, "transformerNumber", transformer.getTransformerNumber()); |
| 96 | + putNullable(transformerNode, "poleNumber", transformer.getPoleNumber()); |
| 97 | + putNullable(transformerNode, "region", transformer.getRegion()); |
| 98 | + putNullable(transformerNode, "type", transformer.getType()); |
| 99 | + } else { |
| 100 | + inspectionNode.putNull("transformer"); |
| 101 | + } |
| 102 | + |
| 103 | + JsonNode currentBoxes = parseJsonNode(mapper, inspection.getBoundingBoxes()); |
| 104 | + JsonNode currentFaults = parseJsonNode(mapper, inspection.getFaultTypes()); |
| 105 | + JsonNode currentAnnotated = parseJsonNode(mapper, inspection.getAnnotatedBy()); |
| 106 | + JsonNode currentSeverity = parseJsonNode(mapper, inspection.getSeverity()); |
| 107 | + |
| 108 | + ObjectNode currentNode = root.putObject("current"); |
| 109 | + putNullable(currentNode, "timestamp", |
| 110 | + inspection.getImageUploadedAt() != null ? inspection.getImageUploadedAt().toString() : generatedAt.toString()); |
| 111 | + currentNode.set("boundingBoxes", cloneNode(currentBoxes)); |
| 112 | + currentNode.set("faultTypes", cloneNode(currentFaults)); |
| 113 | + currentNode.set("annotatedBy", cloneNode(currentAnnotated)); |
| 114 | + currentNode.set("severity", cloneNode(currentSeverity)); |
| 115 | + |
| 116 | + ArrayNode boxHistory = asArrayNode(parseJsonNode(mapper, inspection.getBoundingBoxHistory())); |
| 117 | + ArrayNode faultHistory = asArrayNode(parseJsonNode(mapper, inspection.getFaultTypeHistory())); |
| 118 | + ArrayNode annotatedHistory = asArrayNode(parseJsonNode(mapper, inspection.getAnnotatedByHistory())); |
| 119 | + ArrayNode severityHistory = asArrayNode(parseJsonNode(mapper, inspection.getSeverityHistory())); |
| 120 | + ArrayNode timestampHistory = asArrayNode(parseJsonNode(mapper, inspection.getTimestampHistory())); |
| 121 | + |
| 122 | + ArrayNode history = mapper.createArrayNode(); |
| 123 | + int snapshotCount = maxSize(boxHistory, faultHistory, annotatedHistory, severityHistory, timestampHistory); |
| 124 | + for (int index = 0; index < snapshotCount; index++) { |
| 125 | + ObjectNode entry = mapper.createObjectNode(); |
| 126 | + String ts = extractText(timestampHistory, index); |
| 127 | + putNullable(entry, "timestamp", ts); |
| 128 | + entry.put("isCurrent", false); |
| 129 | + entry.set("boundingBoxes", cloneNode(snapshotValue(boxHistory, index))); |
| 130 | + entry.set("faultTypes", cloneNode(snapshotValue(faultHistory, index))); |
| 131 | + entry.set("annotatedBy", cloneNode(snapshotValue(annotatedHistory, index))); |
| 132 | + entry.set("severity", cloneNode(snapshotValue(severityHistory, index))); |
| 133 | + history.add(entry); |
| 134 | + } |
| 135 | + |
| 136 | + ObjectNode currentEntry = mapper.createObjectNode(); |
| 137 | + putNullable(currentEntry, "timestamp", |
| 138 | + inspection.getImageUploadedAt() != null ? inspection.getImageUploadedAt().toString() : generatedAt.toString()); |
| 139 | + currentEntry.put("isCurrent", true); |
| 140 | + currentEntry.set("boundingBoxes", cloneNode(currentBoxes)); |
| 141 | + currentEntry.set("faultTypes", cloneNode(currentFaults)); |
| 142 | + currentEntry.set("annotatedBy", cloneNode(currentAnnotated)); |
| 143 | + currentEntry.set("severity", cloneNode(currentSeverity)); |
| 144 | + history.add(currentEntry); |
| 145 | + |
| 146 | + root.set("history", history); |
| 147 | + |
| 148 | + byte[] metadataBytes = mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root); |
| 149 | + |
| 150 | + StringBuilder csvBuilder = new StringBuilder(); |
| 151 | + csvBuilder.append("timestamp,isCurrent,boundingBoxes,faultTypes,annotatedBy,severity\n"); |
| 152 | + for (JsonNode entry : history) { |
| 153 | + String timestamp = entry.path("timestamp").isNull() ? "" : entry.path("timestamp").asText(""); |
| 154 | + String boxesJson = mapper.writeValueAsString(entry.path("boundingBoxes")); |
| 155 | + String faultsJson = mapper.writeValueAsString(entry.path("faultTypes")); |
| 156 | + String annotatedJson = mapper.writeValueAsString(entry.path("annotatedBy")); |
| 157 | + String severityJson = mapper.writeValueAsString(entry.path("severity")); |
| 158 | + csvBuilder.append('"').append(csvEscape(timestamp)).append('"').append(','); |
| 159 | + csvBuilder.append(entry.path("isCurrent").asBoolean(false) ? "true" : "false").append(','); |
| 160 | + csvBuilder.append('"').append(csvEscape(boxesJson)).append('"').append(','); |
| 161 | + csvBuilder.append('"').append(csvEscape(faultsJson)).append('"').append(','); |
| 162 | + csvBuilder.append('"').append(csvEscape(annotatedJson)).append('"').append(','); |
| 163 | + csvBuilder.append('"').append(csvEscape(severityJson)).append('"').append('\n'); |
| 164 | + } |
| 165 | + byte[] csvBytes = csvBuilder.toString().getBytes(StandardCharsets.UTF_8); |
| 166 | + |
| 167 | + byte[] imageBytes = decodeDataUrl(inspection.getImageUrl()); |
| 168 | + String imageExt = guessImageExtension(inspection.getImageUrl()); |
| 169 | + |
| 170 | + ByteArrayOutputStream baos = new ByteArrayOutputStream(); |
| 171 | + try (ZipOutputStream zos = new ZipOutputStream(baos)) { |
| 172 | + ZipEntry metadataEntry = new ZipEntry("metadata.json"); |
| 173 | + zos.putNextEntry(metadataEntry); |
| 174 | + zos.write(metadataBytes); |
| 175 | + zos.closeEntry(); |
| 176 | + |
| 177 | + ZipEntry csvEntry = new ZipEntry("history.csv"); |
| 178 | + zos.putNextEntry(csvEntry); |
| 179 | + zos.write(csvBytes); |
| 180 | + zos.closeEntry(); |
| 181 | + |
| 182 | + if (imageBytes != null && imageBytes.length > 0) { |
| 183 | + ZipEntry imageEntry = new ZipEntry("image-original." + imageExt); |
| 184 | + zos.putNextEntry(imageEntry); |
| 185 | + zos.write(imageBytes); |
| 186 | + zos.closeEntry(); |
| 187 | + } |
| 188 | + } |
| 189 | + |
| 190 | + byte[] zipBytes = baos.toByteArray(); |
| 191 | + String filenameBase = inspection.getInspectionNumber(); |
| 192 | + if (filenameBase == null || filenameBase.isBlank()) { |
| 193 | + filenameBase = inspection.getId(); |
| 194 | + } |
| 195 | + String safeBase = sanitizeFilename(filenameBase); |
| 196 | + String downloadName = safeBase + "-export.zip"; |
| 197 | + |
| 198 | + HttpHeaders headers = new HttpHeaders(); |
| 199 | + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); |
| 200 | + headers.setContentDisposition(ContentDisposition.attachment().filename(downloadName).build()); |
| 201 | + headers.setContentLength(zipBytes.length); |
| 202 | + return ResponseEntity.ok().headers(headers).body(zipBytes); |
| 203 | + } catch (Exception ex) { |
| 204 | + return ResponseEntity.internalServerError().body(Map.of("error", "Failed to export inspection")); |
| 205 | + } |
| 206 | + }).orElse(ResponseEntity.notFound().build()); |
| 207 | + } |
| 208 | + |
57 | 209 | @PostMapping |
58 | 210 | public ResponseEntity<?> create(@RequestBody Inspection i) { |
59 | 211 | // ensure transformer exists |
@@ -406,6 +558,122 @@ private void archivePreviousAnalysis(Inspection i, String annotatedBy) throws Ex |
406 | 558 | i.setTimestampHistory(timestampHist.toString()); |
407 | 559 | } |
408 | 560 |
|
| 561 | + private static void putNullable(ObjectNode node, String field, String value) { |
| 562 | + if (value == null) { |
| 563 | + node.putNull(field); |
| 564 | + } else { |
| 565 | + node.put(field, value); |
| 566 | + } |
| 567 | + } |
| 568 | + |
| 569 | + private static JsonNode parseJsonNode(ObjectMapper mapper, String raw) { |
| 570 | + if (raw == null || raw.isBlank()) { |
| 571 | + return NullNode.getInstance(); |
| 572 | + } |
| 573 | + try { |
| 574 | + JsonNode node = mapper.readTree(raw); |
| 575 | + return node == null ? NullNode.getInstance() : node; |
| 576 | + } catch (Exception ex) { |
| 577 | + return NullNode.getInstance(); |
| 578 | + } |
| 579 | + } |
| 580 | + |
| 581 | + private static JsonNode cloneNode(JsonNode node) { |
| 582 | + if (node == null || node.isMissingNode()) { |
| 583 | + return NullNode.getInstance(); |
| 584 | + } |
| 585 | + return node.deepCopy(); |
| 586 | + } |
| 587 | + |
| 588 | + private static ArrayNode asArrayNode(JsonNode node) { |
| 589 | + return node instanceof ArrayNode ? (ArrayNode) node : null; |
| 590 | + } |
| 591 | + |
| 592 | + private static JsonNode snapshotValue(ArrayNode array, int index) { |
| 593 | + if (array == null || index < 0 || index >= array.size()) { |
| 594 | + return NullNode.getInstance(); |
| 595 | + } |
| 596 | + JsonNode node = array.get(index); |
| 597 | + return node == null ? NullNode.getInstance() : node; |
| 598 | + } |
| 599 | + |
| 600 | + private static int maxSize(ArrayNode... arrays) { |
| 601 | + int max = 0; |
| 602 | + if (arrays == null) { |
| 603 | + return 0; |
| 604 | + } |
| 605 | + for (ArrayNode array : arrays) { |
| 606 | + if (array != null && array.size() > max) { |
| 607 | + max = array.size(); |
| 608 | + } |
| 609 | + } |
| 610 | + return max; |
| 611 | + } |
| 612 | + |
| 613 | + private static String extractText(ArrayNode array, int index) { |
| 614 | + if (array == null || index < 0 || index >= array.size()) { |
| 615 | + return null; |
| 616 | + } |
| 617 | + JsonNode node = array.get(index); |
| 618 | + return node != null && node.isValueNode() ? node.asText() : null; |
| 619 | + } |
| 620 | + |
| 621 | + private static String csvEscape(String value) { |
| 622 | + if (value == null) { |
| 623 | + return ""; |
| 624 | + } |
| 625 | + return value.replace("\"", "\"\"") |
| 626 | + .replace('\n', ' ') |
| 627 | + .replace('\r', ' '); |
| 628 | + } |
| 629 | + |
| 630 | + private static byte[] decodeDataUrl(String dataUrl) { |
| 631 | + if (dataUrl == null || dataUrl.isBlank()) { |
| 632 | + return null; |
| 633 | + } |
| 634 | + int comma = dataUrl.indexOf(','); |
| 635 | + if (comma < 0) { |
| 636 | + return null; |
| 637 | + } |
| 638 | + String base64 = dataUrl.substring(comma + 1); |
| 639 | + try { |
| 640 | + return Base64.getDecoder().decode(base64); |
| 641 | + } catch (IllegalArgumentException ex) { |
| 642 | + return null; |
| 643 | + } |
| 644 | + } |
| 645 | + |
| 646 | + private static String guessImageExtension(String dataUrl) { |
| 647 | + if (dataUrl == null || dataUrl.isBlank()) { |
| 648 | + return "bin"; |
| 649 | + } |
| 650 | + int colon = dataUrl.indexOf(':'); |
| 651 | + int semi = dataUrl.indexOf(';'); |
| 652 | + if (colon >= 0 && semi > colon) { |
| 653 | + String mime = dataUrl.substring(colon + 1, semi).toLowerCase(); |
| 654 | + switch (mime) { |
| 655 | + case "image/png": |
| 656 | + return "png"; |
| 657 | + case "image/jpeg": |
| 658 | + case "image/jpg": |
| 659 | + return "jpg"; |
| 660 | + case "image/webp": |
| 661 | + return "webp"; |
| 662 | + default: |
| 663 | + return "bin"; |
| 664 | + } |
| 665 | + } |
| 666 | + return "bin"; |
| 667 | + } |
| 668 | + |
| 669 | + private static String sanitizeFilename(String value) { |
| 670 | + if (value == null || value.isBlank()) { |
| 671 | + return "inspection"; |
| 672 | + } |
| 673 | + String sanitized = value.replaceAll("[^A-Za-z0-9._-]", "_"); |
| 674 | + return sanitized.isBlank() ? "inspection" : sanitized; |
| 675 | + } |
| 676 | + |
409 | 677 | @PostMapping("/{id}/clear-analysis") |
410 | 678 | public ResponseEntity<?> clearAnalysis(@PathVariable String id) { |
411 | 679 | return repo.findById(id).map(i -> { |
|
0 commit comments