feat(backend): stream folder-import progress as NDJSON

The import endpoint did all the work in one request and returned only an
aggregate summary, so the UI couldn't show progress or per-file status.

Refactor FileService.Import to take an optional progress callback and emit
a "start" event (with the total entry count), one "file" event per entry as
it finishes (index, filename, status, optional reason), and a final "done"
event with the tallies. The handler streams these as newline-delimited JSON
and flushes after each, deferring the response headers until the first event
so a validation error raised before any file is touched is still returned as
a normal JSON error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:15:40 +03:00
parent 5571dfa46d
commit 129cc59793
3 changed files with 145 additions and 44 deletions
+28 -5
View File
@@ -684,13 +684,36 @@ func (h *FileHandler) Import(c *gin.Context) {
// Body is optional; ignore bind errors.
_ = c.ShouldBindJSON(&body)
result, err := h.fileSvc.Import(c.Request.Context(), body.Path)
if err != nil {
respondError(c, err)
return
// Stream progress as newline-delimited JSON so the client can render a live
// progress bar and per-file status. Headers are deferred until the first
// event, so a validation error (bad path, import disabled) raised before any
// file is touched can still be returned as a normal JSON error response.
flusher, canFlush := c.Writer.(http.Flusher)
started := false
enc := json.NewEncoder(c.Writer)
emit := func(ev service.ImportEvent) {
if !started {
c.Header("Content-Type", "application/x-ndjson")
c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no") // don't let a proxy buffer the stream
c.Writer.WriteHeader(http.StatusOK)
started = true
}
_ = enc.Encode(ev) // appends a newline
if canFlush {
flusher.Flush()
}
}
respondJSON(c, http.StatusOK, result)
if _, err := h.fileSvc.Import(c.Request.Context(), body.Path, emit); err != nil {
if !started {
respondError(c, err)
return
}
// Headers already sent; surface the failure as a terminal stream event.
emit(service.ImportEvent{Type: "error", Reason: err.Error()})
}
}
// ---------------------------------------------------------------------------