signal-bot/tool/tagger.html
Krzysztof kuhy Rudnicki 0f3da7308d
Some checks are pending
CI / check (push) Waiting to run
fix: the left arrow no longer skips over what you just tagged
--untagged hid an item the moment it gained a tag, including from the back
button, so the one photo you most want to return to -- the one you just
mis-tagged -- was the one you could not reach. The only way back was to
restart without --untagged.

Anything drawn this session stays navigable. Forward still skips work done
in earlier sessions, which is the point of the flag.

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

328 lines
13 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.
// Anything seen this session stays navigable even once it has tags. Without
// this, tagging an item removes it from the set and the left arrow jumps
// straight over your own work -- so a mis-tag is unreachable and the only way
// back is to restart without --untagged.
const visited = new Set();
function shown(index) {
return !state.untagged_only || !state.files[index].keys.length || visited.has(index);
}
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() {
visited.add(at);
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>