From bcd928f12dfa465f59d7ccb3c1badb0ad6e0f755 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 10:26:05 +0200 Subject: [PATCH] feat(importing): add TagTreeImporter loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of four canonical loaders. Reads canonical-tag-tree.xlsx by header name, upserts each tag via TagService.upsertBySourceRef (never the repository — layering rule), and resolves parent links by stripping the last /segment of the canonical tag_path. Idempotent by source_ref. Refs #669 Co-Authored-By: Claude Opus 4.7 --- .../importing/TagTreeImporter.java | 54 +++++++++ .../importing/TagTreeImporterTest.java | 103 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/importing/TagTreeImporter.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/importing/TagTreeImporterTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/TagTreeImporter.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/TagTreeImporter.java new file mode 100644 index 00000000..a871ab32 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/TagTreeImporter.java @@ -0,0 +1,54 @@ +package org.raddatz.familienarchiv.importing; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.tag.Tag; +import org.raddatz.familienarchiv.tag.TagService; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Loads {@code canonical-tag-tree.xlsx} into the tag domain via {@link TagService}, + * upserting each tag by its canonical {@code tag_path} (the source_ref). Parent links are + * resolved by the parent's path, which is the child path with its last {@code /segment} + * stripped. Rows are emitted parents-first by the normalizer, so a parent is always + * resolved before any child references it. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class TagTreeImporter { + + static final List REQUIRED_HEADERS = List.of("tag_path", "parent_name", "tag_name"); + private static final String PATH_SEPARATOR = "/"; + + private final TagService tagService; + + public int load(File artifact) { + List rows = CanonicalSheetReader.readRows(artifact, REQUIRED_HEADERS); + Map idByPath = new HashMap<>(); + int processed = 0; + for (CanonicalSheetReader.Row row : rows) { + String path = row.get("tag_path"); + if (path.isBlank()) continue; + UUID parentId = resolveParentId(path, idByPath); + Tag tag = tagService.upsertBySourceRef(path, row.get("tag_name"), parentId); + idByPath.put(path, tag.getId()); + processed++; + } + log.info("Imported {} tags from {}", processed, artifact.getName()); + return processed; + } + + private UUID resolveParentId(String path, Map idByPath) { + int lastSeparator = path.lastIndexOf(PATH_SEPARATOR); + if (lastSeparator < 0) return null; + String parentPath = path.substring(0, lastSeparator); + return idByPath.get(parentPath); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/TagTreeImporterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/TagTreeImporterTest.java new file mode 100644 index 00000000..e6becae5 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/TagTreeImporterTest.java @@ -0,0 +1,103 @@ +package org.raddatz.familienarchiv.importing; + +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.tag.Tag; +import org.raddatz.familienarchiv.tag.TagService; + +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TagTreeImporterTest { + + @Test + void load_upsertsRootTagWithNullParent(@TempDir Path tempDir) throws Exception { + TagService tagService = mock(TagService.class); + when(tagService.upsertBySourceRef(any(), any(), any())) + .thenAnswer(inv -> tagOf(inv.getArgument(0), inv.getArgument(1), inv.getArgument(2))); + Path xlsx = writeTagTree(tempDir, List.of( + new String[]{"Themen", "", "Themen"})); + + new TagTreeImporter(tagService).load(xlsx.toFile()); + + verify(tagService).upsertBySourceRef("Themen", "Themen", null); + } + + @Test + void load_resolvesParentByPath_forChildTag(@TempDir Path tempDir) throws Exception { + TagService tagService = mock(TagService.class); + UUID rootId = UUID.randomUUID(); + when(tagService.upsertBySourceRef(eq("Themen"), eq("Themen"), isNull())) + .thenReturn(tagOf("Themen", "Themen", null, rootId)); + when(tagService.upsertBySourceRef(eq("Themen/Brautbriefe"), eq("Brautbriefe"), eq(rootId))) + .thenReturn(tagOf("Themen/Brautbriefe", "Brautbriefe", rootId)); + Path xlsx = writeTagTree(tempDir, List.of( + new String[]{"Themen", "", "Themen"}, + new String[]{"Themen/Brautbriefe", "Themen", "Brautbriefe"})); + + new TagTreeImporter(tagService).load(xlsx.toFile()); + + verify(tagService).upsertBySourceRef("Themen/Brautbriefe", "Brautbriefe", rootId); + } + + @Test + void load_returnsCountOfProcessedRows(@TempDir Path tempDir) throws Exception { + TagService tagService = mock(TagService.class); + when(tagService.upsertBySourceRef(any(), any(), any())) + .thenAnswer(inv -> tagOf(inv.getArgument(0), inv.getArgument(1), inv.getArgument(2))); + Path xlsx = writeTagTree(tempDir, List.of( + new String[]{"Themen", "", "Themen"}, + new String[]{"Themen/Brautbriefe", "Themen", "Brautbriefe"})); + + int processed = new TagTreeImporter(tagService).load(xlsx.toFile()); + + assertThat(processed).isEqualTo(2); + } + + private static Tag tagOf(String sourceRef, String name, UUID parentId) { + return tagOf(sourceRef, name, parentId, UUID.randomUUID()); + } + + private static Tag tagOf(String sourceRef, String name, UUID parentId, UUID id) { + return Tag.builder().id(id).sourceRef(sourceRef).name(name).parentId(parentId).build(); + } + + private Path writeTagTree(Path dir, List rows) throws Exception { + Path xlsx = dir.resolve("canonical-tag-tree.xlsx"); + try (XSSFWorkbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("Sheet1"); + Row header = sheet.createRow(0); + header.createCell(0).setCellValue("tag_path"); + header.createCell(1).setCellValue("parent_name"); + header.createCell(2).setCellValue("tag_name"); + for (int r = 0; r < rows.size(); r++) { + Row row = sheet.createRow(r + 1); + String[] values = rows.get(r); + for (int c = 0; c < values.length; c++) { + row.createCell(c).setCellValue(values[c]); + } + } + try (OutputStream out = Files.newOutputStream(xlsx)) { + wb.write(out); + } + } + return xlsx; + } +}