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);