commit fb266af3e5e0db2045ab1d22f9c1da43da0599b9 Author: Chris Kruining Date: Tue Feb 3 14:01:15 2026 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29a138d --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# .NET +bin/ +obj/ +dist/ +*.dll +*.exe +*.pdb + +# IDE +.vs/ +.vscode/ +.idea/ +*.user +*.suo + +# OS +.DS_Store +Thumbs.db + +# Project specific +*.csv +*.dlens +*.apk diff --git a/.justfile b/.justfile new file mode 100644 index 0000000..e62b769 --- /dev/null +++ b/.justfile @@ -0,0 +1,19 @@ +# Scry build recipes + +# Default recipe - show available commands +default: + @just --list + +# Build both standard and embedded versions for all platforms +build apk="delver.apk": + rm -rf dist + dotnet publish -c Release -r win-x64 -o dist/win-x64/standard + dotnet publish -c Release -r win-x64 -p:EmbeddedApk={{apk}} -o dist/win-x64/embedded + dotnet publish -c Release -r linux-x64 -o dist/linux-x64/standard + dotnet publish -c Release -r linux-x64 -p:EmbeddedApk={{apk}} -o dist/linux-x64/embedded + dotnet publish -c Release -r osx-x64 -o dist/osx-x64/standard + dotnet publish -c Release -r osx-x64 -p:EmbeddedApk={{apk}} -o dist/osx-x64/embedded + +# Clean build artifacts +clean: + rm -rf bin obj dist diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..06ed5ea --- /dev/null +++ b/Program.cs @@ -0,0 +1,369 @@ +using System.CommandLine; +using System.Reflection; +using System.Text; +using ICSharpCode.SharpZipLib.Zip; +using Microsoft.Data.Sqlite; +using Spectre.Console; + +// Ensure UTF-8 output for Unicode characters +Console.OutputEncoding = Encoding.UTF8; + +var dlensArgument = new Argument("dlens"); +dlensArgument.Description = "Path to the .dlens database file"; + +var outputOption = new Option("--output", "-o"); +outputOption.Description = "Output CSV file path (defaults to collection.csv)"; + +var showTableOption = new Option("--show-table", "-t"); +showTableOption.Description = "Display the card collection as a table"; +showTableOption.DefaultValueFactory = _ => false; + +#if EMBEDDED_APK +var rootCommand = new RootCommand("Extract and display card data from Delver Lens") +{ + dlensArgument, + outputOption, + showTableOption +}; + +rootCommand.SetAction(async (parseResult, cancellationToken) => +{ + var dlensFile = parseResult.GetValue(dlensArgument)!; + var outputFile = parseResult.GetValue(outputOption) ?? new FileInfo("collection.csv"); + var showTable = parseResult.GetValue(showTableOption); + await ProcessFiles(null, dlensFile, outputFile, showTable); +}); +#else +var apkArgument = new Argument("apk"); +apkArgument.Description = "Path to the Delver Lens APK file"; + +var rootCommand = new RootCommand("Extract and display card data from Delver Lens") +{ + apkArgument, + dlensArgument, + outputOption, + showTableOption +}; + +rootCommand.SetAction(async (parseResult, cancellationToken) => +{ + var apkFile = parseResult.GetValue(apkArgument)!; + var dlensFile = parseResult.GetValue(dlensArgument)!; + var outputFile = parseResult.GetValue(outputOption) ?? new FileInfo("collection.csv"); + var showTable = parseResult.GetValue(showTableOption); + await ProcessFiles(apkFile, dlensFile, outputFile, showTable); +}); +#endif + +return await rootCommand.Parse(args).InvokeAsync(); + +async Task ProcessFiles(FileInfo? apkFile, FileInfo dlensFile, FileInfo outputFile, bool showTable) +{ +#if !EMBEDDED_APK + if (apkFile == null || !apkFile.Exists) + { + AnsiConsole.MarkupLine($"[red]APK file not found:[/] {apkFile?.FullName}"); + return; + } +#endif + + if (!dlensFile.Exists) + { + AnsiConsole.MarkupLine($"[red]dlens file not found:[/] {dlensFile.FullName}"); + return; + } + + List? scannedCards = null; + List? collection = null; + var steps = new[] { false, false, false }; + + Panel BuildPanel() + { + var content = $""" + [bold yellow]Progress[/] + + {Step(0, "Read scanned cards from dlens")} + {Step(1, "Resolve card data from APK")} + {Step(2, "Export collection to CSV")} + """; + + if (steps[2]) + { + content += $""" + + + [bold yellow]Summary[/] + + [blue]Your collection:[/] {collection!.Count} unique cards, {collection.Sum(c => c.Quantity)} total + [green]Exported to:[/] {outputFile.FullName} + + [bold yellow]How to import into Archidekt[/] + + 1. Go to [link]https://archidekt.com/collection[/] + 2. Click [yellow]Import[/] + 3. Click [yellow]Add manual column[/] [blue]6 times[/] + 4. Set the columns in order: + • Quantity → [blue]Quantity[/] + • Scryfall ID → [blue]Scryfall ID[/] + • Foil → [blue]Foil[/] + • Card Name → [blue]Ignore[/] + • Set Code → [blue]Ignore[/] + • Collector Number → [blue]Ignore[/] + 5. Set [yellow]Skip first row[/] to [blue]true[/] [grey](the CSV has a header)[/] + 6. Set the csv file by either dragging and dropping it, or clicking the upload box + 7. Click [yellow]Upload[/] + """; + } + + return new Panel(content) + { + Header = new PanelHeader(" Delver Lens → Archidekt "), + Border = BoxBorder.Rounded, + Padding = new Padding(2, 1) + }; + } + + var spinnerFrames = new[] { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }; + var spinnerIndex = 0; + var currentStep = 0; + + string Step(int index, string text) + { + if (steps[index]) + return $"[green][[✓]][/] {text}"; + if (index == currentStep) + return $"[blue][[{spinnerFrames[spinnerIndex]}]][/] {text}"; + return $"[grey][[○]][/] [grey]{text}[/]"; + } + + // When piped, output CSV to stdout for composability + if (Console.IsOutputRedirected) + { + scannedCards = await GetScannedCards(dlensFile); + collection = await ResolveCollection(apkFile, scannedCards); + WriteCsvToStdout(collection); + return; + } + + // Interactive: use live display with progress panel + using var cts = new CancellationTokenSource(); + + await AnsiConsole.Live(BuildPanel()) + .StartAsync(async ctx => + { + // Spinner animation task + var spinnerTask = Task.Run(async () => + { + while (!cts.Token.IsCancellationRequested) + { + await Task.Delay(80, cts.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + spinnerIndex = (spinnerIndex + 1) % spinnerFrames.Length; + ctx.UpdateTarget(BuildPanel()); + } + }); + + scannedCards = await GetScannedCards(dlensFile); + steps[0] = true; + currentStep = 1; + ctx.UpdateTarget(BuildPanel()); + + collection = await ResolveCollection(apkFile, scannedCards); + steps[1] = true; + currentStep = 2; + ctx.UpdateTarget(BuildPanel()); + + await ExportCsv(collection, outputFile); + steps[2] = true; + ctx.UpdateTarget(BuildPanel()); + + cts.Cancel(); + await spinnerTask; + }); + + // Display table if requested (after live panel completes) + if (showTable) + { + DisplayCollection(collection!); + } +} + +async Task> ResolveCollection(FileInfo? apkFile, List scannedCards) +{ + var tempDbPath = Path.GetTempFileName(); + var cardIds = scannedCards.Select(c => c.CardId).ToHashSet(); + + try + { + // Get APK stream from embedded resource or file +#if EMBEDDED_APK + var assembly = Assembly.GetExecutingAssembly(); + await using var apkStream = assembly.GetManifestResourceStream("delver.apk") + ?? throw new Exception("Embedded APK resource not found"); +#else + await using var apkStream = apkFile!.OpenRead(); +#endif + + using (var zipFile = new ZipFile(apkStream)) + { + var entry = zipFile.GetEntry("res/raw/data.db"); + if (entry == null) + { + throw new Exception("Could not find res/raw/data.db in APK"); + } + + await using var zipStream = zipFile.GetInputStream(entry); + await using var outputStream = File.Create(tempDbPath); + await zipStream.CopyToAsync(outputStream); + } + + var cardData = new Dictionary(); + + await using (var connection = new SqliteConnection($"Data Source={tempDbPath};Mode=ReadOnly")) + { + await connection.OpenAsync(); + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + SELECT + c._id, + n.name, + e.tl_abb, + c.number, + c.scryfall_id + FROM cards c + JOIN names n ON c.name = n._id + JOIN editions e ON c.edition = e._id;"; + + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var id = reader.GetInt32(0); + if (cardIds.Contains(id)) + { + cardData[id] = ( + reader.GetString(1), + reader.GetString(2), + reader.IsDBNull(3) ? "" : reader.GetString(3), + reader.IsDBNull(4) ? "" : reader.GetString(4) + ); + } + } + } + + var collection = new List(); + foreach (var scanned in scannedCards) + { + if (cardData.TryGetValue(scanned.CardId, out var data)) + { + collection.Add(new CollectionCard( + scanned.Quantity, + data.Name, + data.SetCode, + data.CollectorNumber, + data.ScryfallId, + scanned.Foil + )); + } + else + { + collection.Add(new CollectionCard( + scanned.Quantity, + $"Unknown (ID: {scanned.CardId})", + "", + "", + "", + scanned.Foil + )); + } + } + + return collection; + } + finally + { + SqliteConnection.ClearAllPools(); + if (File.Exists(tempDbPath)) + { + File.Delete(tempDbPath); + } + } +} + +void DisplayCollection(List collection) +{ + var table = new Table(); + table.Border = TableBorder.Rounded; + table.AddColumn("Qty"); + table.AddColumn("Name"); + table.AddColumn("Set"); + table.AddColumn("#"); + table.AddColumn("Foil"); + table.AddColumn("Scryfall ID"); + + foreach (var card in collection.OrderBy(c => c.Name).ThenBy(c => c.SetCode)) + { + table.AddRow( + card.Quantity.ToString(), + card.Name.Length > 30 ? card.Name[..27] + "..." : card.Name, + card.SetCode, + card.CollectorNumber, + card.Foil ? "[yellow]Yes[/]" : "", + card.ScryfallId.Length > 8 ? card.ScryfallId[..8] + "..." : card.ScryfallId + ); + } + + AnsiConsole.Write(table); +} + +async Task ExportCsv(List collection, FileInfo outputFile) +{ + var sb = new StringBuilder(); + sb.AppendLine("Quantity,Scryfall ID,Foil,Card Name,Set Code,Collector Number"); + + foreach (var card in collection.OrderBy(c => c.Name).ThenBy(c => c.SetCode)) + { + var foilStr = card.Foil ? "Foil" : "Normal"; + var name = card.Name.Contains(',') ? $"\"{card.Name}\"" : card.Name; + sb.AppendLine($"{card.Quantity},{card.ScryfallId},{foilStr},{name},{card.SetCode},{card.CollectorNumber}"); + } + + await File.WriteAllTextAsync(outputFile.FullName, sb.ToString()); +} + +void WriteCsvToStdout(List collection) +{ + Console.WriteLine("Quantity,Scryfall ID,Foil,Card Name,Set Code,Collector Number"); + + foreach (var card in collection.OrderBy(c => c.Name).ThenBy(c => c.SetCode)) + { + var foilStr = card.Foil ? "Foil" : "Normal"; + var name = card.Name.Contains(',') ? $"\"{card.Name}\"" : card.Name; + Console.WriteLine($"{card.Quantity},{card.ScryfallId},{foilStr},{name},{card.SetCode},{card.CollectorNumber}"); + } +} + +async Task> GetScannedCards(FileInfo dlensFile) +{ + var cards = new List(); + + await using var connection = new SqliteConnection($"Data Source={dlensFile.FullName};Mode=ReadOnly"); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT * FROM cards"; + + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var cardId = reader.GetInt32(reader.GetOrdinal("card")); + var quantity = reader.GetInt32(reader.GetOrdinal("quantity")); + var foil = reader.GetInt32(reader.GetOrdinal("foil")) == 1; + + cards.Add(new ScannedCard(cardId, quantity, foil)); + } + + return cards; +} + +record ScannedCard(int CardId, int Quantity, bool Foil); +record CollectionCard(int Quantity, string Name, string SetCode, string CollectorNumber, string ScryfallId, bool Foil); diff --git a/Scry.csproj b/Scry.csproj new file mode 100644 index 0000000..b3f1ef6 --- /dev/null +++ b/Scry.csproj @@ -0,0 +1,33 @@ + + + + Exe + net10.0 + enable + enable + true + true + true + true + none + false + + + + $(DefineConstants);EMBEDDED_APK + + + + + delver.apk + + + + + + + + + + +