signal-bot/tool/tagger.html
Krzysztof kuhy Rudnicki 9d546fef9a feat: import a folder of photos without importing it twice
tool/tagger.py did not run at all: 48957a1 renamed pack.default_dir to
default_dirs and missed the caller, so the tagger raised ImportError at
module scope through two releases. Nothing could have caught it -- tool/ is
outside --cov and mypy ran with [signal_bot, fake_signal]. Both are widened
here, and tests/test_tool_imports.py imports every script under tool/.

--import was a filename check and shutil.copy2. Pointed at a phone export
that overlaps the album it re-imported the same photo under whatever name
the exporter used that time. It now identifies files by magic bytes, strips
them through the same media_strip path !add_photo uses, and dedups on exact
bytes twice: the source hash, then -- only for what survives -- the hash
after stripping. sources.json records both, so a second run on the same
folder is a no-op. What the album refuses is moved to _skipped/<reason>/
beside the source, never deleted.

Two things that made "strip on import" impossible until now:

media_tags claims -all= zeroes the QuickTime date tags and allows them by
value. It does not; measured, it leaves CreateDate at its real value, and
every mp4 was being refused. -time:all= is what zeroes them.

And a tag allowlist cannot see the worst class at all. IMG_6924.mp4 carries
mebx timed-metadata holding per-frame face bounding boxes and a face id,
and those survive a complete tag strip because they live in mdat as opaque
samples. media_tracks.py drops the track with ffmpeg and verifies with
ffprobe that only video and audio survived. This is not the ffmpeg mistake
media_strip warns about: that was ffmpeg replacing exiftool for tags, this
is track removal with exiftool still doing every tag, and the gate is a
positive read-back rather than an exit code.

The roster was write-once. The setup form only appeared on an empty album,
Album.setup overwrote the file whole, and keys were positional (p{len+1}),
so inserting somebody renumbered everyone after them and repointed every
tags.json entry at the wrong person. Keys now come from the highest number
in use, the form merges, and a person can be added mid-session.

--untagged filters the page and never the server's list. /media/{index} and
/api/tag both index the full album, so a shorter list server-side would
show item i of the album while tagging item i of the filtered set.

Measured on the live album: 174 source files -> 1 not media, 82 already in
pack, 24 duplicate in batch, 67 imported, and re-running imports nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBrE1b9HvFw3xvG9bJiDFt
2026-08-31 13:22:57 +02:00

321 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<!-- Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
The tagger's whole interface. Served only on 127.0.0.1, by tool/tagger.py.
No framework and no network calls but its own: this page shows private
photos, and the only thing it may talk to is the process that opened it. -->
<html lang="pl">
<head>
<meta charset="utf-8">
<title>Tagowanie zdjęć</title>
<style>
:root { --bg: #1b1d21; --fg: #f2f3f5; --dim: #9aa0a6; --accent: #7aa2f7; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg);
font: 16px/1.5 system-ui, sans-serif; }
main { max-width: 900px; margin: 0 auto; padding: 24px; }
h1 { font-size: 20px; margin: 0 0 16px; }
.stage { display: flex; align-items: center; justify-content: center;
height: 62vh; background: #101214; border-radius: 12px; }
.stage img, .stage video { max-width: 100%; max-height: 62vh; border-radius: 12px; }
.people { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }
.person { border: 1px solid #33363d; border-radius: 999px; padding: 8px 14px;
cursor: pointer; user-select: none; background: #24262b; }
.person.on { background: var(--accent); color: #10131a; border-color: var(--accent); }
.bar { display: flex; align-items: center; justify-content: space-between;
gap: 16px; color: var(--dim); }
button { background: #24262b; color: var(--fg); border: 1px solid #33363d;
border-radius: 8px; padding: 8px 14px; cursor: pointer; }
textarea { width: 100%; min-height: 140px; background: #101214; color: var(--fg);
border: 1px solid #33363d; border-radius: 8px; padding: 12px;
font-family: ui-monospace, monospace; }
.hint { color: var(--dim); font-size: 14px; }
.addperson { display: flex; gap: 8px; align-items: center; margin: 4px 0 16px; }
.addperson input { background: #10131a; color: inherit; border: 1px solid #33363d;
border-radius: 8px; padding: 8px 12px; font: inherit; }
.err { color: #f7768e; }
label.group { display: block; padding: 4px 0; }
pre.sample { background: #101214; border: 1px solid #33363d; border-radius: 8px;
padding: 12px; overflow-x: auto; }
.dim { color: #6b7280; }
</style>
</head>
<body>
<main>
<div id="setup" hidden>
<h1>Kto jest w tej paczce?</h1>
<p class="hint">
<b>Jedna osoba na linię</b> — Enter po każdej. Bez wykrzyknika.
Przecinek to <b>inna nazwa tej samej osoby</b>, nie następna osoba.
</p>
<pre class="hint sample">ala
basia
celina, cela <span class="dim">← jedna osoba, dwie nazwy</span>
darek
oboje = ala + basia <span class="dim">← zdjęcie, na którym są obie</span>
ktokolwiek = ala | basia <span class="dim">← losowo jedna z nich</span></pre>
<textarea id="names" spellcheck="false"></textarea>
<h1>W której grupie działają?</h1>
<div id="groups" class="hint">szukam grup…</div>
<p><button id="save">Zapisz i zacznij tagowanie</button></p>
<p id="error" class="err"></p>
</div>
<div id="done" hidden>
<h1>Gotowe.</h1>
<p class="hint">Wszystko zapisane. Komendy i ile mają zdjęć:</p>
<pre class="hint sample" id="counts"></pre>
<p><button id="again">← wróć do zdjęć</button></p>
</div>
<div id="tagger" hidden>
<div class="bar">
<h1 id="progress"></h1>
<span class="hint">1–9 (0 = dziesiąta) = osoba, → dalej, ← wstecz</span>
</div>
<div class="stage" id="stage"></div>
<div class="people" id="people"></div>
<div class="addperson">
<input id="newperson" placeholder="nowa osoba: imie, ksywka" autocomplete="off">
<button id="addperson">dodaj osobę</button>
<span id="addederror" class="hint"></span>
</div>
<div class="bar">
<button id="prev">← wstecz</button>
<span id="saved" class="hint"></span>
<button id="next">dalej →</button>
</div>
</div>
</main>
<script>
const api = (path, body) => fetch(path, body ? {
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body),
} : undefined).then(async (r) => {
if (!r.ok) { throw new Error((await r.json()).detail || r.statusText); }
return r.json();
});
let state = null;
let at = 0;
function parseSetup(text) {
const people = [], combos = [];
const byName = new Map();
for (const raw of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
if (raw.includes("=")) { continue; }
const triggers = raw.split(",")
.map((t) => "!" + t.trim().replace(/^!/, ""))
.filter((t) => t.length > 1);
if (!triggers.length) { continue; }
// Reuse the key this person already has. Handing out p{length+1} on
// every save renumbers everyone after an insertion, and every tags.json
// entry then points at the wrong person, silently.
const existing = (state.people || [])
.find((q) => q.triggers.some((t) => triggers.includes(t)));
const k = existing ? existing.key : `p${nextKey(people)}`;
people.push({key: k, triggers});
// Every alias, not just the first: a combination may name a person by
// whichever spelling its author remembers.
for (const trigger of triggers) { byName.set(trigger.slice(1), k); }
}
let n = 1;
for (const raw of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
if (!raw.includes("=")) { continue; }
const [left, right] = raw.split("=");
const every = right.includes("+");
const parts = right.split(every ? "+" : "|").map((p) => p.trim().replace(/^!/, ""));
const unknown = parts.filter((p) => !byName.has(p));
if (unknown.length) { throw new Error(`nie znam: ${unknown.join(", ")}`); }
const keys = parts.map((p) => byName.get(p));
const triggers = left.split(",")
.map((t) => "!" + t.trim().replace(/^!/, ""))
.filter((t) => t.length > 1);
combos.push(every
? {key: `c${n}`, triggers, all_of: keys}
: {key: `c${n}`, triggers, any_of: keys});
n += 1;
}
return {people, combos};
}
// One past the highest number in use, never the count: deleting p3 by hand
// must not let a later add reuse the key and inherit its photos. Mirrors
// pack_people.next_key on the server, which is the authority.
function nextKey(pending) {
const used = [...(state.people || []), ...pending]
.map((p) => Number(String(p.key || "").replace(/^p/, "")))
.filter((n) => Number.isInteger(n) && n > 0);
return Math.max(0, ...used) + 1;
}
function drawGroups(known, chosen) {
const box = document.getElementById("groups");
if (!known.length) {
box.innerHTML = '<input id="rawgroup" style="width:100%" placeholder="group.…=">';
return;
}
// Nothing is pre-selected. The first group in the listing is whichever
// one the account happens to return first -- checking it by default is a
// one-click way to scope a private album to the wrong chat.
box.innerHTML = known.map((g) => `<label class="group">
<input type="radio" name="group" value="${g.id}" ${chosen.includes(g.id) ? "checked" : ""}>
${g.name || "(bez nazwy)"} <span class="hint">${g.id.slice(0, 18)}…</span></label>`).join("");
}
function chosenGroups() {
const raw = document.getElementById("rawgroup");
if (raw) { return [raw.value.trim()]; }
const picked = document.querySelector('input[name="group"]:checked');
return picked ? [picked.value] : [];
}
function entries() {
return [...state.people];
}
// One expression, used by both the chip label and the key handler. Those
// used to be two independent expressions -- "i === 9 ? 0 : i + 1" here and
// "key === '0' ? 9 : Number(key) - 1" in the handler -- encoding one mapping
// twice. They agreed, and would not have survived an edit.
const HOTKEYS = [..."1234567890", ..."qwertyuiop"];
// .mp4 alone left .mov/.m4v/.webm rendering as a broken <img>.
const VIDEO = [".mp4", ".mov", ".m4v", ".webm"];
function isVideo(name) {
const lower = name.toLowerCase();
return VIDEO.some((suffix) => lower.endsWith(suffix));
}
// Which items this session presents. The server never filters: /media/{index}
// and /api/tag both index the full list, so a shorter list here would show
// item i of the album while tagging item i of the filtered set.
function shown(index) {
return !state.untagged_only || !state.files[index].keys.length;
}
function step(from, direction) {
for (let i = from + direction; i >= 0 && i < state.files.length; i += direction) {
if (shown(i)) { return i; }
}
return -1;
}
function draw() {
const item = state.files[at];
document.getElementById("progress").textContent =
`${at + 1} / ${state.files.length} — ${item.name}`;
const stage = document.getElementById("stage");
stage.innerHTML = isVideo(item.name)
? `<video src="/media/${at}" controls autoplay muted loop></video>`
: `<img src="/media/${at}" alt="">`;
document.getElementById("people").innerHTML = entries().map((p, i) => `
<div class="person ${item.keys.includes(p.key) ? "on" : ""}" data-key="${p.key}">
${HOTKEYS[i] ? HOTKEYS[i] + ". " : ""}${p.triggers[0].slice(1)}
</div>`).join("");
for (const node of document.querySelectorAll(".person")) {
node.onclick = () => toggle(node.dataset.key);
}
}
async function toggle(k) {
const item = state.files[at];
item.keys = item.keys.includes(k) ? item.keys.filter((x) => x !== k) : [...item.keys, k];
draw();
await api("/api/tag", {file: item.name, keys: item.keys});
document.getElementById("saved").textContent = "zapisano";
setTimeout(() => { document.getElementById("saved").textContent = ""; }, 800);
}
async function move(direction) {
const next = step(at, direction);
if (next < 0) {
if (direction < 0) { return; }
// The end of the album is a screen, not a button that stops working.
await finish();
return;
}
at = next;
draw();
}
async function finish() {
const {lines} = await api("/api/summary");
document.getElementById("counts").textContent = lines.join("\n");
document.getElementById("tagger").hidden = true;
document.getElementById("done").hidden = false;
}
document.addEventListener("keydown", (event) => {
if (document.getElementById("tagger").hidden) { return; }
if (event.key === "ArrowRight" || event.key === "Enter") { move(1); }
else if (event.key === "ArrowLeft") { move(-1); }
else if (event.target.tagName !== "INPUT") {
const index = HOTKEYS.indexOf(event.key);
const person = index < 0 ? null : entries()[index];
if (person) { toggle(person.key); }
}
});
async function start() {
state = await api("/api/state");
if (state.configured) {
document.getElementById("tagger").hidden = false;
at = Math.max(0, state.files.findIndex((f) => !f.keys.length));
if (!shown(at)) {
const first = step(-1, 1);
if (first < 0) { await finish(); return; }
at = first;
}
draw();
return;
}
document.getElementById("setup").hidden = false;
drawGroups(state.known_groups, state.groups);
document.getElementById("save").onclick = async () => {
try {
const {people, combos} = parseSetup(document.getElementById("names").value);
if (!people.length) { throw new Error("dodaj przynajmniej jedną osobę"); }
if (!chosenGroups().filter(Boolean).length) { throw new Error("wybierz grupę"); }
await api("/api/setup", {groups: chosenGroups(), people, combos});
location.reload();
} catch (error) {
document.getElementById("error").textContent = error.message;
}
};
}
async function addPerson() {
const field = document.getElementById("newperson");
const error = document.getElementById("addederror");
error.textContent = "";
const triggers = field.value.split(",").map((t) => t.trim()).filter(Boolean);
if (!triggers.length) { return; }
try {
await api("/api/person", {triggers});
// Re-read rather than patch in place: the server assigns the key, and
// guessing it here is how the two ends drift apart.
const fresh = await api("/api/state");
state.people = fresh.people;
field.value = "";
draw();
} catch (err) {
error.textContent = err.message;
}
}
document.getElementById("addperson").onclick = addPerson;
document.getElementById("newperson").addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.stopPropagation(); addPerson(); }
});
document.getElementById("prev").onclick = () => move(-1);
document.getElementById("next").onclick = () => move(1);
document.getElementById("again").onclick = () => {
document.getElementById("done").hidden = true;
document.getElementById("tagger").hidden = false;
draw();
};
start();
</script>
</body>
</html>