6 Commits

Author SHA1 Message Date
H1K0 ca3bca59e7 docs(project): document the import progress NDJSON stream
deploy / deploy (push) Successful in 1m3s
Describe /files/import's application/x-ndjson response and the start/file/
done/error event schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:15:50 +03:00
H1K0 c8bd8512ce feat(frontend): show import progress bar and per-file status
Consume the import endpoint's NDJSON progress stream via a new postStream
client helper (reuses the bearer token and 401 refresh, but keeps the body
as a stream). The Settings import card now renders a live progress bar
(processed/total) and a scrolling per-file list where each entry shows its
status — imported, skipped or error — with the failure reason inline and the
newest row kept in view. A final summary replaces the old single-shot result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:15:50 +03:00
H1K0 129cc59793 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>
2026-06-11 21:15:40 +03:00
H1K0 5571dfa46d chore(project): install exiftool in the runtime image
The backend now shells out to exiftool for metadata extraction, so it must
be present alongside ffmpeg in the Alpine runtime stage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:55:33 +03:00
H1K0 52c62b5c8d feat(backend): extract rich numeric metadata via exiftool
The previous goexif reader only understood EXIF in JPEG/TIFF, so videos,
PNGs and any image without an EXIF block were stored with no metadata at
all. Shell out to exiftool instead (the same tool the prior version used),
which covers images, video and audio in one pass.

Run it with `-n` so every tag comes back as a raw numeric/machine value
(FileSize in bytes, Duration in seconds, AvgBitrate as a number) rather
than human-readable strings — the metadata is the basis for analytics, not
decoration. Temp-file artifacts (SourceFile/Directory/permissions/inode
dates) are stripped and FileName is set to the original.

content_datetime now resolves from the first real capture date in the
metadata (DateTimeOriginal, then the video CreateDate atoms), still falling
back to the import mtime. When exiftool isn't on PATH the pure-Go EXIF
reader remains as a graceful fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:55:23 +03:00
H1K0 0b0f797fae chore(project): name the app container tfm
Give the app service an explicit container_name so it shows up as `tfm`
instead of the generated `tanabata-app-1`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:34:30 +03:00
10 changed files with 742 additions and 110 deletions
+6 -4
View File
@@ -49,13 +49,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server
# -----------------------------------------------------------------------------
# Stage 3 — minimal runtime
#
# Alpine (not distroless/scratch) because video thumbnailing invokes ffmpeg as
# an external process; it must be present on the runtime image.
# Alpine (not distroless/scratch) because video thumbnailing invokes ffmpeg and
# metadata extraction invokes exiftool as external processes; both must be
# present on the runtime image.
# -----------------------------------------------------------------------------
FROM alpine:3.21 AS runtime
# ffmpeg: video frame extraction. ca-certificates/tzdata: TLS + time zones.
RUN apk add --no-cache ffmpeg ca-certificates tzdata
# ffmpeg: video frame extraction. exiftool: rich image/video/audio metadata.
# ca-certificates/tzdata: TLS + time zones.
RUN apk add --no-cache ffmpeg exiftool ca-certificates tzdata
# Run as an unprivileged user.
RUN addgroup -S -g 42776 tanabata && adduser -S -G tanabata -u 42776 tanabata
+27 -4
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 {
// 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()
}
}
if _, err := h.fileSvc.Import(c.Request.Context(), body.Path, emit); err != nil {
if !started {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, result)
// Headers already sent; surface the failure as a terminal stream event.
emit(service.ImportEvent{Type: "error", Reason: err.Error()})
}
}
// ---------------------------------------------------------------------------
+53 -11
View File
@@ -902,6 +902,35 @@ func TestNonOwnerAccessControl(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
}
// importEvent mirrors service.ImportEvent for decoding the streamed progress.
type importEvent struct {
Type string `json:"type"`
Total int `json:"total"`
Index int `json:"index"`
Filename string `json:"filename"`
Status string `json:"status"`
Reason string `json:"reason"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Errors int `json:"errors"`
}
// parseImportEvents splits an NDJSON import response into its events.
func parseImportEvents(t *testing.T, resp *testResponse) []importEvent {
t.Helper()
var events []importEvent
for _, line := range bytes.Split(resp.bodyBytes, []byte("\n")) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var ev importEvent
require.NoError(t, json.Unmarshal(line, &ev), "event line: %s", line)
events = append(events, ev)
}
return events
}
// TestImportFromFolder verifies the admin server-side import: supported files
// are ingested, subdirectories are skipped, the source is removed from the
// import folder afterwards, and a file without EXIF takes the source's mtime as
@@ -923,18 +952,31 @@ func TestImportFromFolder(t *testing.T) {
resp := h.doJSON("POST", "/files/import", map[string]any{}, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var res struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Errors []struct {
Filename string `json:"filename"`
Reason string `json:"reason"`
} `json:"errors"`
// The import streams newline-delimited JSON progress events.
events := parseImportEvents(t, resp)
var start, done *importEvent
files := map[string]importEvent{}
for i := range events {
switch events[i].Type {
case "start":
start = &events[i]
case "done":
done = &events[i]
case "file":
files[events[i].Filename] = events[i]
}
resp.decode(t, &res)
assert.Equal(t, 1, res.Imported, resp.String())
assert.Equal(t, 1, res.Skipped, resp.String()) // the nested directory
assert.Empty(t, res.Errors, resp.String())
}
require.NotNil(t, start, resp.String())
require.NotNil(t, done, resp.String())
assert.Equal(t, 2, start.Total, "start total counts every entry")
assert.Equal(t, 1, done.Imported, resp.String())
assert.Equal(t, 1, done.Skipped, resp.String()) // the nested directory
assert.Equal(t, 0, done.Errors, resp.String())
// Per-file events: the JPEG imported, the subdirectory was skipped.
assert.Equal(t, "imported", files["scan.jpg"].Status, resp.String())
assert.Equal(t, "skipped", files["nested"].Status, resp.String())
// Source file is gone from the import folder after a successful import.
_, statErr := os.Stat(srcPath)
+78 -53
View File
@@ -13,7 +13,6 @@ import (
"github.com/gabriel-vasile/mimetype"
"github.com/google/uuid"
"github.com/rwcarlsen/goexif/exif"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
@@ -71,6 +70,24 @@ type ImportResult struct {
Errors []ImportFileError `json:"errors"`
}
// ImportEvent is one progress message streamed during an import, letting the UI
// show a live progress bar and a per-file status list. Type is the discriminator:
//
// "start" — total is the number of entries about to be processed.
// "file" — one entry finished: index (1-based), filename, status, optional reason.
// "done" — final tallies (imported/skipped/errors).
type ImportEvent struct {
Type string `json:"type"`
Total int `json:"total,omitempty"`
Index int `json:"index,omitempty"`
Filename string `json:"filename,omitempty"`
Status string `json:"status,omitempty"` // "imported" | "skipped" | "error"
Reason string `json:"reason,omitempty"`
Imported int `json:"imported,omitempty"`
Skipped int `json:"skipped,omitempty"`
Errors int `json:"errors,omitempty"`
}
// FileService handles business logic for file records.
type FileService struct {
files port.FileRepo
@@ -112,7 +129,7 @@ func NewFileService(
// Upload validates the MIME type, saves the file to storage, creates the DB
// record, and applies any initial tags — all within a single transaction.
// If ContentDatetime is nil and EXIF DateTimeOriginal is present, it is used.
// If ContentDatetime is nil and the metadata carries a capture date, it is used.
func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File, error) {
userID, _, _ := domain.UserFromContext(ctx)
@@ -129,10 +146,14 @@ func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File,
}
data := buf.Bytes()
// Extract EXIF metadata (best-effort; non-image files will error silently).
exifData, exifDatetime := extractEXIFWithDatetime(data)
// Extract rich metadata (best-effort; covers images, video and audio).
var origName string
if p.OriginalName != nil {
origName = *p.OriginalName
}
exifData, exifDatetime := extractMetadata(data, origName, p.ContentDatetimeFallback)
// Resolve content datetime: explicit > EXIF > fallback (e.g. import mtime) > zero.
// Resolve content datetime: explicit > metadata date > fallback (e.g. import mtime) > zero.
var contentDatetime time.Time
if p.ContentDatetime != nil {
contentDatetime = *p.ContentDatetime
@@ -405,7 +426,11 @@ func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams)
return nil, fmt.Errorf("FileService.Replace: read body: %w", err)
}
data := buf.Bytes()
exifData, _ := extractEXIFWithDatetime(data)
var origName string
if p.OriginalName != nil {
origName = *p.OriginalName
}
exifData, _ := extractMetadata(data, origName, nil)
if _, err := s.storage.Save(ctx, id, bytes.NewReader(data)); err != nil {
return nil, fmt.Errorf("FileService.Replace: save to storage: %w", err)
@@ -525,7 +550,12 @@ func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error
// Import scans a server-side directory and uploads all supported files.
// If path is empty, the configured default import path is used.
func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, error) {
//
// onProgress, when non-nil, receives a "start" event, one "file" event per
// directory entry as it is processed, and a final "done" event — letting a
// caller stream live progress. It is always called from this goroutine (never
// concurrently). The aggregate result is also returned for non-streaming callers.
func (s *FileService) Import(ctx context.Context, path string, onProgress func(ImportEvent)) (*ImportResult, error) {
if s.importPath == "" {
return nil, domain.ErrValidation
}
@@ -546,47 +576,58 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e
return nil, fmt.Errorf("FileService.Import: read dir %q: %w", dir, err)
}
result := &ImportResult{Errors: []ImportFileError{}}
emit := func(ev ImportEvent) {
if onProgress != nil {
onProgress(ev)
}
}
result := &ImportResult{Errors: []ImportFileError{}}
total := len(entries)
emit(ImportEvent{Type: "start", Total: total})
for i, entry := range entries {
name := entry.Name()
file := func(status, reason string) {
emit(ImportEvent{
Type: "file", Index: i + 1, Total: total,
Filename: name, Status: status, Reason: reason,
})
}
fail := func(reason string) {
result.Errors = append(result.Errors, ImportFileError{Filename: name, Reason: reason})
file("error", reason)
}
for _, entry := range entries {
if entry.IsDir() {
result.Skipped++
file("skipped", "directory")
continue
}
fullPath := filepath.Join(dir, entry.Name())
fullPath := filepath.Join(dir, name)
mt, err := mimetype.DetectFile(fullPath)
if err != nil {
result.Errors = append(result.Errors, ImportFileError{
Filename: entry.Name(),
Reason: fmt.Sprintf("MIME detection failed: %s", err),
})
fail(fmt.Sprintf("MIME detection failed: %s", err))
continue
}
mimeStr := mt.String()
// Strip parameters (e.g. "text/plain; charset=utf-8" → "text/plain").
if idx := len(mimeStr); idx > 0 {
for i, c := range mimeStr {
if c == ';' {
mimeStr = mimeStr[:i]
break
}
}
if j := strings.IndexByte(mimeStr, ';'); j >= 0 {
mimeStr = mimeStr[:j]
}
if _, err := s.mimes.GetByName(ctx, mimeStr); err != nil {
result.Skipped++
file("skipped", "unsupported type: "+mimeStr)
continue
}
f, err := os.Open(fullPath)
if err != nil {
result.Errors = append(result.Errors, ImportFileError{
Filename: entry.Name(),
Reason: fmt.Sprintf("open failed: %s", err),
})
fail(fmt.Sprintf("open failed: %s", err))
continue
}
@@ -599,7 +640,6 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e
mtime = &t
}
name := entry.Name()
_, uploadErr := s.Upload(ctx, UploadParams{
Reader: f,
MIMEType: mimeStr,
@@ -609,10 +649,7 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e
f.Close()
if uploadErr != nil {
result.Errors = append(result.Errors, ImportFileError{
Filename: entry.Name(),
Reason: uploadErr.Error(),
})
fail(uploadErr.Error())
continue
}
result.Imported++
@@ -621,12 +658,18 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e
// doesn't duplicate. The file is already safely copied into storage; a
// removal failure is reported but doesn't undo the import.
if rmErr := os.Remove(fullPath); rmErr != nil {
result.Errors = append(result.Errors, ImportFileError{
Filename: entry.Name(),
Reason: fmt.Sprintf("imported, but failed to remove source: %s", rmErr),
reason := fmt.Sprintf("imported, but failed to remove source: %s", rmErr)
result.Errors = append(result.Errors, ImportFileError{Filename: name, Reason: reason})
file("imported", reason) // imported, with a warning
continue
}
file("imported", "")
}
emit(ImportEvent{
Type: "done", Total: total,
Imported: result.Imported, Skipped: result.Skipped, Errors: len(result.Errors),
})
}
}
return result, nil
}
@@ -656,21 +699,3 @@ func confineToBase(base, target string) (string, error) {
}
return absTarget, nil
}
// extractEXIFWithDatetime parses EXIF from raw bytes, returning both the JSON
// representation and the DateTimeOriginal (if present). Both may be nil.
func extractEXIFWithDatetime(data []byte) (json.RawMessage, *time.Time) {
x, err := exif.Decode(bytes.NewReader(data))
if err != nil {
return nil, nil
}
b, err := x.MarshalJSON()
if err != nil {
return nil, nil
}
var dt *time.Time
if t, err := x.DateTime(); err == nil {
dt = &t
}
return json.RawMessage(b), dt
}
+183
View File
@@ -0,0 +1,183 @@
package service
import (
"bytes"
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/rwcarlsen/goexif/exif"
)
// exiftoolPath is resolved once at startup. When exiftool isn't installed we
// skip the subprocess and fall back to the pure-Go EXIF reader, so the server
// still runs (with thinner metadata) on hosts without it.
var exiftoolPath, _ = exec.LookPath("exiftool")
// metadataTimeout bounds a single exiftool invocation so a pathological file
// can't wedge an upload.
const metadataTimeout = 30 * time.Second
// metaTempFileKeys are exiftool fields that describe the temporary file we feed
// it rather than the content. Dropping them avoids leaking internal paths and
// recording the temp file's permissions/inode timestamps.
var metaTempFileKeys = []string{
"SourceFile",
"Directory",
"FileAccessDate",
"FileInodeChangeDate",
"FilePermissions",
}
// metaDateKeys are the metadata fields, in priority order, holding the moment
// the content was actually captured/created — photos first, then video atoms.
var metaDateKeys = []string{
"DateTimeOriginal",
"CreateDate",
"MediaCreateDate",
"TrackCreateDate",
"ModifyDate",
}
// extractMetadata returns rich metadata as JSON plus the best content datetime
// it can find. It prefers exiftool, which understands video, audio and every
// image format and emits machine-readable numeric values (the basis for later
// analytics); when exiftool is unavailable it falls back to the pure-Go EXIF
// reader, which only handles JPEG/TIFF.
//
// originalName supplies the extension exiftool uses for format detection and the
// FileName reported back. mtime, when set (e.g. a server-side import), is stamped
// onto the temp file so FileModifyDate reflects the real source.
func extractMetadata(data []byte, originalName string, mtime *time.Time) (json.RawMessage, *time.Time) {
if exiftoolPath != "" {
if raw, dt, ok := exiftoolExtract(data, originalName, mtime); ok {
return raw, dt
}
}
return extractEXIFWithDatetime(data)
}
// exiftoolExtract stages the bytes in a temp file and shells out to exiftool.
// It returns ok=false on any failure so the caller can fall back.
func exiftoolExtract(data []byte, originalName string, mtime *time.Time) (json.RawMessage, *time.Time, bool) {
// exiftool reads a real file far more reliably than a pipe (it seeks freely,
// e.g. to a trailing MP4 moov atom), so stage the bytes in a temp file whose
// extension matches the original for accurate format detection.
tmp, err := os.CreateTemp("", "tfm-meta-*"+filepath.Ext(originalName))
if err != nil {
return nil, nil, false
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return nil, nil, false
}
if err := tmp.Close(); err != nil {
return nil, nil, false
}
if mtime != nil {
_ = os.Chtimes(tmpName, *mtime, *mtime)
}
ctx, cancel := context.WithTimeout(context.Background(), metadataTimeout)
defer cancel()
// -n forces raw numeric/machine values for every tag (no "3.53 Mbps" strings)
// so the metadata is analytics-ready. -all extracts every tag. largefilesupport
// handles multi-GB videos. Output is a one-element JSON array.
out, err := exec.CommandContext(ctx, exiftoolPath,
"-n", "-all", "-json", "-api", "largefilesupport=1", tmpName,
).Output()
if err != nil {
return nil, nil, false
}
var arr []map[string]json.RawMessage
if err := json.Unmarshal(out, &arr); err != nil || len(arr) == 0 {
return nil, nil, false
}
m := arr[0]
dt := pickMetaDatetime(m)
// Strip temp-file artifacts and substitute the real name.
for _, k := range metaTempFileKeys {
delete(m, k)
}
if mtime == nil {
// Without a real source mtime this is just the temp file's write time.
delete(m, "FileModifyDate")
}
if originalName != "" {
if nb, err := json.Marshal(originalName); err == nil {
m["FileName"] = nb
}
} else {
delete(m, "FileName")
}
raw, err := json.Marshal(m)
if err != nil {
return nil, nil, false
}
return raw, dt, true
}
// pickMetaDatetime returns the first parseable content date among metaDateKeys.
func pickMetaDatetime(m map[string]json.RawMessage) *time.Time {
for _, key := range metaDateKeys {
raw, ok := m[key]
if !ok {
continue
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
continue
}
if t, ok := parseExifDate(s); ok {
return &t
}
}
return nil
}
// parseExifDate parses exiftool's "YYYY:MM:DD HH:MM:SS" timestamps, with or
// without a trailing timezone offset. Zeroed placeholders ("0000:00:00 ...")
// fail to parse and are skipped by the caller.
func parseExifDate(s string) (time.Time, bool) {
s = strings.TrimSpace(s)
for _, layout := range []string{
"2006:01:02 15:04:05-07:00",
"2006:01:02 15:04:05Z07:00",
"2006:01:02 15:04:05",
} {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
}
// extractEXIFWithDatetime is the pure-Go fallback used when exiftool is absent.
// It parses EXIF from raw bytes (JPEG/TIFF only), returning both the JSON
// representation and the DateTimeOriginal (if present). Both may be nil.
func extractEXIFWithDatetime(data []byte) (json.RawMessage, *time.Time) {
x, err := exif.Decode(bytes.NewReader(data))
if err != nil {
return nil, nil
}
b, err := x.MarshalJSON()
if err != nil {
return nil, nil
}
var dt *time.Time
if t, err := x.DateTime(); err == nil {
dt = &t
}
return json.RawMessage(b), dt
}
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"bytes"
"encoding/json"
"image"
"image/color"
"image/png"
"testing"
"time"
)
func TestParseExifDate(t *testing.T) {
cases := []struct {
in string
ok bool
want time.Time
}{
{"2026:03:24 16:57:58", true, time.Date(2026, 3, 24, 16, 57, 58, 0, time.UTC)},
{"2026:05:08 23:07:55+03:00", true, time.Date(2026, 5, 8, 23, 7, 55, 0, time.FixedZone("", 3*3600))},
{" 2026:01:02 03:04:05 ", true, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)},
{"0000:00:00 00:00:00", false, time.Time{}},
{"not a date", false, time.Time{}},
{"", false, time.Time{}},
}
for _, c := range cases {
got, ok := parseExifDate(c.in)
if ok != c.ok {
t.Errorf("parseExifDate(%q) ok=%v, want %v", c.in, ok, c.ok)
continue
}
if ok && !got.Equal(c.want) {
t.Errorf("parseExifDate(%q) = %v, want %v", c.in, got, c.want)
}
}
}
// tinyPNG returns a valid 2x3 PNG with no embedded EXIF/date.
func tinyPNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 2, 3))
img.Set(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
func TestExtractMetadataExiftool(t *testing.T) {
if exiftoolPath == "" {
t.Skip("exiftool not installed; metadata extraction falls back to goexif")
}
raw, dt := extractMetadata(tinyPNG(t), "snapshot.png", nil)
if raw == nil {
t.Fatal("expected non-nil metadata JSON")
}
if dt != nil {
t.Errorf("a PNG without a capture date should yield no content datetime, got %v", dt)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("metadata is not valid JSON: %v", err)
}
// exiftool understood the format (goexif never would for PNG).
if v := jsonString(t, m, "FileType"); v != "PNG" {
t.Errorf("FileType = %q, want PNG", v)
}
// Dimensions are numeric, not human-readable strings.
for _, key := range []string{"ImageWidth", "ImageHeight"} {
raw, ok := m[key]
if !ok {
t.Errorf("missing %s", key)
continue
}
var n float64
if err := json.Unmarshal(raw, &n); err != nil {
t.Errorf("%s is not numeric: %s", key, raw)
}
}
// FileName is the original, not the temp file; temp-file artifacts are gone.
if v := jsonString(t, m, "FileName"); v != "snapshot.png" {
t.Errorf("FileName = %q, want snapshot.png", v)
}
for _, leaked := range []string{"SourceFile", "Directory", "FilePermissions", "FileModifyDate"} {
if _, ok := m[leaked]; ok {
t.Errorf("temp-file field %q should have been stripped", leaked)
}
}
}
func jsonString(t *testing.T, m map[string]json.RawMessage, key string) string {
t.Helper()
raw, ok := m[key]
if !ok {
t.Errorf("missing key %q", key)
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
t.Errorf("key %q is not a string: %s", key, raw)
return ""
}
return s
}
+1
View File
@@ -23,6 +23,7 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: tfm
restart: unless-stopped
# All application config (secrets, DATABASE_URL, tunables) comes from .env.
+60
View File
@@ -166,6 +166,66 @@ export function uploadWithProgress<T>(
});
}
/** POST that consumes a streamed newline-delimited JSON (NDJSON) response,
* invoking onEvent once per parsed line. Used by the server-side import so the
* UI can render live per-file progress. Reuses the bearer token and a single
* 401 refresh+retry, but (unlike request()) keeps the body as a stream. */
export async function postStream(
path: string,
body: unknown,
onEvent: (ev: Record<string, unknown>) => void
): Promise<void> {
const init: RequestInit = { method: 'POST', body: JSON.stringify(body) };
const send = () =>
fetch(BASE + path, { ...init, headers: buildHeaders(init, get(authStore).accessToken) });
let res = await send();
if (res.status === 401) {
if (!refreshPromise) {
refreshPromise = refreshTokens().finally(() => {
refreshPromise = null;
});
}
try {
await refreshPromise;
} catch {
throw new ApiError(401, 'unauthorized', 'Session expired');
}
res = await send();
}
if (!res.ok || !res.body) {
let b: { code?: string; message?: string } = {};
try {
b = await res.json();
} catch {
// ignore parse failure
}
throw new ApiError(res.status, b.code ?? 'error', b.message ?? res.statusText);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
const flushLine = (line: string) => {
const trimmed = line.trim();
if (trimmed) onEvent(JSON.parse(trimmed));
};
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buf.indexOf('\n')) >= 0) {
flushLine(buf.slice(0, nl));
buf = buf.slice(nl + 1);
}
}
buf += decoder.decode();
flushLine(buf);
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
+185 -26
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { api, ApiError } from '$lib/api/client';
import { api, ApiError, postStream } from '$lib/api/client';
import { authStore } from '$lib/stores/auth';
import { themeStore, toggleTheme } from '$lib/stores/theme';
import { appSettings } from '$lib/stores/appSettings';
@@ -100,23 +100,66 @@
}
// ---- Server-side import (admin only) ----
interface ImportResult {
imported: number;
skipped: number;
errors: { filename: string; reason: string }[];
// The backend streams NDJSON progress events (start → file… → done); we render
// a live progress bar and a per-file status list as they arrive.
type ImportStatus = 'imported' | 'skipped' | 'error';
interface ImportItem {
filename: string;
status: ImportStatus;
reason?: string;
}
let importPath = $state('');
let importing = $state(false);
let importError = $state('');
let importResult = $state<ImportResult | null>(null);
let importTotal = $state(0);
let importProcessed = $state(0);
let importDone = $state(false);
let importItems = $state<ImportItem[]>([]);
let importSummary = $state<{ imported: number; skipped: number; errors: number } | null>(null);
let importListEl = $state<HTMLUListElement | null>(null);
// Keep the newest row in view as files stream in.
$effect(() => {
importItems.length;
if (importListEl) importListEl.scrollTop = importListEl.scrollHeight;
});
async function runImport() {
importing = true;
importError = '';
importResult = null;
importTotal = 0;
importProcessed = 0;
importDone = false;
importItems = [];
importSummary = null;
try {
const sub = importPath.trim();
importResult = await api.post<ImportResult>('/files/import', sub ? { path: sub } : {});
await postStream('/files/import', sub ? { path: sub } : {}, (ev) => {
switch (ev.type) {
case 'start':
importTotal = (ev.total as number) ?? 0;
break;
case 'file':
importProcessed = (ev.index as number) ?? importProcessed + 1;
importItems.push({
filename: (ev.filename as string) ?? '',
status: (ev.status as ImportStatus) ?? 'skipped',
reason: (ev.reason as string) || undefined
});
break;
case 'done':
importDone = true;
importSummary = {
imported: (ev.imported as number) ?? 0,
skipped: (ev.skipped as number) ?? 0,
errors: (ev.errors as number) ?? 0
};
break;
case 'error':
importError = (ev.reason as string) ?? 'Import failed';
break;
}
});
} catch (e) {
importError = e instanceof ApiError ? e.message : 'Import failed';
} finally {
@@ -344,17 +387,50 @@
{#if importError}
<p class="msg error" role="alert">{importError}</p>
{/if}
{#if importResult}
<p class="msg success" role="status">
Imported {importResult.imported}, skipped {importResult.skipped}{importResult.errors
.length
? `, ${importResult.errors.length} error${importResult.errors.length === 1 ? '' : 's'}`
{#if importing || importDone || importItems.length > 0}
<div class="import-progress">
<div
class="progress-track"
role="progressbar"
aria-valuemin={0}
aria-valuemax={importTotal}
aria-valuenow={importProcessed}
>
<div
class="progress-fill"
class:done={importDone}
style:width={importTotal > 0
? `${Math.round((importProcessed / importTotal) * 100)}%`
: importDone
? '100%'
: '0%'}
></div>
</div>
<p class="progress-label">
{#if importDone && importSummary}
Done — imported {importSummary.imported}, skipped {importSummary.skipped}{importSummary.errors
? `, ${importSummary.errors} error${importSummary.errors === 1 ? '' : 's'}`
: ''}.
{:else if importTotal > 0}
Importing… {importProcessed}/{importTotal}
{:else}
Scanning…
{/if}
</p>
{#if importResult.errors.length}
<ul class="import-errors">
{#each importResult.errors as err}
<li><span class="err-file">{err.filename}</span>{err.reason}</li>
</div>
{#if importItems.length > 0}
<ul class="import-list" bind:this={importListEl}>
{#each importItems as item}
<li class="import-item {item.status}">
<span class="status-dot" aria-hidden="true"></span>
<span class="item-file" title={item.filename}>{item.filename}</span>
<span class="item-status">{item.status}</span>
{#if item.reason}
<span class="item-reason">{item.reason}</span>
{/if}
</li>
{/each}
</ul>
{/if}
@@ -612,24 +688,107 @@
}
/* ---- Server import ---- */
.import-errors {
list-style: none;
margin: 0;
padding: 8px 10px;
.import-progress {
display: flex;
flex-direction: column;
gap: 4px;
border-radius: 7px;
background-color: color-mix(in srgb, var(--color-danger) 10%, transparent);
gap: 6px;
}
.progress-track {
height: 8px;
border-radius: 4px;
background-color: color-mix(in srgb, var(--color-accent) 15%, var(--color-bg-primary));
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
background-color: var(--color-accent);
transition:
width 0.2s ease,
background-color 0.2s ease;
}
.progress-fill.done {
background-color: #7ecba1;
}
.progress-label {
font-size: 0.8rem;
color: var(--color-text-muted);
max-height: 180px;
margin: 0;
}
.import-list {
list-style: none;
margin: 0;
padding: 6px 0;
display: flex;
flex-direction: column;
gap: 1px;
max-height: 220px;
overflow-y: auto;
}
.import-errors .err-file {
.import-item {
display: flex;
align-items: baseline;
gap: 8px;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.8rem;
}
.status-dot {
flex-shrink: 0;
width: 7px;
height: 7px;
border-radius: 50%;
align-self: center;
background-color: var(--color-text-muted);
}
.import-item.imported .status-dot {
background-color: #7ecba1;
}
.import-item.error .status-dot {
background-color: var(--color-danger);
}
.item-file {
color: var(--color-text-primary);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 45%;
}
.import-item.skipped .item-file {
color: var(--color-text-muted);
font-weight: 500;
}
.item-status {
flex-shrink: 0;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
.import-item.error .item-status {
color: var(--color-danger);
}
.item-reason {
flex: 1;
min-width: 0;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- Sessions ---- */
+36 -9
View File
@@ -654,6 +654,13 @@ paths:
not recursed. A successfully imported file is removed from the import
folder. For files without an EXIF date, the source file's modified time
is used as content_datetime.
Progress is streamed as newline-delimited JSON (`application/x-ndjson`):
a single `start` event, one `file` event per directory entry as it is
processed, and a final `done` event with the tallies. A validation error
raised before any file is touched (e.g. import disabled, bad path) is
instead returned as a normal JSON error with a 4xx/5xx status.
requestBody:
required: true
content:
@@ -666,25 +673,45 @@ paths:
description: Server directory path (uses user's configured import path if omitted)
responses:
'200':
description: Import result
description: >
A stream of newline-delimited JSON import progress events. The
schema below describes a single event line; the response body is one
such object per line.
content:
application/json:
application/x-ndjson:
schema:
type: object
required: [type]
properties:
imported:
type:
type: string
enum: [start, file, done, error]
description: Event discriminator.
total:
type: integer
skipped:
description: Entries to process (start) or processed (done).
index:
type: integer
errors:
type: array
items:
type: object
properties:
description: 1-based position of this entry (file events).
filename:
type: string
description: Entry name (file events).
status:
type: string
enum: [imported, skipped, error]
description: Outcome for this entry (file events).
reason:
type: string
description: Detail for a skipped/error/warning entry.
imported:
type: integer
description: Total imported (done event).
skipped:
type: integer
description: Total skipped (done event).
errors:
type: integer
description: Total errors (done event).
# -------------------------------------------------------------------------
# Tags