Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -28,6 +38,7 @@ public class DocumentController {

private final DocumentService service;
private final DocumentExportService documentExportService;
private final DocumentImportService documentImportService;
private final SemanticSearchService semanticSearchService;

// ── Tree ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -80,6 +91,69 @@ public DocumentNode getDocument(@PathVariable long id) {
return node;
}

/**
* Downloads a node: a <b>document</b> is returned as a single {@code .md} file, a <b>folder</b>
* 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.
*
* <pre>GET /api/documents/{id}/download?meta=false</pre>
*/
@GetMapping("/{id}/download")
public ResponseEntity<StreamingResponseBody> 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<DocumentExportService.ExportEntry> entries =
documentExportService.streamSubtree(id, meta)) {
for (DocumentExportService.ExportEntry e :
(Iterable<DocumentExportService.ExportEntry>)
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<DocumentExportService.ExportEntry> 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<StreamingResponseBody> 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<DocumentHistoryShort> getHistory(@PathVariable long id) {
Expand Down Expand Up @@ -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).
*
* <pre>POST /api/documents/admin/import?parentId=42</pre>
*
* @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) {}
}
Original file line number Diff line number Diff line change
@@ -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<DocumentType, String> {
@Override
public String convert(DocumentType source) {
return source.getValue();
}
}

@ReadingConverter
public static class Reader implements Converter<String, DocumentType> {
@Override
public DocumentType convert(String source) {
return DocumentType.fromString(source);
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +26,14 @@ public interface DocumentRepository
"SELECT * FROM documents WHERE parent_id = :parentId ORDER BY position, type DESC, title")
List<DocumentEntity> findByParentId(@Param("parentId") Long parentId);

/**
* Streams the direct children of a folder, ordered by position, fetched lazily one level at a
* time. The caller <b>must</b> 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<DocumentEntity> findAllByParentIdOrderByPosition(Long parentId);

/** Paginated children of a given parent folder, sorted by position. */
Page<DocumentEntity> findByParentId(Long parentId, Pageable pageable);

Expand Down
Loading