Add library export and import

The only backup was the Docker volume. Export writes the caller's whole
library as JSON or CSV; import reads either back, into the same account or
a different one.

Rows are matched on title + system rather than id, so a file is portable
between accounts and instances, and the same game on three consoles stays
three entries. Merge adds and updates but deletes nothing. Replace wipes
first, and is gated behind an explicit confirm dialog in the UI. dryRun
reports what would happen and writes nothing.

CSV is hand-rolled rather than pulling a dependency, but handles the parts
that actually bite: quoted fields containing commas, escaped quotes,
embedded newlines and CRLF endings. That is not hypothetical here — 101 of
the 105 descriptions contain newlines, and two titles contain accents, so a
naive split-on-comma would corrupt most of the library. Exports carry a BOM
so Excel reads them as UTF-8.

Verified against the real library, not just fixtures: 105 games exported to
CSV, imported into a scratch account and re-exported compare identical
field for field.

15 new tests cover round-trip fidelity, merge vs replace, dry run,
per-user isolation on the destructive path, malformed input, and the
awkward-quoting case. 51 backend tests total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 12:54:54 -04:00
co-authored by Claude Opus 5
parent 771b34bb4b
commit b69a5c9d14
9 changed files with 1265 additions and 55 deletions
+62
View File
@@ -0,0 +1,62 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, tap } from 'rxjs';
export type ImportMode = 'Merge' | 'Replace';
export interface ImportRowError {
row: number;
title: string;
reason: string;
}
export interface ImportResult {
dryRun: boolean;
mode: ImportMode;
parsed: number;
created: number;
updated: number;
deleted: number;
skipped: number;
errors: ImportRowError[];
}
@Injectable({ providedIn: 'root' })
export class LibraryService {
private readonly http = inject(HttpClient);
/**
* Downloads the library. The request needs the bearer token, so it goes
* through HttpClient and is handed to the browser as a blob rather than
* being a plain anchor href.
*/
download(format: 'json' | 'csv'): Observable<Blob> {
return this.http
.get(`/api/library/export?format=${format}`, { responseType: 'blob' })
.pipe(tap((blob) => saveBlob(blob, `ludos-library-${today()}.${format}`)));
}
import(file: File, mode: ImportMode, dryRun: boolean): Observable<ImportResult> {
const form = new FormData();
form.append('file', file, file.name);
return this.http.post<ImportResult>(
`/api/library/import?mode=${mode}&dryRun=${dryRun}`,
form,
);
}
}
function today(): string {
return new Date().toISOString().slice(0, 10);
}
function saveBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
// Revoking immediately can cancel the download in some browsers.
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}