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 ResponseEntityPOST /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
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 Same layout as {@link #exportAll(boolean)} but scoped to one node:
+ *
+ * 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 Layout understood (produced by the export):
+ *
+ * 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
+ *
+ *
+ *
+ *
+ *
+ *
+ * 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
+ * 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