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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user