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:
@@ -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`),
|
||||
`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
|
||||
|
||||
```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()} | ||||