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