369 lines
12 KiB
C#
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);
|