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..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 @@ -10,16 +10,26 @@ 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.nio.charset.StandardCharsets; import java.util.List; +import java.util.stream.Stream; +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; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; @RestController @RequestMapping("/api/documents") @@ -28,6 +38,7 @@ public class DocumentController { private final DocumentService service; private final DocumentExportService documentExportService; + private final DocumentImportService documentImportService; private final SemanticSearchService semanticSearchService; // ── Tree ────────────────────────────────────────────────────────────────── @@ -80,6 +91,69 @@ 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); + } + + String baseName = DocumentExportService.safeName(node.title()); + + if ("folder".equalsIgnoreCase(node.type())) { + 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 → 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 streamingResponse( + StreamingResponseBody 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); + } + /** История изменений описания документа (newest-first). */ @GetMapping("/{id}/history") public List getHistory(@PathVariable long id) { @@ -215,5 +289,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/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 2b5abfd..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,14 +8,15 @@ 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; 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; +import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -107,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 @@ -142,6 +133,171 @@ public int exportAll(boolean includeMeta) { return count; } + // ── Subtree rendering (for downloads) ──────────────────────────────────── + + /** A single rendered file: its archive-relative {@code path} and full {@code content}. */ + 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. + * + *

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 Stream streamSubtree(long rootId, boolean includeMeta) { + DocumentEntity root = + repo.findById(rootId) + .orElseThrow( + () -> 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 #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<>(); + collectSubtreePaths(root, base, idToPath); + + 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 : childrenOf(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 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 + * 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<>(); + try (Stream entries = streamSubtree(rootId, includeMeta)) { + entries.forEach(e -> out.put(e.path(), e.content())); + } + return out; + } + + /** + * 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 idToPath, + boolean includeMeta) { + + if (!entity.getType().isFolder()) { + return documentEntries(entity, base, idToPath, includeMeta); + } + + Path folderDir = parentDir.resolve(safeName(entity.getTitle())); + List children = childrenOf(entity.getId()); + + Stream header = + folderHeaderEntries(entity, folderDir, base, idToPath, includeMeta); + Stream descendants = + children.stream() + .mapMulti( + (child, sink) -> + walk(child, folderDir, base, 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); + } + + /** 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); + } + + /** 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 ──────────────────────────────────────────── /** @@ -149,21 +305,16 @@ public int exportAll(boolean includeMeta) { * 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 = "folder".equalsIgnoreCase(entity.getType()); + boolean isFolder = entity.getType().isFolder(); if (isFolder) { 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"); @@ -182,13 +333,9 @@ 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 = "folder".equalsIgnoreCase(entity.getType()); + boolean isFolder = entity.getType().isFolder(); int written = 0; if (isFolder) { @@ -206,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 @@ -315,7 +462,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 +477,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"; } @@ -387,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/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/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 { 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..7c8c18b --- /dev/null +++ b/backend/src/test/java/io/github/trialiya/kb/service/DocumentExportSubtreeTest.java @@ -0,0 +1,121 @@ +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.Map; +import java.util.Optional; +import java.util.stream.Stream; +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"); + + 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( + 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,