scry/Program.cs
2026-02-03 14:01:15 +01:00

369 lines
12 KiB
C#

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<FileInfo>("dlens");
dlensArgument.Description = "Path to the .dlens database file";
var outputOption = new Option<FileInfo?>("--output", "-o");
outputOption.Description = "Output CSV file path (defaults to collection.csv)";
var showTableOption = new Option<bool>("--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<FileInfo>("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<ScannedCard>? scannedCards = null;
List<CollectionCard>? 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<List<CollectionCard>> ResolveCollection(FileInfo? apkFile, List<ScannedCard> 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<int, (string Name, string SetCode, string CollectorNumber, string ScryfallId)>();
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<CollectionCard>();
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<CollectionCard> 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<CollectionCard> 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<CollectionCard> 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<List<ScannedCard>> GetScannedCards(FileInfo dlensFile)
{
var cards = new List<ScannedCard>();
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);