{% 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 %}
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.

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 %} {% 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 %} {% 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 %}