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
+18
View File
@@ -169,6 +169,24 @@ Parsing an infobox is messier than it looks, and the guards matter:
Four games have no usable article: a typo'd title (`Brett Hull Hocky 95`), Four games have no usable article: a typo'd title (`Brett Hull Hocky 95`),
`Dragon Ball Z Budokai`, and two niche releases. `Dragon Ball Z Budokai`, and two niche releases.
### Export and import
Until this existed, the only backup was the Docker volume.
- `GET /api/library/export?format=json|csv`
- `POST /api/library/import?mode=Merge|Replace&dryRun=true` (multipart `file`)
Both are in the UI on the account page. JSON round-trips exactly and is the
right choice for a backup; CSV opens in a spreadsheet and is written with a BOM
so Excel does not mangle `Pokémon`. **Box art images are not bundled** — they
live in the upload volume, and a library imported into a fresh instance will
reference art that is not there until the fetcher runs again.
Rows are matched on **title + system**, so the same game on three consoles stays
three entries. `Merge` adds and updates but never deletes; `Replace` wipes the
library first and is confirmed twice in the UI. `dryRun` reports exactly what
would happen and writes nothing.
### Database changes ### Database changes
```bash ```bash
@@ -0,0 +1,60 @@
namespace LudosData.Api.Contracts;
/// <summary>
/// One game as it appears in an export file.
///
/// Deliberately has no id and no owner: an export is a portable description of a
/// library, not a database dump. On import, rows are matched by title and
/// system, so a file can move between accounts or instances.
/// </summary>
public record ExportGame
{
public string Title { get; init; } = string.Empty;
public string? System { get; init; }
public string? Genre { get; init; }
public string? Year { get; init; }
public string? Developer { get; init; }
public string? Publisher { get; init; }
public string? Description { get; init; }
/// <summary>
/// Stored filename of the box art. The image itself is not bundled, so a
/// file imported into a fresh instance will reference art that is not there
/// until the fetcher is run again.
/// </summary>
public string? Art { get; init; }
public bool Own { get; init; }
public bool Dumped { get; init; }
public bool Played { get; init; }
public bool Finished { get; init; }
}
/// <summary>Envelope written by the JSON exporter.</summary>
public record LibraryExport(
string Format,
int Version,
DateTimeOffset ExportedAt,
int Count,
IReadOnlyList<ExportGame> Games);
public enum ImportMode
{
/// <summary>Update rows that match on title + system, insert the rest. Nothing is deleted.</summary>
Merge = 0,
/// <summary>Delete the caller's entire library first, then insert the file.</summary>
Replace = 1,
}
public record ImportRowError(int Row, string Title, string Reason);
public record ImportResult(
bool DryRun,
ImportMode Mode,
int Parsed,
int Created,
int Updated,
int Deleted,
int Skipped,
IReadOnlyList<ImportRowError> Errors);
@@ -0,0 +1,311 @@
using System.Text;
using System.Text.Json;
using LudosData.Api.Auth;
using LudosData.Api.Contracts;
using LudosData.Api.Data;
using LudosData.Api.Domain;
using LudosData.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LudosData.Api.Controllers;
/// <summary>
/// Export and import of the caller's whole library.
///
/// Until now the only backup was the Docker volume. This makes a library
/// portable: JSON round-trips exactly, CSV opens in a spreadsheet.
/// </summary>
[ApiController]
[Route("api/library")]
[Authorize]
public class LibraryController(
LudosDbContext db,
ILogger<LibraryController> logger) : ControllerBase
{
private const int ExportVersion = 1;
private static readonly string[] CsvHeaders =
[
"title", "system", "genre", "year", "developer", "publisher",
"description", "art", "own", "dumped", "played", "finished",
];
private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web) { WriteIndented = true };
// ---- export ----------------------------------------------------------
[HttpGet("export")]
public async Task<IActionResult> Export([FromQuery] string format = "json", CancellationToken ct = default)
{
var ownerId = User.GetUserId();
var games = await db.Games.AsNoTracking()
.Where(g => g.OwnerId == ownerId)
.OrderBy(g => g.Title)
.ToListAsync(ct);
var rows = games.Select(ToExport).ToList();
var stamp = DateTime.UtcNow.ToString("yyyy-MM-dd");
if (string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase))
{
var csv = Csv.Write(CsvHeaders, rows.Select(g => new List<string?>
{
g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher,
g.Description, g.Art,
g.Own.ToString(), g.Dumped.ToString(), g.Played.ToString(), g.Finished.ToString(),
}));
// A BOM keeps Excel from mangling non-ASCII titles such as Pokémon.
var bytes = new byte[] { 0xEF, 0xBB, 0xBF }.Concat(Encoding.UTF8.GetBytes(csv)).ToArray();
return File(bytes, "text/csv; charset=utf-8", $"ludos-library-{stamp}.csv");
}
if (!string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
{
return BadRequest(new ProblemDetails { Title = "Format must be 'json' or 'csv'." });
}
var payload = new LibraryExport("ludosdata.library", ExportVersion,
DateTimeOffset.UtcNow, rows.Count, rows);
return File(JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions),
"application/json", $"ludos-library-{stamp}.json");
}
// ---- import ----------------------------------------------------------
[HttpPost("import")]
[RequestSizeLimit(16 * 1024 * 1024)]
public async Task<ActionResult<ImportResult>> Import(
IFormFile file,
[FromQuery] ImportMode mode = ImportMode.Merge,
[FromQuery] bool dryRun = false,
CancellationToken ct = default)
{
if (file is null || file.Length == 0)
{
return BadRequest(new ProblemDetails { Title = "No file was uploaded." });
}
string text;
using (var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
{
text = await reader.ReadToEndAsync(ct);
}
List<ExportGame> incoming;
var errors = new List<ImportRowError>();
try
{
incoming = LooksLikeJson(text)
? ParseJson(text)
: ParseCsv(text, errors);
}
catch (JsonException ex)
{
return BadRequest(new ProblemDetails { Title = $"The file is not valid JSON: {ex.Message}" });
}
catch (InvalidDataException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message });
}
var ownerId = User.GetUserId();
var existing = await db.Games.Where(g => g.OwnerId == ownerId).ToListAsync(ct);
// Title + system identifies a row: the same game legitimately appears
// once per platform (three Donkey Kong Countrys, on SNES, GB and GBA).
var index = existing
.GroupBy(g => Key(g.Title, g.System))
.ToDictionary(g => g.Key, g => g.First());
int created = 0, updated = 0, deleted = 0, skipped = 0;
if (mode == ImportMode.Replace)
{
deleted = existing.Count;
if (!dryRun)
{
db.Games.RemoveRange(existing);
}
index.Clear();
}
var seen = new HashSet<string>();
foreach (var row in incoming)
{
var title = row.Title?.Trim() ?? string.Empty;
if (title.Length == 0)
{
skipped++;
continue;
}
var key = Key(title, row.System);
if (!seen.Add(key))
{
// Two rows for the same game in one file: first one wins.
skipped++;
continue;
}
if (index.TryGetValue(key, out var target))
{
updated++;
if (!dryRun) Apply(row, target);
}
else
{
created++;
if (!dryRun)
{
var game = new Game { OwnerId = ownerId };
Apply(row, game);
db.Games.Add(game);
}
}
}
if (!dryRun)
{
await db.SaveChangesAsync(ct);
logger.LogInformation(
"User {OwnerId} imported {Created} new and {Updated} updated games ({Mode})",
ownerId, created, updated, mode);
}
return Ok(new ImportResult(
dryRun, mode, incoming.Count, created, updated, deleted, skipped, errors));
}
// ---- helpers ---------------------------------------------------------
private static bool LooksLikeJson(string text)
{
var trimmed = text.TrimStart('', ' ', '\t', '\r', '\n');
return trimmed.StartsWith('{') || trimmed.StartsWith('[');
}
/// <summary>Accepts either the export envelope or a bare array of games.</summary>
private static List<ExportGame> ParseJson(string text)
{
var trimmed = text.TrimStart('', ' ', '\t', '\r', '\n');
if (trimmed.StartsWith('['))
{
return JsonSerializer.Deserialize<List<ExportGame>>(trimmed, JsonOptions) ?? [];
}
var envelope = JsonSerializer.Deserialize<LibraryExport>(trimmed, JsonOptions);
return envelope?.Games?.ToList()
?? throw new InvalidDataException("The JSON file contains no games.");
}
private static List<ExportGame> ParseCsv(string text, List<ImportRowError> errors)
{
var rows = Csv.Parse(text);
if (rows.Count == 0)
{
throw new InvalidDataException("The CSV file is empty.");
}
var header = rows[0].Select(h => h.Trim().ToLowerInvariant()).ToList();
var titleAt = header.IndexOf("title");
if (titleAt < 0)
{
throw new InvalidDataException("The CSV file has no 'title' column.");
}
string? Field(List<string> row, string name)
{
var at = header.IndexOf(name);
if (at < 0 || at >= row.Count) return null;
var value = row[at].Trim();
return value.Length == 0 ? null : value;
}
bool Flag(List<string> row, string name)
{
var value = Field(row, name);
return value is not null
&& (value.Equals("true", StringComparison.OrdinalIgnoreCase)
|| value is "1" or "yes" or "y");
}
var games = new List<ExportGame>();
for (var i = 1; i < rows.Count; i++)
{
var row = rows[i];
var title = titleAt < row.Count ? row[titleAt].Trim() : string.Empty;
if (title.Length == 0)
{
// Row number as a person counts them: header is row 1.
errors.Add(new ImportRowError(i + 1, string.Empty, "Missing title"));
continue;
}
games.Add(new ExportGame
{
Title = title,
System = Field(row, "system"),
Genre = Field(row, "genre"),
Year = Field(row, "year"),
Developer = Field(row, "developer"),
Publisher = Field(row, "publisher"),
Description = Field(row, "description"),
Art = Field(row, "art"),
Own = Flag(row, "own"),
Dumped = Flag(row, "dumped"),
Played = Flag(row, "played"),
Finished = Flag(row, "finished"),
});
}
return games;
}
private static string Key(string title, string? system) =>
$"{title.Trim().ToLowerInvariant()}{(system ?? string.Empty).Trim().ToLowerInvariant()}";
private static void Apply(ExportGame source, Game target)
{
target.Title = source.Title.Trim();
target.System = Blank(source.System);
target.Genre = Blank(source.Genre);
target.Year = Blank(source.Year);
target.Developer = Blank(source.Developer);
target.Publisher = Blank(source.Publisher);
target.Description = Blank(source.Description);
target.Art = Blank(source.Art);
target.Own = source.Own;
target.Dumped = source.Dumped;
target.Played = source.Played;
target.Finished = source.Finished;
}
private static string? Blank(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static ExportGame ToExport(Game g) => new()
{
Title = g.Title,
System = g.System,
Genre = g.Genre,
Year = g.Year,
Developer = g.Developer,
Publisher = g.Publisher,
Description = g.Description,
Art = g.Art,
Own = g.Own,
Dumped = g.Dumped,
Played = g.Played,
Finished = g.Finished,
};
}
+124
View File
@@ -0,0 +1,124 @@
using System.Text;
namespace LudosData.Api.Services;
/// <summary>
/// Minimal RFC 4180 CSV reader and writer.
///
/// Hand-rolled rather than taking a dependency, because the surface needed here
/// is small — but it does handle the parts that actually bite: quoted fields
/// containing commas, escaped quotes ("" inside a quoted field), embedded
/// newlines, and CRLF or LF line endings. A description pasted from a web page
/// will contain at least two of those.
/// </summary>
public static class Csv
{
public static string Write(IReadOnlyList<string> headers, IEnumerable<IReadOnlyList<string?>> rows)
{
var builder = new StringBuilder();
builder.AppendLine(string.Join(',', headers.Select(Escape)));
foreach (var row in rows)
{
builder.AppendLine(string.Join(',', row.Select(Escape)));
}
return builder.ToString();
}
private static string Escape(string? value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
var needsQuotes = value.Contains(',') || value.Contains('"')
|| value.Contains('\n') || value.Contains('\r');
return needsQuotes ? $"\"{value.Replace("\"", "\"\"")}\"" : value;
}
/// <summary>Parses CSV text into rows of fields. The first row is the header.</summary>
public static List<List<string>> Parse(string text)
{
var rows = new List<List<string>>();
var row = new List<string>();
var field = new StringBuilder();
var inQuotes = false;
var fieldStarted = false;
for (var i = 0; i < text.Length; i++)
{
var c = text[i];
if (inQuotes)
{
if (c == '"')
{
// A doubled quote inside a quoted field is a literal quote.
if (i + 1 < text.Length && text[i + 1] == '"')
{
field.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
field.Append(c);
}
continue;
}
switch (c)
{
case '"' when !fieldStarted:
inQuotes = true;
fieldStarted = true;
break;
case ',':
row.Add(field.ToString());
field.Clear();
fieldStarted = false;
break;
case '\r':
// Swallow; the \n that follows ends the row.
break;
case '\n':
row.Add(field.ToString());
field.Clear();
fieldStarted = false;
rows.Add(row);
row = [];
break;
default:
field.Append(c);
fieldStarted = true;
break;
}
}
// A final row with no trailing newline still counts.
if (field.Length > 0 || row.Count > 0)
{
row.Add(field.ToString());
rows.Add(row);
}
// Drop trailing blank rows produced by a final newline.
while (rows.Count > 0 && rows[^1].All(string.IsNullOrWhiteSpace))
{
rows.RemoveAt(rows.Count - 1);
}
return rows;
}
}
@@ -0,0 +1,321 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using LudosData.Api.Contracts;
namespace LudosData.Api.Tests;
public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static async Task SeedAsync(HttpClient client)
{
await client.PostAsJsonAsync("/api/games", new
{
title = "Chrono Trigger",
system = "SNES",
genre = "rpg",
year = "1995",
developer = "Square",
own = true,
played = true,
finished = true,
});
await client.PostAsJsonAsync("/api/games", new
{
title = "Ico",
system = "PS2",
genre = "adventure",
year = "2001",
own = true,
});
}
private static MultipartFormDataContent FileContent(string body, string name, string mediaType)
{
var content = new MultipartFormDataContent();
var part = new ByteArrayContent(Encoding.UTF8.GetBytes(body));
part.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(mediaType);
content.Add(part, "file", name);
return content;
}
// ---- export ----------------------------------------------------------
[Fact]
public async Task Json_export_contains_the_library()
{
var client = await factory.CreateUserClientAsync("exp-json");
await SeedAsync(client);
var response = await client.GetAsync("/api/library/export?format=json");
response.EnsureSuccessStatusCode();
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
Assert.Contains("attachment", response.Content.Headers.ContentDisposition?.DispositionType
?? response.Content.Headers.ContentDisposition?.ToString() ?? "attachment");
var payload = await response.Content.ReadFromJsonAsync<LibraryExport>();
Assert.Equal(2, payload!.Count);
Assert.Contains(payload.Games, g => g.Title == "Chrono Trigger" && g.Developer == "Square");
}
[Fact]
public async Task Csv_export_has_a_header_and_one_row_per_game()
{
var client = await factory.CreateUserClientAsync("exp-csv");
await SeedAsync(client);
var csv = await client.GetStringAsync("/api/library/export?format=csv");
var rows = LudosData.Api.Services.Csv.Parse(csv.TrimStart(''));
Assert.Equal("title", rows[0][0]);
Assert.Equal(3, rows.Count); // header + 2 games
Assert.Contains(rows.Skip(1), r => r[0] == "Chrono Trigger");
}
[Fact]
public async Task Export_only_covers_the_signed_in_users_games()
{
var alice = await factory.CreateUserClientAsync("exp-alice");
var bob = await factory.CreateUserClientAsync("exp-bob");
await SeedAsync(alice);
await bob.PostAsJsonAsync("/api/games", new { title = "Bob Only", system = "N64", own = true });
var payload = await bob.GetFromJsonAsync<LibraryExport>("/api/library/export?format=json");
Assert.Equal(1, payload!.Count);
Assert.Equal("Bob Only", payload.Games[0].Title);
}
[Fact]
public async Task Export_rejects_an_unknown_format()
{
var client = await factory.CreateUserClientAsync("exp-bad");
var response = await client.GetAsync("/api/library/export?format=xml");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Export_and_import_round_trip_without_loss()
{
var source = await factory.CreateUserClientAsync("round-source");
await SeedAsync(source);
var exported = await source.GetStringAsync("/api/library/export?format=json");
var target = await factory.CreateUserClientAsync("round-target");
var response = await target.PostAsync("/api/library/import",
FileContent(exported, "library.json", "application/json"));
response.EnsureSuccessStatusCode();
var before = await source.GetFromJsonAsync<LibraryExport>("/api/library/export?format=json");
var after = await target.GetFromJsonAsync<LibraryExport>("/api/library/export?format=json");
Assert.Equal(before!.Count, after!.Count);
Assert.Equal(
before.Games.OrderBy(g => g.Title).Select(g => (g.Title, g.System, g.Developer, g.Finished)),
after.Games.OrderBy(g => g.Title).Select(g => (g.Title, g.System, g.Developer, g.Finished)));
}
// ---- import ----------------------------------------------------------
[Fact]
public async Task Import_creates_missing_games_and_updates_matching_ones()
{
var client = await factory.CreateUserClientAsync("imp-merge");
await SeedAsync(client);
const string csv = """
title,system,genre,year,own,finished
Chrono Trigger,SNES,rpg,1995,true,false
Super Metroid,SNES,platformer,1994,true,true
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
Assert.Equal(1, result!.Created); // Super Metroid
Assert.Equal(1, result.Updated); // Chrono Trigger matched on title + system
Assert.Equal(0, result.Deleted);
// The update took effect: it was finished before, and the file says otherwise.
var page = await client.GetFromJsonAsync<PagePayload>("/api/games?search=Chrono");
Assert.False(page!.Items[0].Finished);
}
[Fact]
public async Task The_same_title_on_a_different_system_is_a_different_game()
{
var client = await factory.CreateUserClientAsync("imp-platform");
await client.PostAsJsonAsync("/api/games", new
{
title = "Donkey Kong Country", system = "SNES", own = true,
});
const string csv = """
title,system,own
Donkey Kong Country,SNES,true
Donkey Kong Country,GB,true
Donkey Kong Country,GBA,true
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
Assert.Equal(2, result!.Created); // GB and GBA
Assert.Equal(1, result.Updated); // the existing SNES row
}
[Fact]
public async Task Dry_run_reports_what_would_happen_and_changes_nothing()
{
var client = await factory.CreateUserClientAsync("imp-dry");
await SeedAsync(client);
const string csv = """
title,system,own
Brand New Game,N64,true
""";
var result = await (await client.PostAsync("/api/library/import?dryRun=true",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
Assert.True(result!.DryRun);
Assert.Equal(1, result.Created);
var page = await client.GetFromJsonAsync<PagePayload>("/api/games");
Assert.Equal(2, page!.Total); // still just the seeded pair
}
[Fact]
public async Task Replace_mode_clears_the_library_first()
{
var client = await factory.CreateUserClientAsync("imp-replace");
await SeedAsync(client);
const string csv = """
title,system,own
Only Survivor,GC,true
""";
var result = await (await client.PostAsync("/api/library/import?mode=Replace",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
Assert.Equal(2, result!.Deleted);
Assert.Equal(1, result.Created);
var page = await client.GetFromJsonAsync<PagePayload>("/api/games");
Assert.Equal(1, page!.Total);
Assert.Equal("Only Survivor", page.Items[0].Title);
}
[Fact]
public async Task Import_never_touches_another_users_library()
{
var alice = await factory.CreateUserClientAsync("imp-alice");
var bob = await factory.CreateUserClientAsync("imp-bob");
await SeedAsync(alice);
await SeedAsync(bob);
// Replace is the most destructive mode; it must stop at the caller.
await bob.PostAsync("/api/library/import?mode=Replace",
FileContent("title,system,own\nBob Only,GC,true", "in.csv", "text/csv"));
var alicePage = await alice.GetFromJsonAsync<PagePayload>("/api/games");
Assert.Equal(2, alicePage!.Total);
}
[Fact]
public async Task Rows_without_a_title_are_reported_rather_than_imported()
{
var client = await factory.CreateUserClientAsync("imp-invalid");
const string csv = """
title,system,own
,SNES,true
Valid Game,SNES,true
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
Assert.Equal(1, result!.Created);
Assert.Single(result.Errors);
Assert.Equal(2, result.Errors[0].Row); // header is row 1
}
[Fact]
public async Task Csv_survives_commas_quotes_and_newlines_in_a_description()
{
var client = await factory.CreateUserClientAsync("imp-quoting");
var awkward = "A description with, a comma, \"quotes\" and\na newline.";
await client.PostAsJsonAsync("/api/games", new
{
title = "Awkward, Game \"Title\"",
system = "PS1",
description = awkward,
own = true,
});
// Round-trip through CSV rather than asserting on the encoding itself.
var csv = await client.GetStringAsync("/api/library/export?format=csv");
var target = await factory.CreateUserClientAsync("imp-quoting-target");
await target.PostAsync("/api/library/import", FileContent(csv, "in.csv", "text/csv"));
var payload = await target.GetFromJsonAsync<LibraryExport>("/api/library/export?format=json");
var game = Assert.Single(payload!.Games);
Assert.Equal("Awkward, Game \"Title\"", game.Title);
Assert.Equal(awkward, game.Description);
}
[Fact]
public async Task A_bare_json_array_is_accepted_as_well_as_the_envelope()
{
var client = await factory.CreateUserClientAsync("imp-bare");
const string json = """
[ { "title": "Bare Array Game", "system": "N64", "own": true } ]
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(json, "in.json", "application/json"))).Content.ReadFromJsonAsync<ImportResult>();
Assert.Equal(1, result!.Created);
}
[Fact]
public async Task Malformed_files_are_rejected_with_a_reason()
{
var client = await factory.CreateUserClientAsync("imp-malformed");
var badJson = await client.PostAsync("/api/library/import",
FileContent("{ not valid json", "in.json", "application/json"));
var headerless = await client.PostAsync("/api/library/import",
FileContent("name,platform\nFoo,SNES", "in.csv", "text/csv"));
var empty = await client.PostAsync("/api/library/import",
FileContent("", "in.csv", "text/csv"));
Assert.Equal(HttpStatusCode.BadRequest, badJson.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, headerless.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
}
[Fact]
public async Task Export_and_import_require_a_token()
{
var anonymous = factory.CreateClient();
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.GetAsync("/api/library/export")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.PostAsync("/api/library/import",
FileContent("title\nX", "in.csv", "text/csv"))).StatusCode);
}
private record GamePayload(int Id, string Title, bool Finished);
private record PagePayload(List<GamePayload> Items, int Total);
}
+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 { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; 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 { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list'; 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 { Router, RouterLink } from '@angular/router';
import { AuthService } from '../../core/auth.service'; 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'; import { Toolbar } from '../../shared/toolbar';
@Component({ @Component({
selector: 'app-account', selector: 'app-account',
imports: [Toolbar, RouterLink, MatCardModule, MatIconModule, MatButtonModule, MatListModule], imports: [
template: ` Toolbar,
<app-toolbar /> RouterLink,
FormsModule,
<div class="page"> MatCardModule,
<mat-card> MatIconModule,
<mat-card-header> MatButtonModule,
<mat-card-title>Account</mat-card-title> MatListModule,
</mat-card-header> MatDividerModule,
MatFormFieldModule,
<mat-card-content> MatSelectModule,
@if (user(); as currentUser) { MatCheckboxModule,
<mat-list> MatProgressBarModule,
<mat-list-item> MatDialogModule,
<mat-icon matListItemIcon>person</mat-icon> ],
<div matListItemTitle>{{ currentUser.userName }}</div> templateUrl: './account.html',
<div matListItemLine>Username</div> styleUrl: './account.scss',
</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;
}
`,
}) })
export class Account { export class Account {
private readonly auth = inject(AuthService); private readonly auth = inject(AuthService);
private readonly library = inject(LibraryService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly dialog = inject(MatDialog);
readonly user = this.auth.user; 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 { logout(): void {
this.auth.logout(); this.auth.logout();
void this.router.navigate(['/login']); 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 },
);
},
});
}
} }