signal-bot/tool/tagger.html
Krzysztof kuhy Rudnicki 7f66116cd5 fix: the tagger ends on a screen, not on a button that stops working
Clicking "dalej" on the last photo did nothing, because move() clamped at
the end. Everything had in fact been saved -- each toggle writes tags.json
on the spot -- but the only way to find that out was to stop the tagger and
read its summary, so the last photo answered "did that work?" with silence.

The end of the album is now a screen: it says Gotowe, prints how many photos
each command has, and offers a way back into the photos.

The counts are distinct files rather than a sum per reading. A photo showing
two of the three people an "either of them" entry covers is one photo that
command can return, and summing claimed it twice.

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

241 lines
9.3 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; }
.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">marek
ala
celina, cela <span class="dim">← jedna osoba, dwie nazwy</span>
iga
oboje = ala + marek <span class="dim">← zdjęcie, na którym są oboje</span>
ktokolwiek = ala | marek <span class="dim">← losowo jedno 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="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; }
const k = `p${people.length + 1}`;
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};
}
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];
}
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 = item.name.toLowerCase().endsWith(".mp4")
? `<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}">
${i === 9 ? 0 : i + 1}. ${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(step) {
if (step > 0 && at === state.files.length - 1) {
// The end of the album is a screen, not a button that stops working.
await finish();
return;
}
at = Math.min(state.files.length - 1, Math.max(0, at + step));
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 (/^[0-9]$/.test(event.key)) {
// 0 is the tenth, so a pack of ten is still entirely keyboard-driven.
const index = event.key === "0" ? 9 : Number(event.key) - 1;
const person = 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));
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;
}
};
}
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>