From becbf559c7d098e6eaaff4a5297adb42ce124d5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 17:09:59 +0000 Subject: [PATCH 1/5] feat(backend): download/import endpoints + DocumentType enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DocumentType enum заменяет String-тип в DocumentEntity; JDBC-конвертер хранит lowercase ('document'/'folder') для обратной совместимости, @JsonValue сохраняет контракт API - GET /api/documents/{id}/download — документ как .md, папка как .zip (поддерево с переписыванием /?doc=ID ссылок), через renderSubtree - POST /api/documents/admin/import — синхронизация (импорт) из серверной папки экспорта: восстановление дерева, порядок из .index.md, обратное переписывание ссылок на /?doc=ID - тесты: renderSubtree (документ/папка/ссылки) и импорт (иерархия, порядок, реверс ссылок); существующие тесты обновлены под enum --- .../trialiya/kb/config/H2JdbcConfig.java | 5 +- .../kb/config/PgVectorJdbcConfig.java | 5 +- .../kb/controller/DocumentController.java | 98 +++++++ .../kb/convert/DocumentTypeJdbcConverter.java | 32 +++ .../trialiya/kb/model/doc/DocumentType.java | 52 ++++ .../kb/model/doc/entity/DocumentEntity.java | 3 +- .../kb/service/DocumentExportService.java | 96 ++++++- .../kb/service/DocumentImportService.java | 262 ++++++++++++++++++ .../trialiya/kb/service/DocumentService.java | 19 +- .../trialiya/kb/PostgresDocumentIT.java | 9 +- .../kb/service/DocumentExportSubtreeTest.java | 108 ++++++++ .../kb/service/DocumentImportServiceTest.java | 135 +++++++++ .../kb/service/DocumentServiceUnitTest.java | 9 +- 13 files changed, 809 insertions(+), 24 deletions(-) create mode 100644 backend/src/main/java/io/github/trialiya/kb/convert/DocumentTypeJdbcConverter.java create mode 100644 backend/src/main/java/io/github/trialiya/kb/model/doc/DocumentType.java create mode 100644 backend/src/main/java/io/github/trialiya/kb/service/DocumentImportService.java create mode 100644 backend/src/test/java/io/github/trialiya/kb/service/DocumentExportSubtreeTest.java create mode 100644 backend/src/test/java/io/github/trialiya/kb/service/DocumentImportServiceTest.java diff --git a/backend/src/main/java/io/github/trialiya/kb/config/H2JdbcConfig.java b/backend/src/main/java/io/github/trialiya/kb/config/H2JdbcConfig.java index 938617e..dc72921 100644 --- a/backend/src/main/java/io/github/trialiya/kb/config/H2JdbcConfig.java +++ b/backend/src/main/java/io/github/trialiya/kb/config/H2JdbcConfig.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.trialiya.kb.convert.ChatMessageMetaToJsonConverter; +import io.github.trialiya.kb.convert.DocumentTypeJdbcConverter; import java.util.List; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -29,6 +30,8 @@ public JdbcDialect jdbcDialect(NamedParameterJdbcOperations operations) { public List userConverters() { return List.of( new ChatMessageMetaToJsonConverter.Writer(objectMapper), - new ChatMessageMetaToJsonConverter.Reader(objectMapper)); + new ChatMessageMetaToJsonConverter.Reader(objectMapper), + new DocumentTypeJdbcConverter.Writer(), + new DocumentTypeJdbcConverter.Reader()); } } diff --git a/backend/src/main/java/io/github/trialiya/kb/config/PgVectorJdbcConfig.java b/backend/src/main/java/io/github/trialiya/kb/config/PgVectorJdbcConfig.java index 075aaef..937040a 100644 --- a/backend/src/main/java/io/github/trialiya/kb/config/PgVectorJdbcConfig.java +++ b/backend/src/main/java/io/github/trialiya/kb/config/PgVectorJdbcConfig.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.trialiya.kb.convert.ChatMessageMetaToJsonConverter; +import io.github.trialiya.kb.convert.DocumentTypeJdbcConverter; import io.github.trialiya.kb.convert.FloatArrayToVectorConverter; import java.util.List; import org.springframework.context.annotation.Configuration; @@ -26,6 +27,8 @@ public List userConverters() { new FloatArrayToVectorConverter.Writer(), new FloatArrayToVectorConverter.Reader(), new ChatMessageMetaToJsonConverter.Writer(objectMapper), - new ChatMessageMetaToJsonConverter.Reader(objectMapper)); + new ChatMessageMetaToJsonConverter.Reader(objectMapper), + new DocumentTypeJdbcConverter.Writer(), + new DocumentTypeJdbcConverter.Reader()); } } diff --git a/backend/src/main/java/io/github/trialiya/kb/controller/DocumentController.java b/backend/src/main/java/io/github/trialiya/kb/controller/DocumentController.java index c1e5ad9..1809838 100644 --- a/backend/src/main/java/io/github/trialiya/kb/controller/DocumentController.java +++ b/backend/src/main/java/io/github/trialiya/kb/controller/DocumentController.java @@ -10,14 +10,27 @@ import io.github.trialiya.kb.model.doc.dto.SearchResult; import io.github.trialiya.kb.model.doc.dto.UpdateDocumentRequest; import io.github.trialiya.kb.service.DocumentExportService; +import io.github.trialiya.kb.service.DocumentImportService; import io.github.trialiya.kb.service.DocumentService; import io.github.trialiya.kb.service.SemanticSearchService; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -28,6 +41,7 @@ public class DocumentController { private final DocumentService service; private final DocumentExportService documentExportService; + private final DocumentImportService documentImportService; private final SemanticSearchService semanticSearchService; // ── Tree ────────────────────────────────────────────────────────────────── @@ -80,6 +94,72 @@ public DocumentNode getDocument(@PathVariable long id) { return node; } + /** + * Downloads a node: a document is returned as a single {@code .md} file, a folder + * as a {@code .zip} archive of its subtree (Markdown + optional {@code .yaml} metadata, mirroring + * the export layout). Internal {@code /?doc=ID} links are rewritten to relative paths within the + * archive. + * + *
GET /api/documents/{id}/download?meta=false
+ */ + @GetMapping("/{id}/download") + public ResponseEntity download( + @PathVariable long id, @RequestParam(defaultValue = "false") boolean meta) { + + DocumentNode node = service.getById(id); + if (node == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + + Map entries; + try { + entries = documentExportService.renderSubtree(id, meta); + } catch (NoSuchElementException e) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + + String baseName = DocumentExportService.safeName(node.title()); + + if ("folder".equalsIgnoreCase(node.type())) { + byte[] zip = toZip(entries); + return fileResponse(zip, baseName + ".zip", "application/zip"); + } + + // Document → return its Markdown body directly. + String markdown = + entries.entrySet().stream() + .filter(en -> en.getKey().endsWith(".md")) + .map(Map.Entry::getValue) + .findFirst() + .orElse(""); + return fileResponse( + markdown.getBytes(StandardCharsets.UTF_8), baseName + ".md", "text/markdown"); + } + + private static ResponseEntity fileResponse( + byte[] body, String filename, String contentType) { + ContentDisposition disposition = + ContentDisposition.attachment().filename(filename, StandardCharsets.UTF_8).build(); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .contentType(MediaType.parseMediaType(contentType)) + .body(body); + } + + private static byte[] toZip(Map entries) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (Map.Entry e : entries.entrySet()) { + zos.putNextEntry(new ZipEntry(e.getKey())); + zos.write(e.getValue().getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } catch (IOException ex) { + throw new UncheckedIOException("Failed to build zip archive", ex); + } + return baos.toByteArray(); + } + /** История изменений описания документа (newest-first). */ @GetMapping("/{id}/history") public List getHistory(@PathVariable long id) { @@ -215,5 +295,23 @@ public void export(@RequestParam(defaultValue = "true") boolean meta) { documentExportService.exportAll(meta); } + /** + * Synchronises (imports) the server-side export folder ({@code kb.documents.export-path}) back + * into the database, recreating the tree under {@code parentId} (or root when omitted). + * + *
POST /api/documents/admin/import?parentId=42
+ * + * @throws ResponseStatusException 422 if the export folder is not configured or missing + */ + @PostMapping("/admin/import") + public DocumentImportService.ImportResult importFromFolder( + @RequestParam(required = false) Long parentId) { + try { + return documentImportService.importFromFolder(parentId); + } catch (IllegalStateException e) { + throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage()); + } + } + public record ReindexResponse(int indexed) {} } diff --git a/backend/src/main/java/io/github/trialiya/kb/convert/DocumentTypeJdbcConverter.java b/backend/src/main/java/io/github/trialiya/kb/convert/DocumentTypeJdbcConverter.java new file mode 100644 index 0000000..9ef3543 --- /dev/null +++ b/backend/src/main/java/io/github/trialiya/kb/convert/DocumentTypeJdbcConverter.java @@ -0,0 +1,32 @@ +package io.github.trialiya.kb.convert; + +import io.github.trialiya.kb.model.doc.DocumentType; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; + +/** + * Maps {@link DocumentType} to/from the lowercase string stored in {@code documents.type}. Without + * this, Spring Data JDBC would persist the enum by {@code name()} (upper-case), breaking existing + * lowercase rows. + */ +public final class DocumentTypeJdbcConverter { + + private DocumentTypeJdbcConverter() {} + + @WritingConverter + public static class Writer implements Converter { + @Override + public String convert(DocumentType source) { + return source.getValue(); + } + } + + @ReadingConverter + public static class Reader implements Converter { + @Override + public DocumentType convert(String source) { + return DocumentType.fromString(source); + } + } +} diff --git a/backend/src/main/java/io/github/trialiya/kb/model/doc/DocumentType.java b/backend/src/main/java/io/github/trialiya/kb/model/doc/DocumentType.java new file mode 100644 index 0000000..75207e9 --- /dev/null +++ b/backend/src/main/java/io/github/trialiya/kb/model/doc/DocumentType.java @@ -0,0 +1,52 @@ +package io.github.trialiya.kb.model.doc; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Type of a knowledge-base node. Persisted (and exposed over JSON) as a lowercase string — + * {@code "document"} / {@code "folder"} — to stay backward-compatible with existing rows and the + * frontend contract. + * + *

Persistence: a dedicated Spring Data JDBC converter pair (see {@code DocumentTypeJdbcConverter}) + * maps this enum to/from the lowercase {@link #getValue() value}. JSON: {@link JsonValue} / + * {@link JsonCreator} keep the same lowercase representation on the wire. + */ +public enum DocumentType { + DOCUMENT("document"), + FOLDER("folder"); + + private final String value; + + DocumentType(String value) { + this.value = value; + } + + /** Lowercase wire/DB representation, e.g. {@code "folder"}. */ + @JsonValue + public String getValue() { + return value; + } + + public boolean isFolder() { + return this == FOLDER; + } + + /** + * Lenient parse: accepts the lowercase value or the enum name (any case). Unknown / blank input + * falls back to {@link #DOCUMENT}, matching the previous string-based behaviour. + */ + @JsonCreator + public static DocumentType fromString(String raw) { + if (raw == null || raw.isBlank()) { + return DOCUMENT; + } + String normalized = raw.trim(); + for (DocumentType t : values()) { + if (t.value.equalsIgnoreCase(normalized) || t.name().equalsIgnoreCase(normalized)) { + return t; + } + } + return DOCUMENT; + } +} diff --git a/backend/src/main/java/io/github/trialiya/kb/model/doc/entity/DocumentEntity.java b/backend/src/main/java/io/github/trialiya/kb/model/doc/entity/DocumentEntity.java index 9897cc0..2662f24 100644 --- a/backend/src/main/java/io/github/trialiya/kb/model/doc/entity/DocumentEntity.java +++ b/backend/src/main/java/io/github/trialiya/kb/model/doc/entity/DocumentEntity.java @@ -1,5 +1,6 @@ package io.github.trialiya.kb.model.doc.entity; +import io.github.trialiya.kb.model.doc.DocumentType; import java.time.LocalDateTime; import lombok.AllArgsConstructor; import lombok.Data; @@ -16,7 +17,7 @@ public class DocumentEntity { @Id private Long id; private String title; - private String type; + private DocumentType type; private Long parentId; private String description; private LocalDateTime updatedAt; diff --git a/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java b/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java index 2b5abfd..5ee46c4 100644 --- a/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java +++ b/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java @@ -12,8 +12,10 @@ import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -142,6 +144,92 @@ public int exportAll(boolean includeMeta) { return count; } + // ── In-memory subtree rendering (for downloads) ────────────────────────── + + /** + * Renders the subtree rooted at {@code rootId} into an in-memory map of + * {@code relativePath → fileContent}, without touching the filesystem. Same layout as + * {@link #exportAll(boolean)} but scoped to one node: + * + *

    + *
  • a document root yields a single {@code .md} entry (plus {@code .yaml} + * when {@code includeMeta}); + *
  • a folder root yields the folder directory with its {@code .content.md} / + * {@code .index.md} / children, ready to be zipped. + *
+ * + *

Internal {@code /?doc=ID} links are rewritten to relative paths within the subtree; + * links pointing outside the subtree are left untouched. + * + * @throws NoSuchElementException if no node with {@code rootId} exists + */ + public LinkedHashMap renderSubtree(long rootId, boolean includeMeta) { + DocumentEntity root = + repo.findById(rootId) + .orElseThrow( + () -> new NoSuchElementException("Document not found: " + rootId)); + + List all = Streams.stream(repo.findAll()).collect(Collectors.toList()); + Map> byParent = + all.stream() + .filter(e -> e.getParentId() != null) + .collect(Collectors.groupingBy(DocumentEntity::getParentId)); + byParent.values() + .forEach(list -> list.sort(Comparator.comparingInt(DocumentEntity::getPosition))); + + // Virtual base — never hits disk; used only for relativising entry names and links. + Path base = Paths.get("/"); + + Map idToPath = new HashMap<>(); + collectPaths(root, base, byParent, idToPath); + + LinkedHashMap out = new LinkedHashMap<>(); + renderNodeToMap(root, base, base, byParent, idToPath, includeMeta, out); + return out; + } + + /** Recursive counterpart of {@link #exportNode} that appends to an in-memory map instead of disk. */ + private void renderNodeToMap( + DocumentEntity entity, + Path parentDir, + Path base, + Map> byParent, + Map idToPath, + boolean includeMeta, + Map sink) { + + if (entity.getType().isFolder()) { + Path folderDir = parentDir.resolve(safeName(entity.getTitle())); + + if (includeMeta) { + put(sink, base, folderDir.resolve(FOLDER_META_FILE), renderMeta(entity)); + } + Path contentFile = folderDir.resolve(FOLDER_CONTENT_FILE); + put(sink, base, contentFile, renderContent(entity, contentFile, idToPath)); + + List children = childrenOf(entity.getId(), byParent); + for (DocumentEntity child : children) { + renderNodeToMap(child, folderDir, base, byParent, idToPath, includeMeta, sink); + } + put(sink, base, folderDir.resolve(INDEX_FILE), renderIndex(children, folderDir, idToPath)); + } else { + Path contentFile = idToPath.get(entity.getId()); + put(sink, base, contentFile, renderContent(entity, contentFile, idToPath)); + if (includeMeta) { + String name = contentFile.getFileName().toString(); + Path metaFile = + contentFile.resolveSibling(name.substring(0, name.lastIndexOf('.')) + ".yaml"); + put(sink, base, metaFile, renderMeta(entity)); + } + } + } + + /** Adds a single entry to the sink keyed by its path relative to {@code base} (forward slashes). */ + private void put(Map sink, Path base, Path file, String content) { + String entry = base.relativize(file).toString().replace('\\', '/'); + sink.put(entry, content); + } + // ── Pass 1: collect id → path ──────────────────────────────────────────── /** @@ -155,7 +243,7 @@ private void collectPaths( Map> byParent, Map idToPath) { - boolean isFolder = "folder".equalsIgnoreCase(entity.getType()); + boolean isFolder = entity.getType().isFolder(); if (isFolder) { Path folderDir = parentDir.resolve(safeName(entity.getTitle())); @@ -188,7 +276,7 @@ private int exportNode( Map idToPath, boolean includeMeta) { - boolean isFolder = "folder".equalsIgnoreCase(entity.getType()); + boolean isFolder = entity.getType().isFolder(); int written = 0; if (isFolder) { @@ -315,7 +403,7 @@ private String renderMeta(DocumentEntity e) { StringBuilder sb = new StringBuilder(); sb.append("id: ").append(e.getId()).append("\n"); sb.append("title: \"").append(escapeYaml(e.getTitle())).append("\"\n"); - sb.append("type: ").append(e.getType()).append("\n"); + sb.append("type: ").append(e.getType().getValue()).append("\n"); if (e.getParentId() != null) { sb.append("parentId: ").append(e.getParentId()).append("\n"); } @@ -330,7 +418,7 @@ private String renderMeta(DocumentEntity e) { * Converts a document title into a filesystem-safe name: lower-case, spaces/special chars * replaced with hyphens, no leading/trailing hyphens. */ - static String safeName(String title) { + public static String safeName(String title) { if (title == null || title.isBlank()) { return "untitled"; } diff --git a/backend/src/main/java/io/github/trialiya/kb/service/DocumentImportService.java b/backend/src/main/java/io/github/trialiya/kb/service/DocumentImportService.java new file mode 100644 index 0000000..c9d83c0 --- /dev/null +++ b/backend/src/main/java/io/github/trialiya/kb/service/DocumentImportService.java @@ -0,0 +1,262 @@ +package io.github.trialiya.kb.service; + +import io.github.trialiya.kb.config.model.DocumentsConfiguration; +import io.github.trialiya.kb.model.doc.DocumentType; +import io.github.trialiya.kb.model.doc.dto.CreateDocumentRequest; +import io.github.trialiya.kb.model.doc.dto.Document; +import io.github.trialiya.kb.model.doc.dto.UpdateDocumentRequest; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Imports (synchronises) the on-disk export folder ({@code kb.documents.export-path}) back into the + * database. It is the inverse of {@link DocumentExportService}: it walks the directory tree, recreates + * the documents/folders preserving order and titles, and rewrites the relative Markdown links back to + * internal {@code /?doc=ID} links. + * + *

Layout understood (produced by the export): + * + *

    + *
  • sub-directory → folder; its body comes from {@code .content.md}, title from {@code .index.md} + * link text (or the directory name as a fallback); + *
  • {@code .md} (non-hidden) → document; body is the file content, title from + * {@code .index.md} (or the file name as a fallback); + *
  • {@code .index.md} → defines the sibling order; entries not listed there are appended in name + * order. + *
+ * + *

This is an additive import: it always creates new nodes under {@code parentId} (or root). It does + * not delete or merge with existing rows. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DocumentImportService { + + /** Markdown list link: {@code - [Title](relative/path.md)}. */ + private static final Pattern INDEX_ENTRY = Pattern.compile("- \\[(.*?)]\\((.*?)\\)"); + + /** Any Markdown link target: {@code [text](target)} — used for reverse link rewriting. */ + private static final Pattern MD_LINK = Pattern.compile("\\]\\((.*?)\\)"); + + static final String FOLDER_CONTENT_FILE = ".content.md"; + static final String INDEX_FILE = ".index.md"; + + private final DocumentService documentService; + private final DocumentsConfiguration config; + + /** Result of an import run. */ + public record ImportResult(int created, int folders, int documents) {} + + /** + * Imports the configured export folder under {@code parentId} (or root when {@code null}). + * + * @throws IllegalStateException if the export folder is not configured or does not exist + */ + @Transactional + public ImportResult importFromFolder(Long parentId) { + if (config.exportPath() == null || config.exportPath().isBlank()) { + throw new IllegalStateException("Export path is not configured (kb.documents.export-path)"); + } + Path base = Paths.get(config.exportPath()); + if (!Files.isDirectory(base)) { + throw new IllegalStateException("Export folder does not exist: " + base.toAbsolutePath()); + } + + Counters counters = new Counters(); + // file (absolute, normalized) → created document id — for reverse link rewriting. + Map fileToId = new HashMap<>(); + // created id → (own file path, raw description) — pass 2 input. + Map pending = new LinkedHashMap<>(); + + importDir(base, parentId, counters, fileToId, pending); + rewriteLinks(pending, fileToId); + + log.info( + "Import complete: {} node(s) created ({} folders, {} documents) from {}", + counters.folders + counters.documents, + counters.folders, + counters.documents, + base.toAbsolutePath()); + return new ImportResult(counters.folders + counters.documents, counters.folders, counters.documents); + } + + // ── Pass 1: walk the tree and create nodes ─────────────────────────────── + + private void importDir( + Path dir, + Long parentId, + Counters counters, + Map fileToId, + Map pending) { + + for (Child child : orderedChildren(dir)) { + if (child.isFolder()) { + Path contentFile = child.path().resolve(FOLDER_CONTENT_FILE).normalize(); + String body = readIfExists(contentFile); + long id = create(child.title(), DocumentType.FOLDER, parentId, body, counters); + fileToId.put(contentFile, id); + pending.put(id, new Pending(contentFile, body)); + importDir(child.path(), id, counters, fileToId, pending); + } else { + Path file = child.path().normalize(); + String body = readIfExists(file); + long id = create(child.title(), DocumentType.DOCUMENT, parentId, body, counters); + fileToId.put(file, id); + pending.put(id, new Pending(file, body)); + } + } + } + + /** + * Returns the ordered children of {@code dir}: order and titles come from {@code .index.md} when + * present; anything not listed there is appended in directory name order. + */ + private List orderedChildren(Path dir) { + List result = new ArrayList<>(); + Map remaining = new LinkedHashMap<>(); + + try (Stream entries = Files.list(dir)) { + entries.sorted(Comparator.comparing(p -> p.getFileName().toString())) + .forEach( + p -> { + String name = p.getFileName().toString(); + if (Files.isDirectory(p)) { + remaining.put(p.normalize(), new Child(p, name, true)); + } else if (name.endsWith(".md") && !name.startsWith(".")) { + String title = name.substring(0, name.length() - ".md".length()); + remaining.put(p.normalize(), new Child(p, title, false)); + } + }); + } catch (IOException e) { + throw new UncheckedIOException("Cannot list directory: " + dir, e); + } + + // Apply order/titles from .index.md, if any. + for (IndexEntry idx : parseIndex(dir)) { + Path target = dir.resolve(idx.target()).normalize(); + Path key = idx.target().endsWith(FOLDER_CONTENT_FILE) ? target.getParent() : target; + if (key == null) continue; + Child c = remaining.remove(key); + if (c != null) { + result.add(new Child(c.path(), idx.title(), c.isFolder())); + } + } + // Append everything not referenced by the index. + result.addAll(remaining.values()); + return result; + } + + private List parseIndex(Path dir) { + Path index = dir.resolve(INDEX_FILE); + if (!Files.isRegularFile(index)) { + return List.of(); + } + List entries = new ArrayList<>(); + Matcher m = INDEX_ENTRY.matcher(readIfExists(index)); + while (m.find()) { + entries.add(new IndexEntry(m.group(1).trim(), m.group(2).trim())); + } + return entries; + } + + private long create( + String title, DocumentType type, Long parentId, String description, Counters counters) { + CreateDocumentRequest req = new CreateDocumentRequest(); + req.setTitle(title); + req.setType(type.getValue()); + req.setParentId(parentId); + req.setDescription(description == null || description.isBlank() ? null : description); + Document created = documentService.create(req); + if (type.isFolder()) { + counters.folders++; + } else { + counters.documents++; + } + return created.id(); + } + + // ── Pass 2: rewrite relative links back to /?doc=ID ────────────────────── + + private void rewriteLinks(Map pending, Map fileToId) { + for (Map.Entry e : pending.entrySet()) { + Pending p = e.getValue(); + if (p.description() == null || p.description().isBlank()) { + continue; + } + Path ownDir = p.file().getParent(); + Matcher m = MD_LINK.matcher(p.description()); + StringBuilder out = new StringBuilder(); + boolean changed = false; + while (m.find()) { + String targetRaw = m.group(1); + Long targetId = resolveTarget(ownDir, targetRaw, fileToId); + if (targetId != null) { + m.appendReplacement(out, Matcher.quoteReplacement("](/?doc=" + targetId + ")")); + changed = true; + } else { + m.appendReplacement(out, Matcher.quoteReplacement(m.group(0))); + } + } + m.appendTail(out); + if (changed) { + UpdateDocumentRequest req = new UpdateDocumentRequest(); + req.setDescription(out.toString()); + documentService.update(e.getKey(), req); + } + } + } + + /** Resolves a relative Markdown link target to a created document id, or {@code null}. */ + private Long resolveTarget(Path ownDir, String targetRaw, Map fileToId) { + if (ownDir == null || targetRaw.isBlank() || targetRaw.startsWith("/") || targetRaw.contains("://")) { + return null; // absolute / external / already an internal link + } + try { + Path resolved = ownDir.resolve(targetRaw).normalize(); + return fileToId.get(resolved); + } catch (RuntimeException ex) { + return null; + } + } + + private String readIfExists(Path file) { + if (!Files.isRegularFile(file)) { + return null; + } + try { + return Files.readString(file); + } catch (IOException e) { + throw new UncheckedIOException("Cannot read file: " + file, e); + } + } + + // ── Small carriers ─────────────────────────────────────────────────────── + + private static final class Counters { + int folders; + int documents; + } + + private record Child(Path path, String title, boolean isFolder) {} + + private record IndexEntry(String title, String target) {} + + private record Pending(Path file, String description) {} +} diff --git a/backend/src/main/java/io/github/trialiya/kb/service/DocumentService.java b/backend/src/main/java/io/github/trialiya/kb/service/DocumentService.java index 79f8ca1..264c63c 100644 --- a/backend/src/main/java/io/github/trialiya/kb/service/DocumentService.java +++ b/backend/src/main/java/io/github/trialiya/kb/service/DocumentService.java @@ -1,6 +1,7 @@ package io.github.trialiya.kb.service; import io.github.trialiya.kb.config.model.SearchConfiguration; +import io.github.trialiya.kb.model.doc.DocumentType; import io.github.trialiya.kb.model.doc.dto.CreateDocumentRequest; import io.github.trialiya.kb.model.doc.dto.Document; import io.github.trialiya.kb.model.doc.dto.DocumentHistory; @@ -148,7 +149,7 @@ public List getTreeSkeleton() { new DocumentNode( e.getId(), e.getTitle(), - e.getType(), + e.getType().getValue(), e.getParentId(), e.getVersion(), null, @@ -174,7 +175,7 @@ private DocumentNode toShallowNode(DocumentEntity e) { new DocumentNode( c.getId(), c.getTitle(), - c.getType(), + c.getType().getValue(), c.getParentId(), c.getVersion(), null, @@ -192,7 +193,7 @@ private DocumentNode toShallowNode(DocumentEntity e) { return new DocumentNode( e.getId(), e.getTitle(), - e.getType(), + e.getType().getValue(), e.getParentId(), e.getVersion(), e.getDescription(), @@ -211,7 +212,7 @@ private DocumentNode toStubNode(DocumentEntity e) { return new DocumentNode( e.getId(), e.getTitle(), - e.getType(), + e.getType().getValue(), e.getParentId(), e.getVersion(), snippetOf(e.getDescription()), @@ -240,7 +241,7 @@ private DocumentNode buildNode(DocumentEntity e, Map> return new DocumentNode( e.getId(), e.getTitle(), - e.getType(), + e.getType().getValue(), e.getParentId(), e.getVersion(), null, // description omitted — fetch via GET /api/documents/{id} @@ -259,7 +260,7 @@ private DocumentNode buildNode(DocumentEntity e, Map> @Transactional public Document create(CreateDocumentRequest req) { - String type = "folder".equals(req.getType()) ? "folder" : "document"; + DocumentType type = DocumentType.fromString(req.getType()); int nextPos = nextSiblingPosition(req.getParentId()); DocumentEntity entity = @@ -545,7 +546,7 @@ private void validateTargetParent(long id, Long targetParentId) { () -> new ResponseStatusException( HttpStatus.NOT_FOUND, "Target parent not found")); - if (!"folder".equals(targetFolder.getType())) { + if (!targetFolder.getType().isFolder()) { throw new ResponseStatusException( HttpStatus.UNPROCESSABLE_ENTITY, "Target must be a folder"); } @@ -764,7 +765,7 @@ private DocumentHistoryEntity snapshotOf(DocumentEntity entity) { entity.getId(), entity.getVersion(), entity.getTitle(), - entity.getType(), + entity.getType().getValue(), entity.getDescription(), entity.getUpdatedAt(), entity.getSummary(), @@ -776,7 +777,7 @@ private Document toDto(DocumentEntity e) { return new Document( e.getId(), e.getTitle(), - e.getType(), + e.getType().getValue(), e.getParentId(), e.getVersion(), e.getDescriptionVersion(), diff --git a/backend/src/test/java/io/github/trialiya/kb/PostgresDocumentIT.java b/backend/src/test/java/io/github/trialiya/kb/PostgresDocumentIT.java index bf81f4e..0410671 100644 --- a/backend/src/test/java/io/github/trialiya/kb/PostgresDocumentIT.java +++ b/backend/src/test/java/io/github/trialiya/kb/PostgresDocumentIT.java @@ -6,6 +6,7 @@ import io.github.trialiya.kb.config.CommonConfig; import io.github.trialiya.kb.config.JdbcConfig; import io.github.trialiya.kb.config.PgVectorJdbcConfig; +import io.github.trialiya.kb.model.doc.DocumentType; import io.github.trialiya.kb.model.doc.dto.Document; import io.github.trialiya.kb.model.doc.entity.DocumentEmbeddingEntity; import io.github.trialiya.kb.model.doc.entity.DocumentEntity; @@ -67,14 +68,14 @@ private DocumentService service() { // ── Fixtures ───────────────────────────────────────────────────────────── private DocumentEntity folder(String title, Long parentId, int position) { - return save(title, "folder", parentId, position); + return save(title, DocumentType.FOLDER, parentId, position); } private DocumentEntity doc(String title, Long parentId, int position) { - return save(title, "document", parentId, position); + return save(title, DocumentType.DOCUMENT, parentId, position); } - private DocumentEntity save(String title, String type, Long parentId, int position) { + private DocumentEntity save(String title, DocumentType type, Long parentId, int position) { return repo.save( new DocumentEntity( null, @@ -99,7 +100,7 @@ void productionMigrationsApplyAndSeedSystemDocuments() { // делает truncate + reinsert поверх сидов V1). DocumentEntity root = repo.findById(1L).orElseThrow(); assertThat(root.getTitle()).isEqualTo("Проект"); - assertThat(root.getType()).isEqualTo("folder"); + assertThat(root.getType()).isEqualTo(DocumentType.FOLDER); assertThat(root.isSystem()).isTrue(); // identity-генерация работает: новая вставка получает сгенерированный id, diff --git a/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportSubtreeTest.java b/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportSubtreeTest.java new file mode 100644 index 0000000..3f3a451 --- /dev/null +++ b/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportSubtreeTest.java @@ -0,0 +1,108 @@ +package io.github.trialiya.kb.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.github.trialiya.kb.config.model.DocumentsConfiguration; +import io.github.trialiya.kb.model.doc.DocumentType; +import io.github.trialiya.kb.model.doc.entity.DocumentEntity; +import io.github.trialiya.kb.repository.DocumentRepository; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link DocumentExportService#renderSubtree} — the in-memory, filesystem-free + * rendering used by the download endpoint. Repository is mocked. + * + *

+ *   Docs/ (id=1, folder)  description links to id=2
+ *     Intro (id=2)        description links to id=3
+ *     API   (id=3)
+ * 
+ */ +class DocumentExportSubtreeTest { + + private DocumentRepository repo; + private DocumentExportService service; + + private DocumentEntity docs; + private DocumentEntity intro; + private DocumentEntity api; + + @BeforeEach + void setUp() { + repo = mock(DocumentRepository.class); + service = new DocumentExportService(repo, new DocumentsConfiguration("/unused", true)); + + docs = entity(1, "Docs", DocumentType.FOLDER, null, 0, "Folder root, see [intro](/?doc=2)."); + intro = entity(2, "Intro", DocumentType.DOCUMENT, 1L, 0, "See [API doc](/?doc=3)."); + api = entity(3, "API", DocumentType.DOCUMENT, 1L, 1, "API body"); + + List all = new ArrayList<>(List.of(docs, intro, api)); + when(repo.findAll()).thenReturn(all); + when(repo.findById(1L)).thenReturn(Optional.of(docs)); + when(repo.findById(2L)).thenReturn(Optional.of(intro)); + when(repo.findById(3L)).thenReturn(Optional.of(api)); + } + + private static DocumentEntity entity( + long id, String title, DocumentType type, Long parentId, int pos, String description) { + DocumentEntity e = new DocumentEntity(); + e.setId(id); + e.setTitle(title); + e.setType(type); + e.setParentId(parentId); + e.setPosition(pos); + e.setDescription(description); + e.setUpdatedAt(LocalDateTime.of(2026, 6, 14, 12, 0)); + return e; + } + + @Test + void folderSubtreeContainsFullLayout() { + Map entries = service.renderSubtree(1, true); + + assertThat(entries).containsKeys( + "docs/.meta.yaml", + "docs/.content.md", + "docs/.index.md", + "docs/intro.md", + "docs/intro.yaml", + "docs/api.md", + "docs/api.yaml"); + } + + @Test + void folderSubtreeRewritesLinksRelativeToArchive() { + Map entries = service.renderSubtree(1, false); + + // Intro and API are siblings inside the folder → link becomes a bare file name. + assertThat(entries.get("docs/intro.md")).contains("[API doc](api.md)").doesNotContain("/?doc=3"); + // The folder body links to the child document. + assertThat(entries.get("docs/.content.md")).contains("[intro](intro.md)"); + // No meta requested. + assertThat(entries).doesNotContainKey("docs/intro.yaml"); + } + + @Test + void documentSubtreeIsSingleMarkdownEntry() { + Map entries = service.renderSubtree(2, false); + + assertThat(entries).containsOnlyKeys("intro.md"); + // The target (id=3) is outside this single-document subtree → link left untouched. + assertThat(entries.get("intro.md")).contains("[API doc](/?doc=3)"); + } + + @Test + void missingRootThrows() { + when(repo.findById(99L)).thenReturn(Optional.empty()); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.renderSubtree(99, true)) + .isInstanceOf(java.util.NoSuchElementException.class); + } +} diff --git a/backend/src/test/java/io/github/trialiya/kb/service/DocumentImportServiceTest.java b/backend/src/test/java/io/github/trialiya/kb/service/DocumentImportServiceTest.java new file mode 100644 index 0000000..fbd7fda --- /dev/null +++ b/backend/src/test/java/io/github/trialiya/kb/service/DocumentImportServiceTest.java @@ -0,0 +1,135 @@ +package io.github.trialiya.kb.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.github.trialiya.kb.config.model.DocumentsConfiguration; +import io.github.trialiya.kb.model.doc.dto.CreateDocumentRequest; +import io.github.trialiya.kb.model.doc.dto.Document; +import io.github.trialiya.kb.model.doc.dto.UpdateDocumentRequest; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Unit tests for {@link DocumentImportService}. {@link DocumentService} is mocked: {@code create} + * assigns sequential ids and records the requests; {@code update} records the reverse-link rewrites. + * + *
+ *   base/.index.md          → Docs, then Top
+ *   base/docs/.content.md   → "Folder body [Top](../top.md)"
+ *   base/docs/.index.md     → Intro
+ *   base/docs/intro.md      → "Intro [Top](../top.md)"
+ *   base/top.md             → "Top body"
+ * 
+ */ +class DocumentImportServiceTest { + + @TempDir Path base; + + private final List created = new ArrayList<>(); + private final Map updated = new HashMap<>(); + + private DocumentImportService newService() { + DocumentService docService = mock(DocumentService.class); + AtomicLong seq = new AtomicLong(); + + when(docService.create(any())) + .thenAnswer( + inv -> { + CreateDocumentRequest r = inv.getArgument(0); + created.add(r); + long id = seq.incrementAndGet(); + return new Document( + id, r.getTitle(), r.getType(), r.getParentId(), 1, 1, null, null, + null, null, false, null); + }); + when(docService.update(anyLong(), any())) + .thenAnswer( + inv -> { + long id = inv.getArgument(0); + UpdateDocumentRequest r = inv.getArgument(1); + updated.put(id, r.getDescription()); + return null; + }); + + return new DocumentImportService(docService, new DocumentsConfiguration(base.toString(), true)); + } + + private void writeFixture() throws Exception { + Files.writeString(base.resolve(".index.md"), "- [Docs](docs/.content.md)\n- [Top](top.md)\n"); + Path docs = Files.createDirectory(base.resolve("docs")); + Files.writeString(docs.resolve(".content.md"), "Folder body [Top](../top.md)"); + Files.writeString(docs.resolve(".index.md"), "- [Intro](intro.md)\n"); + Files.writeString(docs.resolve("intro.md"), "Intro [Top](../top.md)"); + Files.writeString(base.resolve("top.md"), "Top body"); + } + + @Test + void importsTreePreservingHierarchyOrderAndTitles() throws Exception { + writeFixture(); + + DocumentImportService.ImportResult result = newService().importFromFolder(null); + + assertThat(result.created()).isEqualTo(3); + assertThat(result.folders()).isEqualTo(1); + assertThat(result.documents()).isEqualTo(2); + + // Creation order: folder Docs (root) → Intro (child of Docs) → Top (root). + assertThat(created).hasSize(3); + assertThat(created.get(0).getTitle()).isEqualTo("Docs"); + assertThat(created.get(0).getType()).isEqualTo("folder"); + assertThat(created.get(0).getParentId()).isNull(); + + assertThat(created.get(1).getTitle()).isEqualTo("Intro"); + assertThat(created.get(1).getType()).isEqualTo("document"); + assertThat(created.get(1).getParentId()).isEqualTo(1L); // Docs got id=1 + + assertThat(created.get(2).getTitle()).isEqualTo("Top"); + assertThat(created.get(2).getParentId()).isNull(); + } + + @Test + void rewritesRelativeLinksBackToInternalDocLinks() throws Exception { + writeFixture(); + + newService().importFromFolder(null); + + // Top is created last with id=3; links pointing at ../top.md become /?doc=3. + assertThat(updated.get(1L)).contains("/?doc=3"); // folder Docs body + assertThat(updated.get(2L)).contains("/?doc=3"); // Intro body + assertThat(updated).doesNotContainKey(3L); // Top has no links → no rewrite + } + + @Test + void importsUnderGivenParent() throws Exception { + writeFixture(); + + newService().importFromFolder(42L); + + // Top-level imported nodes are parented to 42. + assertThat(created.get(0).getParentId()).isEqualTo(42L); // Docs + assertThat(created.get(2).getParentId()).isEqualTo(42L); // Top + } + + @Test + void missingFolderThrows() { + DocumentService docService = mock(DocumentService.class); + DocumentImportService service = + new DocumentImportService( + docService, new DocumentsConfiguration(base.resolve("nope").toString(), true)); + + assertThatThrownBy(() -> service.importFromFolder(null)) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/backend/src/test/java/io/github/trialiya/kb/service/DocumentServiceUnitTest.java b/backend/src/test/java/io/github/trialiya/kb/service/DocumentServiceUnitTest.java index 070cb62..561a6c4 100644 --- a/backend/src/test/java/io/github/trialiya/kb/service/DocumentServiceUnitTest.java +++ b/backend/src/test/java/io/github/trialiya/kb/service/DocumentServiceUnitTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.mock; import io.github.trialiya.kb.config.CommonConfig; +import io.github.trialiya.kb.model.doc.DocumentType; import io.github.trialiya.kb.model.doc.dto.Document; import io.github.trialiya.kb.model.doc.entity.DocumentEntity; import io.github.trialiya.kb.repository.DocumentHistoryRepository; @@ -69,19 +70,19 @@ void setUp() { // ── Fixture helpers ─────────────────────────────────────────────────────── private DocumentEntity folder(String title, Long parentId, int position) { - return save(title, "folder", parentId, position, false); + return save(title, DocumentType.FOLDER, parentId, position, false); } private DocumentEntity doc(String title, Long parentId, int position) { - return save(title, "document", parentId, position, false); + return save(title, DocumentType.DOCUMENT, parentId, position, false); } private DocumentEntity systemDoc(String title, Long parentId, int position) { - return save(title, "document", parentId, position, true); + return save(title, DocumentType.DOCUMENT, parentId, position, true); } private DocumentEntity save( - String title, String type, Long parentId, int position, boolean system) { + String title, DocumentType type, Long parentId, int position, boolean system) { return repo.save( new DocumentEntity( null, From e7c03889929973a68b80afeab4fa1ec32d4372ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 23:55:06 +0000 Subject: [PATCH 2/5] refactor(backend): stream subtree downloads via StreamingResponseBody Render the download subtree lazily as a Stream (depth-first walk using Stream.mapMulti for recursive child expansion) and write entries straight into the response OutputStream (ZipOutputStream for folders, raw Markdown for documents) instead of buffering the whole archive in a byte[]. renderSubtree is kept as a thin eager collector over streamSubtree for existing callers/tests. https://claude.ai/code/session_01XejhfdCBDHhk3pqcszieMC --- .../kb/controller/DocumentController.java | 73 +++++----- .../kb/service/DocumentExportService.java | 131 ++++++++++++------ 2 files changed, 125 insertions(+), 79 deletions(-) diff --git a/backend/src/main/java/io/github/trialiya/kb/controller/DocumentController.java b/backend/src/main/java/io/github/trialiya/kb/controller/DocumentController.java index 1809838..d1f2488 100644 --- a/backend/src/main/java/io/github/trialiya/kb/controller/DocumentController.java +++ b/backend/src/main/java/io/github/trialiya/kb/controller/DocumentController.java @@ -13,13 +13,9 @@ import io.github.trialiya.kb.service.DocumentImportService; import io.github.trialiya.kb.service.DocumentService; import io.github.trialiya.kb.service.SemanticSearchService; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.List; -import java.util.Map; -import java.util.NoSuchElementException; +import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import lombok.RequiredArgsConstructor; @@ -33,6 +29,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; @RestController @RequestMapping("/api/documents") @@ -103,7 +100,7 @@ public DocumentNode getDocument(@PathVariable long id) { *
GET /api/documents/{id}/download?meta=false
*/ @GetMapping("/{id}/download") - public ResponseEntity download( + public ResponseEntity download( @PathVariable long id, @RequestParam(defaultValue = "false") boolean meta) { DocumentNode node = service.getById(id); @@ -111,33 +108,43 @@ public ResponseEntity download( throw new ResponseStatusException(HttpStatus.NOT_FOUND); } - Map entries; - try { - entries = documentExportService.renderSubtree(id, meta); - } catch (NoSuchElementException e) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND); - } - String baseName = DocumentExportService.safeName(node.title()); if ("folder".equalsIgnoreCase(node.type())) { - byte[] zip = toZip(entries); - return fileResponse(zip, baseName + ".zip", "application/zip"); + StreamingResponseBody body = + out -> { + try (ZipOutputStream zos = new ZipOutputStream(out); + Stream entries = + documentExportService.streamSubtree(id, meta)) { + for (DocumentExportService.ExportEntry e : + (Iterable) entries::iterator) { + zos.putNextEntry(new ZipEntry(e.path())); + zos.write(e.content().getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + }; + return streamingResponse(body, baseName + ".zip", "application/zip"); } - // Document → return its Markdown body directly. - String markdown = - entries.entrySet().stream() - .filter(en -> en.getKey().endsWith(".md")) - .map(Map.Entry::getValue) - .findFirst() - .orElse(""); - return fileResponse( - markdown.getBytes(StandardCharsets.UTF_8), baseName + ".md", "text/markdown"); + // Document → stream its Markdown body directly. + StreamingResponseBody body = + out -> { + try (Stream entries = + documentExportService.streamSubtree(id, meta)) { + String markdown = + entries.filter(e -> e.path().endsWith(".md")) + .map(DocumentExportService.ExportEntry::content) + .findFirst() + .orElse(""); + out.write(markdown.getBytes(StandardCharsets.UTF_8)); + } + }; + return streamingResponse(body, baseName + ".md", "text/markdown"); } - private static ResponseEntity fileResponse( - byte[] body, String filename, String contentType) { + private static ResponseEntity streamingResponse( + StreamingResponseBody body, String filename, String contentType) { ContentDisposition disposition = ContentDisposition.attachment().filename(filename, StandardCharsets.UTF_8).build(); return ResponseEntity.ok() @@ -146,20 +153,6 @@ private static ResponseEntity fileResponse( .body(body); } - private static byte[] toZip(Map entries) { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - for (Map.Entry e : entries.entrySet()) { - zos.putNextEntry(new ZipEntry(e.getKey())); - zos.write(e.getValue().getBytes(StandardCharsets.UTF_8)); - zos.closeEntry(); - } - } catch (IOException ex) { - throw new UncheckedIOException("Failed to build zip archive", ex); - } - return baos.toByteArray(); - } - /** История изменений описания документа (newest-first). */ @GetMapping("/{id}/history") public List getHistory(@PathVariable long id) { diff --git a/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java b/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java index 5ee46c4..e0cd861 100644 --- a/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java +++ b/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java @@ -19,6 +19,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; +import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -144,12 +145,18 @@ public int exportAll(boolean includeMeta) { return count; } - // ── In-memory subtree rendering (for downloads) ────────────────────────── + // ── Subtree rendering (for downloads) ──────────────────────────────────── + + /** A single rendered file: its archive-relative {@code path} and full {@code content}. */ + public record ExportEntry(String path, String content) {} /** - * Renders the subtree rooted at {@code rootId} into an in-memory map of - * {@code relativePath → fileContent}, without touching the filesystem. Same layout as - * {@link #exportAll(boolean)} but scoped to one node: + * Lazily renders the subtree rooted at {@code rootId} as a stream of {@link ExportEntry} + * (path → content), without touching the filesystem. Entries are produced on demand during a + * depth-first walk, so a consumer (e.g. a {@code ZipOutputStream} writer) can stream them to the + * client without ever holding the whole subtree's content in memory at once. + * + *

Same layout as {@link #exportAll(boolean)} but scoped to one node: * *

    *
  • a document root yields a single {@code .md} entry (plus {@code .yaml} @@ -163,71 +170,117 @@ public int exportAll(boolean includeMeta) { * * @throws NoSuchElementException if no node with {@code rootId} exists */ - public LinkedHashMap renderSubtree(long rootId, boolean includeMeta) { + public Stream streamSubtree(long rootId, boolean includeMeta) { DocumentEntity root = repo.findById(rootId) .orElseThrow( () -> new NoSuchElementException("Document not found: " + rootId)); - List all = Streams.stream(repo.findAll()).collect(Collectors.toList()); Map> byParent = - all.stream() + Streams.stream(repo.findAll()) .filter(e -> e.getParentId() != null) .collect(Collectors.groupingBy(DocumentEntity::getParentId)); byParent.values() .forEach(list -> list.sort(Comparator.comparingInt(DocumentEntity::getPosition))); - // Virtual base — never hits disk; used only for relativising entry names and links. + // Virtual base — never hits disk; used only for relativising entry names and links. The + // id → path map is cheap (paths only, no content) and is needed up-front for link rewriting. Path base = Paths.get("/"); - Map idToPath = new HashMap<>(); collectPaths(root, base, byParent, idToPath); + return walk(root, base, base, byParent, idToPath, includeMeta); + } + + /** + * Renders the subtree rooted at {@code rootId} into an ordered, in-memory map of + * {@code relativePath → fileContent}. Thin eager wrapper over {@link #streamSubtree} kept for + * callers/tests that want the whole subtree materialised at once. + * + * @throws NoSuchElementException if no node with {@code rootId} exists + */ + public LinkedHashMap renderSubtree(long rootId, boolean includeMeta) { LinkedHashMap out = new LinkedHashMap<>(); - renderNodeToMap(root, base, base, byParent, idToPath, includeMeta, out); + try (Stream entries = streamSubtree(rootId, includeMeta)) { + entries.forEach(e -> out.put(e.path(), e.content())); + } return out; } - /** Recursive counterpart of {@link #exportNode} that appends to an in-memory map instead of disk. */ - private void renderNodeToMap( + /** + * Depth-first lazy walk producing one {@link ExportEntry} per file. Folders expand to their own + * header files (optional {@code .meta.yaml}, then {@code .content.md}), then their children + * (flattened recursively via {@link Stream#mapMulti}, evaluated on demand), then the trailing + * {@code .index.md}. Documents expand to their {@code .md} (plus {@code .yaml} with meta). + */ + private Stream walk( DocumentEntity entity, Path parentDir, Path base, Map> byParent, Map idToPath, - boolean includeMeta, - Map sink) { + boolean includeMeta) { - if (entity.getType().isFolder()) { - Path folderDir = parentDir.resolve(safeName(entity.getTitle())); + if (!entity.getType().isFolder()) { + return documentEntries(entity, base, idToPath, includeMeta); + } - if (includeMeta) { - put(sink, base, folderDir.resolve(FOLDER_META_FILE), renderMeta(entity)); - } - Path contentFile = folderDir.resolve(FOLDER_CONTENT_FILE); - put(sink, base, contentFile, renderContent(entity, contentFile, idToPath)); + Path folderDir = parentDir.resolve(safeName(entity.getTitle())); + List children = childrenOf(entity.getId(), byParent); + + Stream header = folderHeaderEntries(entity, folderDir, base, idToPath, includeMeta); + Stream descendants = + children.stream() + .mapMulti( + (child, sink) -> + walk(child, folderDir, base, byParent, idToPath, includeMeta) + .forEach(sink)); + Stream index = + Stream.of( + entry( + base, + folderDir.resolve(INDEX_FILE), + renderIndex(children, folderDir, idToPath))); + + return Stream.concat(Stream.concat(header, descendants), index); + } - List children = childrenOf(entity.getId(), byParent); - for (DocumentEntity child : children) { - renderNodeToMap(child, folderDir, base, byParent, idToPath, includeMeta, sink); - } - put(sink, base, folderDir.resolve(INDEX_FILE), renderIndex(children, folderDir, idToPath)); - } else { - Path contentFile = idToPath.get(entity.getId()); - put(sink, base, contentFile, renderContent(entity, contentFile, idToPath)); - if (includeMeta) { - String name = contentFile.getFileName().toString(); - Path metaFile = - contentFile.resolveSibling(name.substring(0, name.lastIndexOf('.')) + ".yaml"); - put(sink, base, metaFile, renderMeta(entity)); - } + /** Header entries of a folder: optional {@code .meta.yaml} followed by {@code .content.md}. */ + private Stream folderHeaderEntries( + DocumentEntity entity, + Path folderDir, + Path base, + Map idToPath, + boolean includeMeta) { + + Path contentFile = folderDir.resolve(FOLDER_CONTENT_FILE); + ExportEntry content = entry(base, contentFile, renderContent(entity, contentFile, idToPath)); + if (includeMeta) { + ExportEntry meta = entry(base, folderDir.resolve(FOLDER_META_FILE), renderMeta(entity)); + return Stream.of(meta, content); + } + return Stream.of(content); + } + + /** Entries of a document: its {@code .md} body and, with meta, the sidecar {@code .yaml}. */ + private Stream documentEntries( + DocumentEntity entity, Path base, Map idToPath, boolean includeMeta) { + + Path contentFile = idToPath.get(entity.getId()); + ExportEntry content = entry(base, contentFile, renderContent(entity, contentFile, idToPath)); + if (includeMeta) { + String name = contentFile.getFileName().toString(); + Path metaFile = + contentFile.resolveSibling(name.substring(0, name.lastIndexOf('.')) + ".yaml"); + return Stream.of(content, entry(base, metaFile, renderMeta(entity))); } + return Stream.of(content); } - /** Adds a single entry to the sink keyed by its path relative to {@code base} (forward slashes). */ - private void put(Map sink, Path base, Path file, String content) { - String entry = base.relativize(file).toString().replace('\\', '/'); - sink.put(entry, content); + /** Builds an {@link ExportEntry} keyed by {@code file}'s path relative to {@code base}. */ + private static ExportEntry entry(Path base, Path file, String content) { + String path = base.relativize(file).toString().replace('\\', '/'); + return new ExportEntry(path, content); } // ── Pass 1: collect id → path ──────────────────────────────────────────── From 7cd01b26ef0802d3d9c5a6e987a7aef672814876 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 00:04:44 +0000 Subject: [PATCH 3/5] refactor(backend): load subtree children per level instead of findAll() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single repo.findAll() table scan in the download/stream path with DocumentRepository.findAllByParentIdOrderByPosition(parentId): a position-sorted stream fetched one level at a time and drained inside try-with-resources so its JDBC cursor is released. The full documents table is never held in memory; only the lightweight id→path map (for link rewriting) and one level of entities live at a time. exportAll (disk export) is left unchanged. https://claude.ai/code/session_01XejhfdCBDHhk3pqcszieMC --- .../kb/repository/DocumentRepository.java | 9 ++++ .../kb/service/DocumentExportService.java | 46 ++++++++++++++----- .../kb/service/DocumentExportSubtreeTest.java | 11 +++-- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/backend/src/main/java/io/github/trialiya/kb/repository/DocumentRepository.java b/backend/src/main/java/io/github/trialiya/kb/repository/DocumentRepository.java index 7b85522..4884066 100644 --- a/backend/src/main/java/io/github/trialiya/kb/repository/DocumentRepository.java +++ b/backend/src/main/java/io/github/trialiya/kb/repository/DocumentRepository.java @@ -3,6 +3,7 @@ import io.github.trialiya.kb.model.doc.entity.DocumentEntity; import java.util.List; import java.util.Set; +import java.util.stream.Stream; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jdbc.repository.query.Modifying; @@ -25,6 +26,14 @@ public interface DocumentRepository "SELECT * FROM documents WHERE parent_id = :parentId ORDER BY position, type DESC, title") List findByParentId(@Param("parentId") Long parentId); + /** + * Streams the direct children of a folder, ordered by position, fetched lazily one level at a + * time. The caller must close the returned stream (try-with-resources) so the underlying + * JDBC connection/cursor is released. Used by the subtree download to avoid loading the whole + * {@code documents} table into memory. + */ + Stream findAllByParentIdOrderByPosition(Long parentId); + /** Paginated children of a given parent folder, sorted by position. */ Page findByParentId(Long parentId, Pageable pageable); diff --git a/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java b/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java index e0cd861..f2563f4 100644 --- a/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java +++ b/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java @@ -176,20 +176,43 @@ public Stream streamSubtree(long rootId, boolean includeMeta) { .orElseThrow( () -> new NoSuchElementException("Document not found: " + rootId)); - Map> byParent = - Streams.stream(repo.findAll()) - .filter(e -> e.getParentId() != null) - .collect(Collectors.groupingBy(DocumentEntity::getParentId)); - byParent.values() - .forEach(list -> list.sort(Comparator.comparingInt(DocumentEntity::getPosition))); - // Virtual base — never hits disk; used only for relativising entry names and links. The // id → path map is cheap (paths only, no content) and is needed up-front for link rewriting. + // Children are loaded one level at a time (see #subtreeChildren) rather than via a single + // full-table scan, so the whole document set is never held in memory. Path base = Paths.get("/"); Map idToPath = new HashMap<>(); - collectPaths(root, base, byParent, idToPath); + collectSubtreePaths(root, base, idToPath); - return walk(root, base, base, byParent, idToPath, includeMeta); + return walk(root, base, base, idToPath, includeMeta); + } + + /** + * Filesystem-free counterpart of {@link #collectPaths} for the in-memory subtree: populates + * {@code idToPath} with each node's archive-relative target, loading children lazily per level. + */ + private void collectSubtreePaths(DocumentEntity entity, Path parentDir, Map idToPath) { + if (entity.getType().isFolder()) { + Path folderDir = parentDir.resolve(safeName(entity.getTitle())); + idToPath.put(entity.getId(), folderDir.resolve(FOLDER_CONTENT_FILE)); + for (DocumentEntity child : subtreeChildren(entity.getId())) { + collectSubtreePaths(child, folderDir, idToPath); + } + } else { + idToPath.put(entity.getId(), parentDir.resolve(safeName(entity.getTitle()) + ".md")); + } + } + + /** + * Loads the direct children of {@code parentId}, already sorted by position in SQL, draining and + * closing the repository stream so its JDBC cursor is released. Materialised into a list because + * each level is consumed twice (folder body links + the trailing {@code .index.md}); only one + * level lives in memory at a time. + */ + private List subtreeChildren(long parentId) { + try (Stream children = repo.findAllByParentIdOrderByPosition(parentId)) { + return children.toList(); + } } /** @@ -217,7 +240,6 @@ private Stream walk( DocumentEntity entity, Path parentDir, Path base, - Map> byParent, Map idToPath, boolean includeMeta) { @@ -226,14 +248,14 @@ private Stream walk( } Path folderDir = parentDir.resolve(safeName(entity.getTitle())); - List children = childrenOf(entity.getId(), byParent); + List children = subtreeChildren(entity.getId()); Stream header = folderHeaderEntries(entity, folderDir, base, idToPath, includeMeta); Stream descendants = children.stream() .mapMulti( (child, sink) -> - walk(child, folderDir, base, byParent, idToPath, includeMeta) + walk(child, folderDir, base, idToPath, includeMeta) .forEach(sink)); Stream index = Stream.of( diff --git a/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportSubtreeTest.java b/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportSubtreeTest.java index 3f3a451..140ed5e 100644 --- a/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportSubtreeTest.java +++ b/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportSubtreeTest.java @@ -9,10 +9,9 @@ import io.github.trialiya.kb.model.doc.entity.DocumentEntity; import io.github.trialiya.kb.repository.DocumentRepository; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -44,11 +43,15 @@ void setUp() { intro = entity(2, "Intro", DocumentType.DOCUMENT, 1L, 0, "See [API doc](/?doc=3)."); api = entity(3, "API", DocumentType.DOCUMENT, 1L, 1, "API body"); - List all = new ArrayList<>(List.of(docs, intro, api)); - when(repo.findAll()).thenReturn(all); when(repo.findById(1L)).thenReturn(Optional.of(docs)); when(repo.findById(2L)).thenReturn(Optional.of(intro)); when(repo.findById(3L)).thenReturn(Optional.of(api)); + + // Children are loaded per level; each call must yield a fresh (single-use) stream because + // the subtree is walked twice (path collection + content rendering). + when(repo.findAllByParentIdOrderByPosition(1L)).thenAnswer(inv -> Stream.of(intro, api)); + when(repo.findAllByParentIdOrderByPosition(2L)).thenAnswer(inv -> Stream.empty()); + when(repo.findAllByParentIdOrderByPosition(3L)).thenAnswer(inv -> Stream.empty()); } private static DocumentEntity entity( From 0d095c1406836b21c79728c417b725fed48df678 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 00:12:19 +0000 Subject: [PATCH 4/5] refactor(backend): drop findAll() from exportAll, unify per-level loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exportAll now loads roots via findRoots and each level via the shared childrenOf(parentId) helper (findAllByParentIdOrderByPosition, stream closed immediately) instead of a full-table findAll() + in-memory byParent index — consistent with the subtree streaming path. Adds DocumentExportAllTest covering the on-disk layout, link rewriting, and meta toggle; applies spotless formatting. https://claude.ai/code/session_01XejhfdCBDHhk3pqcszieMC --- .../kb/controller/DocumentController.java | 9 +- .../kb/service/DocumentExportService.java | 96 ++++++------- .../kb/service/DocumentExportAllTest.java | 129 ++++++++++++++++++ .../kb/service/DocumentExportSubtreeTest.java | 30 ++-- 4 files changed, 192 insertions(+), 72 deletions(-) create mode 100644 backend/src/test/java/io/github/trialiya/kb/service/DocumentExportAllTest.java diff --git a/backend/src/main/java/io/github/trialiya/kb/controller/DocumentController.java b/backend/src/main/java/io/github/trialiya/kb/controller/DocumentController.java index d1f2488..87c0e82 100644 --- a/backend/src/main/java/io/github/trialiya/kb/controller/DocumentController.java +++ b/backend/src/main/java/io/github/trialiya/kb/controller/DocumentController.java @@ -93,9 +93,9 @@ public DocumentNode getDocument(@PathVariable long id) { /** * Downloads a node: a document is returned as a single {@code .md} file, a folder - * as a {@code .zip} archive of its subtree (Markdown + optional {@code .yaml} metadata, mirroring - * the export layout). Internal {@code /?doc=ID} links are rewritten to relative paths within the - * archive. + * as a {@code .zip} archive of its subtree (Markdown + optional {@code .yaml} metadata, + * mirroring the export layout). Internal {@code /?doc=ID} links are rewritten to relative paths + * within the archive. * *
    GET /api/documents/{id}/download?meta=false
    */ @@ -117,7 +117,8 @@ public ResponseEntity download( Stream entries = documentExportService.streamSubtree(id, meta)) { for (DocumentExportService.ExportEntry e : - (Iterable) entries::iterator) { + (Iterable) + entries::iterator) { zos.putNextEntry(new ZipEntry(e.path())); zos.write(e.content().getBytes(StandardCharsets.UTF_8)); zos.closeEntry(); diff --git a/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java b/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java index f2563f4..a2150c1 100644 --- a/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java +++ b/backend/src/main/java/io/github/trialiya/kb/service/DocumentExportService.java @@ -1,6 +1,5 @@ package io.github.trialiya.kb.service; -import com.google.common.collect.Streams; import io.github.trialiya.kb.config.model.DocumentsConfiguration; import io.github.trialiya.kb.model.doc.entity.DocumentEntity; import io.github.trialiya.kb.repository.DocumentRepository; @@ -9,7 +8,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; @@ -18,7 +16,6 @@ import java.util.NoSuchElementException; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -110,31 +107,22 @@ public int exportAll() { public int exportAll(boolean includeMeta) { Path root = Paths.get(config.exportPath()); - List all = Streams.stream(repo.findAll()).collect(Collectors.toList()); - - // Build parent → children index - Map> byParent = - all.stream() - .filter(e -> e.getParentId() != null) - .collect(Collectors.groupingBy(DocumentEntity::getParentId)); - - byParent.values() - .forEach(list -> list.sort(Comparator.comparingInt(DocumentEntity::getPosition))); - + // Children are loaded one level at a time (see #childrenOf) rather than via a single + // full-table scan, so the whole document set is never held in memory. List roots = repo.findRoots(); roots.sort(Comparator.comparingInt(DocumentEntity::getPosition)); // Pass 1: build id → absolute .md path map (needed for link rewriting) Map idToPath = new HashMap<>(); for (DocumentEntity rootDoc : roots) { - collectPaths(rootDoc, root, byParent, idToPath); + collectPaths(rootDoc, root, idToPath); } // Pass 2: write files createDirectories(root); int count = 0; for (DocumentEntity rootDoc : roots) { - count += exportNode(rootDoc, root, byParent, idToPath, includeMeta); + count += exportNode(rootDoc, root, idToPath, includeMeta); } // Write root-level index @@ -153,20 +141,20 @@ public record ExportEntry(String path, String content) {} /** * Lazily renders the subtree rooted at {@code rootId} as a stream of {@link ExportEntry} * (path → content), without touching the filesystem. Entries are produced on demand during a - * depth-first walk, so a consumer (e.g. a {@code ZipOutputStream} writer) can stream them to the - * client without ever holding the whole subtree's content in memory at once. + * depth-first walk, so a consumer (e.g. a {@code ZipOutputStream} writer) can stream them to + * the client without ever holding the whole subtree's content in memory at once. * *

    Same layout as {@link #exportAll(boolean)} but scoped to one node: * *

      - *
    • a document root yields a single {@code .md} entry (plus {@code .yaml} - * when {@code includeMeta}); - *
    • a folder root yields the folder directory with its {@code .content.md} / - * {@code .index.md} / children, ready to be zipped. + *
    • a document root yields a single {@code .md} entry (plus {@code + * .yaml} when {@code includeMeta}); + *
    • a folder root yields the folder directory with its {@code .content.md} / {@code + * .index.md} / children, ready to be zipped. *
    * - *

    Internal {@code /?doc=ID} links are rewritten to relative paths within the subtree; - * links pointing outside the subtree are left untouched. + *

    Internal {@code /?doc=ID} links are rewritten to relative paths within the + * subtree; links pointing outside the subtree are left untouched. * * @throws NoSuchElementException if no node with {@code rootId} exists */ @@ -177,8 +165,9 @@ public Stream streamSubtree(long rootId, boolean includeMeta) { () -> new NoSuchElementException("Document not found: " + rootId)); // Virtual base — never hits disk; used only for relativising entry names and links. The - // id → path map is cheap (paths only, no content) and is needed up-front for link rewriting. - // Children are loaded one level at a time (see #subtreeChildren) rather than via a single + // id → path map is cheap (paths only, no content) and is needed up-front for link + // rewriting. + // Children are loaded one level at a time (see #childrenOf) rather than via a single // full-table scan, so the whole document set is never held in memory. Path base = Paths.get("/"); Map idToPath = new HashMap<>(); @@ -191,11 +180,12 @@ public Stream streamSubtree(long rootId, boolean includeMeta) { * Filesystem-free counterpart of {@link #collectPaths} for the in-memory subtree: populates * {@code idToPath} with each node's archive-relative target, loading children lazily per level. */ - private void collectSubtreePaths(DocumentEntity entity, Path parentDir, Map idToPath) { + private void collectSubtreePaths( + DocumentEntity entity, Path parentDir, Map idToPath) { if (entity.getType().isFolder()) { Path folderDir = parentDir.resolve(safeName(entity.getTitle())); idToPath.put(entity.getId(), folderDir.resolve(FOLDER_CONTENT_FILE)); - for (DocumentEntity child : subtreeChildren(entity.getId())) { + for (DocumentEntity child : childrenOf(entity.getId())) { collectSubtreePaths(child, folderDir, idToPath); } } else { @@ -204,20 +194,20 @@ private void collectSubtreePaths(DocumentEntity entity, Path parentDir, Map subtreeChildren(long parentId) { + private List childrenOf(long parentId) { try (Stream children = repo.findAllByParentIdOrderByPosition(parentId)) { return children.toList(); } } /** - * Renders the subtree rooted at {@code rootId} into an ordered, in-memory map of - * {@code relativePath → fileContent}. Thin eager wrapper over {@link #streamSubtree} kept for + * Renders the subtree rooted at {@code rootId} into an ordered, in-memory map of {@code + * relativePath → fileContent}. Thin eager wrapper over {@link #streamSubtree} kept for * callers/tests that want the whole subtree materialised at once. * * @throws NoSuchElementException if no node with {@code rootId} exists @@ -248,9 +238,10 @@ private Stream walk( } Path folderDir = parentDir.resolve(safeName(entity.getTitle())); - List children = subtreeChildren(entity.getId()); + List children = childrenOf(entity.getId()); - Stream header = folderHeaderEntries(entity, folderDir, base, idToPath, includeMeta); + Stream header = + folderHeaderEntries(entity, folderDir, base, idToPath, includeMeta); Stream descendants = children.stream() .mapMulti( @@ -276,7 +267,8 @@ private Stream folderHeaderEntries( boolean includeMeta) { Path contentFile = folderDir.resolve(FOLDER_CONTENT_FILE); - ExportEntry content = entry(base, contentFile, renderContent(entity, contentFile, idToPath)); + ExportEntry content = + entry(base, contentFile, renderContent(entity, contentFile, idToPath)); if (includeMeta) { ExportEntry meta = entry(base, folderDir.resolve(FOLDER_META_FILE), renderMeta(entity)); return Stream.of(meta, content); @@ -289,7 +281,8 @@ private Stream documentEntries( DocumentEntity entity, Path base, Map idToPath, boolean includeMeta) { Path contentFile = idToPath.get(entity.getId()); - ExportEntry content = entry(base, contentFile, renderContent(entity, contentFile, idToPath)); + ExportEntry content = + entry(base, contentFile, renderContent(entity, contentFile, idToPath)); if (includeMeta) { String name = contentFile.getFileName().toString(); Path metaFile = @@ -312,11 +305,7 @@ private static ExportEntry entry(Path base, Path file, String content) { * will be written to. Folder content maps to the {@code .content.md} hidden file inside the * folder directory. */ - private void collectPaths( - DocumentEntity entity, - Path parentDir, - Map> byParent, - Map idToPath) { + private void collectPaths(DocumentEntity entity, Path parentDir, Map idToPath) { boolean isFolder = entity.getType().isFolder(); @@ -324,9 +313,8 @@ private void collectPaths( Path folderDir = parentDir.resolve(safeName(entity.getTitle())); idToPath.put(entity.getId(), folderDir.resolve(FOLDER_CONTENT_FILE)); - List children = childrenOf(entity.getId(), byParent); - for (DocumentEntity child : children) { - collectPaths(child, folderDir, byParent, idToPath); + for (DocumentEntity child : childrenOf(entity.getId())) { + collectPaths(child, folderDir, idToPath); } } else { Path candidate = parentDir.resolve(safeName(entity.getTitle()) + ".md"); @@ -345,11 +333,7 @@ private void collectPaths( // ── Pass 2: recursive tree walk ────────────────────────────────────────── private int exportNode( - DocumentEntity entity, - Path parentDir, - Map> byParent, - Map idToPath, - boolean includeMeta) { + DocumentEntity entity, Path parentDir, Map idToPath, boolean includeMeta) { boolean isFolder = entity.getType().isFolder(); int written = 0; @@ -369,9 +353,9 @@ private int exportNode( written++; log.debug("Written folder content: {}", contentFile); - List children = childrenOf(entity.getId(), byParent); + List children = childrenOf(entity.getId()); for (DocumentEntity child : children) { - written += exportNode(child, folderDir, byParent, idToPath, includeMeta); + written += exportNode(child, folderDir, idToPath, includeMeta); } // Write index for this folder @@ -550,10 +534,6 @@ private static String escapeYaml(String value) { // ── Misc helpers ───────────────────────────────────────────────────────── - private List childrenOf(Long id, Map> byParent) { - return byParent.getOrDefault(id, Collections.emptyList()); - } - private boolean hasContent(String s) { return s != null && !s.isBlank(); } diff --git a/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportAllTest.java b/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportAllTest.java new file mode 100644 index 0000000..9382319 --- /dev/null +++ b/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportAllTest.java @@ -0,0 +1,129 @@ +package io.github.trialiya.kb.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.github.trialiya.kb.config.model.DocumentsConfiguration; +import io.github.trialiya.kb.model.doc.DocumentType; +import io.github.trialiya.kb.model.doc.entity.DocumentEntity; +import io.github.trialiya.kb.repository.DocumentRepository; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Unit tests for {@link DocumentExportService#exportAll(boolean)} — the on-disk export. The + * repository is mocked and serves children one level at a time via {@code findRoots} / {@code + * findAllByParentIdOrderByPosition}, so the test also pins down that {@code exportAll} never relies + * on a full-table {@code findAll()} scan. + * + *

    + *   <root>/
    + *     docs/ (id=1, folder)   .content.md links to id=2
    + *       Intro (id=2)
    + *       API   (id=3)
    + *     readme (id=4)          description links to id=2
    + * 
    + */ +class DocumentExportAllTest { + + @TempDir Path exportDir; + + private DocumentRepository repo; + private DocumentExportService service; + + private DocumentEntity docs; + private DocumentEntity intro; + private DocumentEntity api; + private DocumentEntity readme; + + @BeforeEach + void setUp() { + repo = mock(DocumentRepository.class); + service = + new DocumentExportService( + repo, new DocumentsConfiguration(exportDir.toString(), true)); + + docs = + entity( + 1, + "Docs", + DocumentType.FOLDER, + null, + 0, + "Folder root, see [intro](/?doc=2)."); + intro = entity(2, "Intro", DocumentType.DOCUMENT, 1L, 0, "Intro body"); + api = entity(3, "API", DocumentType.DOCUMENT, 1L, 1, "API body"); + readme = entity(4, "Readme", DocumentType.DOCUMENT, null, 1, "Start at [intro](/?doc=2)."); + + when(repo.findRoots()).thenReturn(new ArrayList<>(List.of(docs, readme))); + // Each call yields a fresh (single-use) stream — the tree is walked twice (paths + write). + when(repo.findAllByParentIdOrderByPosition(1L)).thenAnswer(inv -> Stream.of(intro, api)); + when(repo.findAllByParentIdOrderByPosition(2L)).thenAnswer(inv -> Stream.empty()); + when(repo.findAllByParentIdOrderByPosition(3L)).thenAnswer(inv -> Stream.empty()); + when(repo.findAllByParentIdOrderByPosition(4L)).thenAnswer(inv -> Stream.empty()); + } + + private static DocumentEntity entity( + long id, String title, DocumentType type, Long parentId, int pos, String description) { + DocumentEntity e = new DocumentEntity(); + e.setId(id); + e.setTitle(title); + e.setType(type); + e.setParentId(parentId); + e.setPosition(pos); + e.setDescription(description); + e.setUpdatedAt(LocalDateTime.of(2026, 6, 14, 12, 0)); + return e; + } + + @Test + void writesFullTreeLayoutToDisk() { + int count = service.exportAll(true); + + assertThat(exportDir.resolve(".index.md")).exists(); + assertThat(exportDir.resolve("docs/.meta.yaml")).exists(); + assertThat(exportDir.resolve("docs/.content.md")).exists(); + assertThat(exportDir.resolve("docs/.index.md")).exists(); + assertThat(exportDir.resolve("docs/intro.md")).exists(); + assertThat(exportDir.resolve("docs/intro.yaml")).exists(); + assertThat(exportDir.resolve("docs/api.md")).exists(); + assertThat(exportDir.resolve("docs/api.yaml")).exists(); + assertThat(exportDir.resolve("readme.md")).exists(); + assertThat(exportDir.resolve("readme.yaml")).exists(); + // Return value counts every written file. + assertThat(count).isPositive(); + } + + @Test + void rewritesInternalLinksToRelativePaths() throws Exception { + service.exportAll(false); + + // Root-level readme → intro lives under docs/. + assertThat(Files.readString(exportDir.resolve("readme.md"))) + .contains("[intro](docs/intro.md)") + .doesNotContain("/?doc=2"); + // Folder body → its own child. + assertThat(Files.readString(exportDir.resolve("docs/.content.md"))) + .contains("[intro](intro.md)"); + } + + @Test + void skipsMetaWhenDisabled() { + service.exportAll(false); + + assertThat(exportDir.resolve("docs/.meta.yaml")).doesNotExist(); + assertThat(exportDir.resolve("docs/intro.yaml")).doesNotExist(); + assertThat(exportDir.resolve("readme.yaml")).doesNotExist(); + // Content files are still written. + assertThat(exportDir.resolve("docs/.content.md")).exists(); + assertThat(exportDir.resolve("readme.md")).exists(); + } +} diff --git a/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportSubtreeTest.java b/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportSubtreeTest.java index 140ed5e..7c8c18b 100644 --- a/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportSubtreeTest.java +++ b/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportSubtreeTest.java @@ -39,7 +39,14 @@ void setUp() { repo = mock(DocumentRepository.class); service = new DocumentExportService(repo, new DocumentsConfiguration("/unused", true)); - docs = entity(1, "Docs", DocumentType.FOLDER, null, 0, "Folder root, see [intro](/?doc=2)."); + docs = + entity( + 1, + "Docs", + DocumentType.FOLDER, + null, + 0, + "Folder root, see [intro](/?doc=2)."); intro = entity(2, "Intro", DocumentType.DOCUMENT, 1L, 0, "See [API doc](/?doc=3)."); api = entity(3, "API", DocumentType.DOCUMENT, 1L, 1, "API body"); @@ -71,14 +78,15 @@ private static DocumentEntity entity( void folderSubtreeContainsFullLayout() { Map entries = service.renderSubtree(1, true); - assertThat(entries).containsKeys( - "docs/.meta.yaml", - "docs/.content.md", - "docs/.index.md", - "docs/intro.md", - "docs/intro.yaml", - "docs/api.md", - "docs/api.yaml"); + assertThat(entries) + .containsKeys( + "docs/.meta.yaml", + "docs/.content.md", + "docs/.index.md", + "docs/intro.md", + "docs/intro.yaml", + "docs/api.md", + "docs/api.yaml"); } @Test @@ -86,7 +94,9 @@ void folderSubtreeRewritesLinksRelativeToArchive() { Map entries = service.renderSubtree(1, false); // Intro and API are siblings inside the folder → link becomes a bare file name. - assertThat(entries.get("docs/intro.md")).contains("[API doc](api.md)").doesNotContain("/?doc=3"); + assertThat(entries.get("docs/intro.md")) + .contains("[API doc](api.md)") + .doesNotContain("/?doc=3"); // The folder body links to the child document. assertThat(entries.get("docs/.content.md")).contains("[intro](intro.md)"); // No meta requested. From fdbf96ff4c695c38e051df52db896007c0ba00c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 00:20:23 +0000 Subject: [PATCH 5/5] test(backend): fix DocumentExportServiceTest for enum + per-level loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge main into the branch (resolving the merge-only CI failure) and update DocumentExportServiceTest — added on main after this branch diverged — to the DocumentType enum and the per-level repository loader (findAllByParentIdOrderByPosition) instead of the removed findAll(). Drops the now-redundant DocumentExportAllTest, since the merged DocumentExportServiceTest covers exportAll comprehensively. https://claude.ai/code/session_01XejhfdCBDHhk3pqcszieMC --- .../kb/service/DocumentExportAllTest.java | 129 ------------------ .../kb/service/DocumentExportServiceTest.java | 22 ++- 2 files changed, 18 insertions(+), 133 deletions(-) delete mode 100644 backend/src/test/java/io/github/trialiya/kb/service/DocumentExportAllTest.java diff --git a/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportAllTest.java b/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportAllTest.java deleted file mode 100644 index 9382319..0000000 --- a/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportAllTest.java +++ /dev/null @@ -1,129 +0,0 @@ -package io.github.trialiya.kb.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import io.github.trialiya.kb.config.model.DocumentsConfiguration; -import io.github.trialiya.kb.model.doc.DocumentType; -import io.github.trialiya.kb.model.doc.entity.DocumentEntity; -import io.github.trialiya.kb.repository.DocumentRepository; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Stream; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Unit tests for {@link DocumentExportService#exportAll(boolean)} — the on-disk export. The - * repository is mocked and serves children one level at a time via {@code findRoots} / {@code - * findAllByParentIdOrderByPosition}, so the test also pins down that {@code exportAll} never relies - * on a full-table {@code findAll()} scan. - * - *
    - *   <root>/
    - *     docs/ (id=1, folder)   .content.md links to id=2
    - *       Intro (id=2)
    - *       API   (id=3)
    - *     readme (id=4)          description links to id=2
    - * 
    - */ -class DocumentExportAllTest { - - @TempDir Path exportDir; - - private DocumentRepository repo; - private DocumentExportService service; - - private DocumentEntity docs; - private DocumentEntity intro; - private DocumentEntity api; - private DocumentEntity readme; - - @BeforeEach - void setUp() { - repo = mock(DocumentRepository.class); - service = - new DocumentExportService( - repo, new DocumentsConfiguration(exportDir.toString(), true)); - - docs = - entity( - 1, - "Docs", - DocumentType.FOLDER, - null, - 0, - "Folder root, see [intro](/?doc=2)."); - intro = entity(2, "Intro", DocumentType.DOCUMENT, 1L, 0, "Intro body"); - api = entity(3, "API", DocumentType.DOCUMENT, 1L, 1, "API body"); - readme = entity(4, "Readme", DocumentType.DOCUMENT, null, 1, "Start at [intro](/?doc=2)."); - - when(repo.findRoots()).thenReturn(new ArrayList<>(List.of(docs, readme))); - // Each call yields a fresh (single-use) stream — the tree is walked twice (paths + write). - when(repo.findAllByParentIdOrderByPosition(1L)).thenAnswer(inv -> Stream.of(intro, api)); - when(repo.findAllByParentIdOrderByPosition(2L)).thenAnswer(inv -> Stream.empty()); - when(repo.findAllByParentIdOrderByPosition(3L)).thenAnswer(inv -> Stream.empty()); - when(repo.findAllByParentIdOrderByPosition(4L)).thenAnswer(inv -> Stream.empty()); - } - - private static DocumentEntity entity( - long id, String title, DocumentType type, Long parentId, int pos, String description) { - DocumentEntity e = new DocumentEntity(); - e.setId(id); - e.setTitle(title); - e.setType(type); - e.setParentId(parentId); - e.setPosition(pos); - e.setDescription(description); - e.setUpdatedAt(LocalDateTime.of(2026, 6, 14, 12, 0)); - return e; - } - - @Test - void writesFullTreeLayoutToDisk() { - int count = service.exportAll(true); - - assertThat(exportDir.resolve(".index.md")).exists(); - assertThat(exportDir.resolve("docs/.meta.yaml")).exists(); - assertThat(exportDir.resolve("docs/.content.md")).exists(); - assertThat(exportDir.resolve("docs/.index.md")).exists(); - assertThat(exportDir.resolve("docs/intro.md")).exists(); - assertThat(exportDir.resolve("docs/intro.yaml")).exists(); - assertThat(exportDir.resolve("docs/api.md")).exists(); - assertThat(exportDir.resolve("docs/api.yaml")).exists(); - assertThat(exportDir.resolve("readme.md")).exists(); - assertThat(exportDir.resolve("readme.yaml")).exists(); - // Return value counts every written file. - assertThat(count).isPositive(); - } - - @Test - void rewritesInternalLinksToRelativePaths() throws Exception { - service.exportAll(false); - - // Root-level readme → intro lives under docs/. - assertThat(Files.readString(exportDir.resolve("readme.md"))) - .contains("[intro](docs/intro.md)") - .doesNotContain("/?doc=2"); - // Folder body → its own child. - assertThat(Files.readString(exportDir.resolve("docs/.content.md"))) - .contains("[intro](intro.md)"); - } - - @Test - void skipsMetaWhenDisabled() { - service.exportAll(false); - - assertThat(exportDir.resolve("docs/.meta.yaml")).doesNotExist(); - assertThat(exportDir.resolve("docs/intro.yaml")).doesNotExist(); - assertThat(exportDir.resolve("readme.yaml")).doesNotExist(); - // Content files are still written. - assertThat(exportDir.resolve("docs/.content.md")).exists(); - assertThat(exportDir.resolve("readme.md")).exists(); - } -} diff --git a/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportServiceTest.java b/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportServiceTest.java index a48395b..c4aac48 100644 --- a/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportServiceTest.java +++ b/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportServiceTest.java @@ -1,16 +1,20 @@ package io.github.trialiya.kb.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import io.github.trialiya.kb.config.model.DocumentsConfiguration; +import io.github.trialiya.kb.model.doc.DocumentType; import io.github.trialiya.kb.model.doc.entity.DocumentEntity; import io.github.trialiya.kb.repository.DocumentRepository; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDateTime; +import java.util.Comparator; import java.util.List; +import java.util.Objects; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -50,7 +54,7 @@ private static DocumentEntity doc( DocumentEntity e = new DocumentEntity(); e.setId(id); e.setTitle(title); - e.setType("document"); + e.setType(DocumentType.DOCUMENT); e.setParentId(parentId); e.setPosition(position); e.setDescription(description); @@ -61,15 +65,25 @@ private static DocumentEntity doc( private static DocumentEntity folder( long id, String title, Long parentId, int position, String description) { DocumentEntity e = doc(id, title, parentId, position, description); - e.setType("folder"); + e.setType(DocumentType.FOLDER); return e; } /** Wires the standard fixture tree into the mocked repository. */ private void stubTree(List all, List roots) { - // Mutable copies — the service sorts the returned lists in place. - when(repo.findAll()).thenReturn(new java.util.ArrayList<>(all)); + // Mutable copy — the service sorts the returned roots in place. when(repo.findRoots()).thenReturn(new java.util.ArrayList<>(roots)); + // Children are loaded one level at a time; answer with a fresh, position-sorted stream for + // any parent id (the tree is walked twice — path collection + write — so it can't be + // reused). + when(repo.findAllByParentIdOrderByPosition(anyLong())) + .thenAnswer( + inv -> { + long parentId = inv.getArgument(0); + return all.stream() + .filter(e -> Objects.equals(e.getParentId(), parentId)) + .sorted(Comparator.comparingInt(DocumentEntity::getPosition)); + }); } private String read(String relativePath) throws Exception {