initial commit
This commit is contained in:
commit
fb266af3e5
4 changed files with 444 additions and 0 deletions
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
19
.justfile
Normal file
19
.justfile
Normal file
|
|
@ -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
|
||||||
369
Program.cs
Normal file
369
Program.cs
Normal file
|
|
@ -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<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);
|
||||||
33
Scry.csproj
Normal file
33
Scry.csproj
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<PublishReadyToRun>true</PublishReadyToRun>
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
<DebugType>none</DebugType>
|
||||||
|
<DebugSymbols>false</DebugSymbols>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(EmbeddedApk)' != ''">
|
||||||
|
<DefineConstants>$(DefineConstants);EMBEDDED_APK</DefineConstants>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="'$(EmbeddedApk)' != ''">
|
||||||
|
<EmbeddedResource Include="$(EmbeddedApk)">
|
||||||
|
<LogicalName>delver.apk</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.2" />
|
||||||
|
<PackageReference Include="SharpZipLib" Version="1.4.2" />
|
||||||
|
<PackageReference Include="Spectre.Console" Version="0.54.0" />
|
||||||
|
<PackageReference Include="System.CommandLine" Version="2.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue