diff --git a/README.md b/README.md index e8e723b..a04068d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/src/LudosData.Api/Contracts/LibraryContracts.cs b/backend/src/LudosData.Api/Contracts/LibraryContracts.cs new file mode 100644 index 0000000..9d443d0 --- /dev/null +++ b/backend/src/LudosData.Api/Contracts/LibraryContracts.cs @@ -0,0 +1,60 @@ +namespace LudosData.Api.Contracts; + +/// +/// 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. +/// +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; } + + /// + /// 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. + /// + 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; } +} + +/// Envelope written by the JSON exporter. +public record LibraryExport( + string Format, + int Version, + DateTimeOffset ExportedAt, + int Count, + IReadOnlyList Games); + +public enum ImportMode +{ + /// Update rows that match on title + system, insert the rest. Nothing is deleted. + Merge = 0, + + /// Delete the caller's entire library first, then insert the file. + 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 Errors); diff --git a/backend/src/LudosData.Api/Controllers/LibraryController.cs b/backend/src/LudosData.Api/Controllers/LibraryController.cs new file mode 100644 index 0000000..359bf86 --- /dev/null +++ b/backend/src/LudosData.Api/Controllers/LibraryController.cs @@ -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; + +/// +/// 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. +/// +[ApiController] +[Route("api/library")] +[Authorize] +public class LibraryController( + LudosDbContext db, + ILogger 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 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 + { + 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> 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 incoming; + var errors = new List(); + + 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(); + + 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('['); + } + + /// Accepts either the export envelope or a bare array of games. + private static List ParseJson(string text) + { + var trimmed = text.TrimStart('', ' ', '\t', '\r', '\n'); + + if (trimmed.StartsWith('[')) + { + return JsonSerializer.Deserialize>(trimmed, JsonOptions) ?? []; + } + + var envelope = JsonSerializer.Deserialize(trimmed, JsonOptions); + return envelope?.Games?.ToList() + ?? throw new InvalidDataException("The JSON file contains no games."); + } + + private static List ParseCsv(string text, List 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 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 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(); + + 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, + }; +} diff --git a/backend/src/LudosData.Api/Services/Csv.cs b/backend/src/LudosData.Api/Services/Csv.cs new file mode 100644 index 0000000..7a492de --- /dev/null +++ b/backend/src/LudosData.Api/Services/Csv.cs @@ -0,0 +1,124 @@ +using System.Text; + +namespace LudosData.Api.Services; + +/// +/// 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. +/// +public static class Csv +{ + public static string Write(IReadOnlyList headers, IEnumerable> 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; + } + + /// Parses CSV text into rows of fields. The first row is the header. + public static List> Parse(string text) + { + var rows = new List>(); + var row = new List(); + 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; + } +} diff --git a/backend/tests/LudosData.Api.Tests/LibraryTests.cs b/backend/tests/LudosData.Api.Tests/LibraryTests.cs new file mode 100644 index 0000000..90c707b --- /dev/null +++ b/backend/tests/LudosData.Api.Tests/LibraryTests.cs @@ -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 +{ + 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(); + 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("/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("/api/library/export?format=json"); + var after = await target.GetFromJsonAsync("/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(); + + 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("/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(); + + 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(); + + Assert.True(result!.DryRun); + Assert.Equal(1, result.Created); + + var page = await client.GetFromJsonAsync("/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(); + + Assert.Equal(2, result!.Deleted); + Assert.Equal(1, result.Created); + + var page = await client.GetFromJsonAsync("/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("/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(); + + 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("/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(); + + 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 Items, int Total); +} diff --git a/frontend/src/app/core/library.service.ts b/frontend/src/app/core/library.service.ts new file mode 100644 index 0000000..299e5c0 --- /dev/null +++ b/frontend/src/app/core/library.service.ts @@ -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 { + 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 { + const form = new FormData(); + form.append('file', file, file.name); + + return this.http.post( + `/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); +} diff --git a/frontend/src/app/features/account/account.html b/frontend/src/app/features/account/account.html new file mode 100644 index 0000000..a3bf073 --- /dev/null +++ b/frontend/src/app/features/account/account.html @@ -0,0 +1,153 @@ + + +
+ + + Account + + + + @if (user(); as currentUser) { + + + person +
{{ currentUser.userName }}
+
Username
+
+ + @if (currentUser.email) { + + mail +
{{ currentUser.email }}
+
Email
+
+ } + + @if (currentUser.firstName || currentUser.lastName) { + + badge +
{{ currentUser.firstName }} {{ currentUser.lastName }}
+
Name
+
+ } +
+ } +
+ + + Back to library + + +
+ + + + + Export your library + Download everything as a file you keep + + + +

+ 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. +

+ +
+ + +
+
+
+ + + + @if (importing()) { + + } + + + Import + Restore a backup, or bring a library in from elsewhere + + + +

+ Accepts JSON or CSV. Rows are matched on title and system, so the same game on + two consoles stays two entries. +

+ + + +
+ + {{ selectedFile()?.name ?? 'No file chosen' }} +
+ +
+ + Mode + + Merge — add and update, delete nothing + Replace — wipe the library first + + + + + Preview only + +
+ + @if (mode() === 'Replace' && !dryRun()) { + + } + + + + @if (result(); as r) { +
+

+ {{ r.dryRun ? 'Preview — nothing was changed' : 'Import complete' }} +

+
    +
  • {{ r.parsed }} rows read
  • +
  • {{ r.created }} added
  • +
  • {{ r.updated }} updated
  • + @if (r.deleted) { +
  • {{ r.deleted }} deleted
  • + } + @if (r.skipped) { +
  • {{ r.skipped }} skipped
  • + } +
+ + @if (r.errors.length) { +

Rows that could not be read

+
    + @for (e of r.errors; track e.row) { +
  • Row {{ e.row }}: {{ e.reason }}
  • + } +
+ } +
+ } +
+
+
diff --git a/frontend/src/app/features/account/account.scss b/frontend/src/app/features/account/account.scss new file mode 100644 index 0000000..1f47e68 --- /dev/null +++ b/frontend/src/app/features/account/account.scss @@ -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); + } +} diff --git a/frontend/src/app/features/account/account.ts b/frontend/src/app/features/account/account.ts index 61b247b..55215c5 100644 --- a/frontend/src/app/features/account/account.ts +++ b/frontend/src/app/features/account/account.ts @@ -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: ` - - -
- - - Account - - - - @if (user(); as currentUser) { - - - person -
{{ currentUser.userName }}
-
Username
-
- - @if (currentUser.email) { - - mail -
{{ currentUser.email }}
-
Email
-
- } - - @if (currentUser.firstName || currentUser.lastName) { - - badge -
- {{ currentUser.firstName }} {{ currentUser.lastName }} -
-
Name
-
- } -
- } -
- - - Back to library - - -
-
- `, - 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(null); + protected readonly mode = signal('Merge'); + protected readonly dryRun = signal(true); + protected readonly result = signal(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 }, + ); + }, + }); + } }