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);
}
@@ -0,0 +1,153 @@
<app-toolbar />
<div class="page">
<mat-card>
<mat-card-header>
<mat-card-title>Account</mat-card-title>
</mat-card-header>
<mat-card-content>
@if (user(); as currentUser) {
<mat-list>
<mat-list-item>
<mat-icon matListItemIcon>person</mat-icon>
<div matListItemTitle>{{ currentUser.userName }}</div>
<div matListItemLine>Username</div>
</mat-list-item>
@if (currentUser.email) {
<mat-list-item>
<mat-icon matListItemIcon>mail</mat-icon>
<div matListItemTitle>{{ currentUser.email }}</div>
<div matListItemLine>Email</div>
</mat-list-item>
}
@if (currentUser.firstName || currentUser.lastName) {
<mat-list-item>
<mat-icon matListItemIcon>badge</mat-icon>
<div matListItemTitle>{{ currentUser.firstName }} {{ currentUser.lastName }}</div>
<div matListItemLine>Name</div>
</mat-list-item>
}
</mat-list>
}
</mat-card-content>
<mat-card-actions>
<a mat-button routerLink="/games">Back to library</a>
<button mat-button (click)="logout()">Sign out</button>
</mat-card-actions>
</mat-card>
<!-- Export -->
<mat-card>
<mat-card-header>
<mat-card-title>Export your library</mat-card-title>
<mat-card-subtitle>Download everything as a file you keep</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<p class="hint">
JSON round-trips exactly and is the right choice for a backup. CSV opens in a
spreadsheet. Box art images are not included in either — they live in the
server's upload volume.
</p>
<div class="button-row">
<button mat-flat-button color="primary" (click)="export('json')" [disabled]="exporting()">
<mat-icon>download</mat-icon>
Export JSON
</button>
<button mat-stroked-button (click)="export('csv')" [disabled]="exporting()">
<mat-icon>table_view</mat-icon>
Export CSV
</button>
</div>
</mat-card-content>
</mat-card>
<!-- Import -->
<mat-card>
@if (importing()) {
<mat-progress-bar mode="indeterminate" />
}
<mat-card-header>
<mat-card-title>Import</mat-card-title>
<mat-card-subtitle>Restore a backup, or bring a library in from elsewhere</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<p class="hint">
Accepts JSON or CSV. Rows are matched on title and system, so the same game on
two consoles stays two entries.
</p>
<input #fileInput type="file" accept=".json,.csv,application/json,text/csv" hidden
(change)="onFileSelected($event)" />
<div class="button-row">
<button mat-stroked-button type="button" (click)="fileInput.click()">
<mat-icon>upload_file</mat-icon>
Choose file
</button>
<span class="filename">{{ selectedFile()?.name ?? 'No file chosen' }}</span>
</div>
<div class="options">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Mode</mat-label>
<mat-select [ngModel]="mode()" (ngModelChange)="mode.set($event)">
<mat-option value="Merge">Merge — add and update, delete nothing</mat-option>
<mat-option value="Replace">Replace — wipe the library first</mat-option>
</mat-select>
</mat-form-field>
<mat-checkbox [ngModel]="dryRun()" (ngModelChange)="dryRun.set($event)">
Preview only
</mat-checkbox>
</div>
@if (mode() === 'Replace' && !dryRun()) {
<p class="warning" role="alert">
<mat-icon>warning</mat-icon>
Replace deletes every game in your library before importing. Export a backup first.
</p>
}
<button mat-flat-button color="primary" (click)="runImport()"
[disabled]="!selectedFile() || importing()">
{{ dryRun() ? 'Preview import' : 'Import' }}
</button>
@if (result(); as r) {
<div class="result">
<h3>
{{ r.dryRun ? 'Preview — nothing was changed' : 'Import complete' }}
</h3>
<ul>
<li><strong>{{ r.parsed }}</strong> rows read</li>
<li><strong>{{ r.created }}</strong> added</li>
<li><strong>{{ r.updated }}</strong> updated</li>
@if (r.deleted) {
<li><strong>{{ r.deleted }}</strong> deleted</li>
}
@if (r.skipped) {
<li><strong>{{ r.skipped }}</strong> skipped</li>
}
</ul>
@if (r.errors.length) {
<h4>Rows that could not be read</h4>
<ul class="errors">
@for (e of r.errors; track e.row) {
<li>Row {{ e.row }}: {{ e.reason }}</li>
}
</ul>
}
</div>
}
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,101 @@
.page {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 44rem;
margin-inline: auto;
padding: 1.5rem;
@media (max-width: 599px) {
padding: 1rem;
gap: 1rem;
}
}
mat-card {
overflow: hidden;
}
.hint {
margin: 0 0 1rem;
color: var(--mat-sys-on-surface-variant);
font-size: 0.875rem;
line-height: 1.5;
}
.button-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.filename {
font-size: 0.875rem;
color: var(--mat-sys-on-surface-variant);
overflow-wrap: anywhere;
}
.options {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.25rem;
margin: 1.25rem 0;
mat-form-field {
min-width: 18rem;
flex: 1 1 18rem;
}
}
.warning {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin: 0 0 1rem;
padding: 0.75rem;
border-radius: 0.5rem;
background: var(--mat-sys-error-container);
color: var(--mat-sys-on-error-container);
font-size: 0.875rem;
line-height: 1.45;
mat-icon {
flex: none;
font-size: 1.25rem;
width: 1.25rem;
height: 1.25rem;
}
}
.result {
margin-top: 1.5rem;
padding: 1rem;
border-radius: 0.5rem;
background: var(--mat-sys-surface-container-high);
h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 500;
}
h4 {
margin: 1rem 0 0.375rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-error);
}
ul {
margin: 0;
padding-left: 1.25rem;
font-size: 0.875rem;
line-height: 1.7;
}
.errors {
color: var(--mat-sys-error);
}
}
+115 -55
View File
@@ -1,78 +1,138 @@
import { Component, inject } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, RouterLink } from '@angular/router';
import { AuthService } from '../../core/auth.service';
import { ImportMode, ImportResult, LibraryService } from '../../core/library.service';
import { ConfirmDialog, ConfirmDialogData } from '../../shared/confirm-dialog';
import { Toolbar } from '../../shared/toolbar';
@Component({
selector: 'app-account',
imports: [Toolbar, RouterLink, MatCardModule, MatIconModule, MatButtonModule, MatListModule],
template: `
<app-toolbar />
<div class="page">
<mat-card>
<mat-card-header>
<mat-card-title>Account</mat-card-title>
</mat-card-header>
<mat-card-content>
@if (user(); as currentUser) {
<mat-list>
<mat-list-item>
<mat-icon matListItemIcon>person</mat-icon>
<div matListItemTitle>{{ currentUser.userName }}</div>
<div matListItemLine>Username</div>
</mat-list-item>
@if (currentUser.email) {
<mat-list-item>
<mat-icon matListItemIcon>mail</mat-icon>
<div matListItemTitle>{{ currentUser.email }}</div>
<div matListItemLine>Email</div>
</mat-list-item>
}
@if (currentUser.firstName || currentUser.lastName) {
<mat-list-item>
<mat-icon matListItemIcon>badge</mat-icon>
<div matListItemTitle>
{{ currentUser.firstName }} {{ currentUser.lastName }}
</div>
<div matListItemLine>Name</div>
</mat-list-item>
}
</mat-list>
}
</mat-card-content>
<mat-card-actions>
<a mat-button routerLink="/games">Back to library</a>
<button mat-button (click)="logout()">Sign out</button>
</mat-card-actions>
</mat-card>
</div>
`,
styles: `
.page {
max-width: 40rem;
margin-inline: auto;
padding: 1.5rem;
}
`,
imports: [
Toolbar,
RouterLink,
FormsModule,
MatCardModule,
MatIconModule,
MatButtonModule,
MatListModule,
MatDividerModule,
MatFormFieldModule,
MatSelectModule,
MatCheckboxModule,
MatProgressBarModule,
MatDialogModule,
],
templateUrl: './account.html',
styleUrl: './account.scss',
})
export class Account {
private readonly auth = inject(AuthService);
private readonly library = inject(LibraryService);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly dialog = inject(MatDialog);
readonly user = this.auth.user;
protected readonly exporting = signal(false);
protected readonly importing = signal(false);
protected readonly selectedFile = signal<File | null>(null);
protected readonly mode = signal<ImportMode>('Merge');
protected readonly dryRun = signal(true);
protected readonly result = signal<ImportResult | null>(null);
logout(): void {
this.auth.logout();
void this.router.navigate(['/login']);
}
protected export(format: 'json' | 'csv'): void {
this.exporting.set(true);
this.library.download(format).subscribe({
next: () => {
this.exporting.set(false);
this.snackBar.open(`Library exported as ${format.toUpperCase()}`, undefined, {
duration: 3000,
});
},
error: () => {
this.exporting.set(false);
this.snackBar.open('Export failed.', 'Dismiss', { duration: 5000 });
},
});
}
protected onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
this.selectedFile.set(input.files?.[0] ?? null);
this.result.set(null);
}
protected runImport(): void {
const file = this.selectedFile();
if (!file || this.importing()) {
return;
}
// Replace deletes the whole library first, so it never runs unconfirmed.
if (this.mode() === 'Replace' && !this.dryRun()) {
const data: ConfirmDialogData = {
title: 'Replace the entire library?',
message:
'Every game currently in your library will be deleted and rebuilt from this ' +
'file. Export a backup first if you have not already. This cannot be undone.',
confirmLabel: 'Replace everything',
destructive: true,
};
this.dialog
.open(ConfirmDialog, { data, width: '26rem' })
.afterClosed()
.subscribe((confirmed) => confirmed && this.send(file));
return;
}
this.send(file);
}
private send(file: File): void {
this.importing.set(true);
this.result.set(null);
this.library.import(file, this.mode(), this.dryRun()).subscribe({
next: (result) => {
this.importing.set(false);
this.result.set(result);
if (!result.dryRun) {
this.snackBar.open(
`Imported: ${result.created} added, ${result.updated} updated`,
undefined,
{ duration: 4000 },
);
}
},
error: (err: HttpErrorResponse) => {
this.importing.set(false);
this.snackBar.open(
err.error?.title ?? 'The file could not be imported.',
'Dismiss',
{ duration: 6000 },
);
},
});
}
}