{% extends "base.html" %} {% macro chosen_icon() -%} {%- endmacro %} {% block title %}Import scans · foilstack{% endblock %} {% block status %}{% if active %}import running · {{ active.processed }}/{{ active.total }}{% elif counts.all %}{{ counts.all }} scans to confirm · {{ counts.nomatch }} with no match{% else %}queue clear · drop some scans to begin{% endif %}{% endblock %} {% block screen %}
Import scans jpg, png, tif · or a .zip of them
Drop your card scans here
one image per card, or a .zip of them
up to {{ "{:,}".format(max_images) }} scans · {{ max_mb }} MB per upload
{{ active.filename if active }}
{{ 'matching…' if active }}
{{ 'queued' if active else 'ready' }} 0%
{# Two of these counted something other than what they were called. "matched" was `job.processed`, which increments for every scan the importer finishes — including the ones that matched nothing and the ones that errored — so a batch where half the images failed still reported them as matched. And "waiting" is not part of this import at all: it is the review queue, which outlives the job and holds rows from earlier batches too. Sat in a row of progress numbers it read as "waiting to be identified" and looked stuck, because it does not move while an archive uploads. #}
scans
{{ active.total if active else 0 }}
processed
{{ active.processed if active else 0 }}
to confirm
{{ counts.all }}
Match settings
Default condition
{% for c in conditions %} {% endfor %}
Default finish
Auto-accept above
{% for t in thresholds %} {% endfor %}
{# Off by default, and it has to stay that way: this is the only setting on the screen that overrules a match the encoder was sure about, and a batch that is not really one set is worse off with it on. The seller is asserting something about the pile in their hand, so it is theirs to tick. The label is not "Batch is all one…" — an ellipsis set in the same uppercase mono as every other label on the panel reads as a truncated string, and sends the reader looking for the rest. #}
Batch is all one
Why finish and auto-accept work this way

A foil and a non-foil printing use the same artwork, so a scan cannot tell them apart. That is why you set the finish yourself. It is worth getting right: a foil often sells for several times what the plain version does.

A scan skips the review queue only when all three of these are true.

  • It scores above the threshold you picked.
  • It beats the second-best match by a clear margin.
  • If that second-best is the same card in another printing, the two prices are close.

Reprints share artwork, so a high score on its own proves nothing. The threshold is saved with each import, so you can always see later why a scan was accepted.

All one game or set is worth ticking when the batch really is — a booster box, or one binder page. Every card is matched on its own first; then the game or set that most of them agree on is worked out, and any card that landed outside it is moved to its best match inside it. A card moved that way always goes to the queue for you to look at, however well it scored.

Leave it off for a mixed pile. It decides the game or set by majority, so on a batch that is genuinely three games it will pull two of them towards the third.

matcher: dinov3 image embeddings · nearest neighbour
scans kept on disk · never uploaded off-host
{% if jobs %}
Recent imports
{% for job in jobs %} {# Truncated by the column rather than by a slice: the pane is a fixed width, so where the name has to give way is a question the layout can answer and the template cannot. The full name stays reachable on hover. #} {% endfor %}
WHENFILESTATUSSCANS
{{ job.filename }} {{ job.status }} {%- if job.processed == job.total %}{{ job.total }} {%- else %}{{ job.processed }}/{{ job.total }}{% endif -%}
{% endif %}

Matches

{% if queue_value %}{{ queue_value|money }} market value{% endif %}
{% for g in groups %} {# The upload these cards arrived in. Sections run oldest first and the cards inside them dearest first, so the backlog drains from the front and the card worth most is the first decision in each batch. A `
`, so collapsing costs no script and arrives with the keyboard and the accessibility tree already working — the same idiom the match-settings panel above uses. Open by default: a queue that hid its own cards on load would be a screen with nothing on it. #}
{{ g.filename }} · {{ g.rows|length }} {{ 'card' if g.rows|length == 1 else 'cards' }} {%- if g.value %} · {{ g.value|money }}{% endif %} {%- if g.cohort %} · {{ g.cohort }}{% endif %} {% for r in g.rows %}
scan
{{ r.filename }}
{% if r.card_id %}{% endif %} catalogue
{{ r.name }}
{{ r.meta }}
{# No one-click swap on a row someone has already decided: that button commits on the spot, and putting a commit-to-the-guess control beside a deliberate choice is a trap. The panel behind "wrong card?" still lists every candidate, and picking there does not commit. #} {% if r.needs_review and r.alternates and not r.chosen and not r.cohort %} {# Titled because the line is ellipsised at the pane's width, and what falls off the end is the tail — which on a row the batch moved is the match it was moved off, the only part a reader cannot get from anywhere else on the screen. #} {% else %}{{ r.alt }}{% endif %} {% if r.needs_review %} {% endif %}
{% if r.chosen %}{{ chosen_icon() }}{% else %}
{{ r.pct }} match
{% endif %}
{% for c in conditions %} {% endfor %}
{# Rendered in its final state rather than left to the script to correct, so a foil batch does not flash "Non-foil" first — and that now includes the fill, because a freshly rendered row is no longer always showing what its import asked for. A card the catalogue prices one way only overrules the default, and the unfilled chip is what says so. #} {% if r.ambiguous %} ? {% endif %}
{% if r.needs_review and r.card_id %} {% endif %}
{# Full-width, so it wraps onto its own line inside the flex row rather than squeezing the columns beside it. Filled on first open. #}
{% endfor %}
{% endfor %} {% if not groups %}
{% if filter == 'matched' %}nothing matched is waiting {% elif filter == 'nomatch' %}everything waiting has a match {% else %}queue is clear · drop some scans to start{% endif %}
{% endif %}
{% endblock %} {% block scripts %} {% endblock %}