signal-bot/tool/tagger.html
Krzysztof kuhy Rudnicki 3b3a78820a fix: the tagger pre-selected a group, which is how an album reaches the wrong chat
The first group in the account's listing was checked by default, and the
first group in this account's listing is a test group. Somebody who typed
the names, hit save and never looked at the radios would have scoped a
private album to the wrong chat -- silently, because the commands then
simply do nothing in the chat they were meant for. Nothing is pre-selected
now, and saving without a group is refused.

Also replaces a test of mine that failed one run in sixteen: it drew a
two-way random choice five times and demanded both outcomes. The property
worth pinning is that a choice lands on a reading that *has* photos, which
is deterministic when only one of them does.

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

200 lines
7.8 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; }
</style>
</head>
<body>
<main>
<div id="setup" hidden>
<h1>Kto jest w tej paczce?</h1>
<p class="hint">
Jedna osoba na linię: nazwy komend po przecinku, bez wykrzyknika.<br>
<code>celina, cela</code><br>
Kombinacje po znaku <code>=</code>: <code>oboje = ala + marek</code>
(zdjęcie z obojgiem) albo <code>ktokolwiek = ala | marek</code>
(losowo jedno z nich).
</p>
<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="tagger" hidden>
<div class="bar">
<h1 id="progress"></h1>
<span class="hint">1–9 = 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});
byName.set(triggers[0].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 keys = parts.map((p) => byName.get(p)).filter(Boolean);
if (keys.length !== parts.length) { throw new Error(`nie znam kogoś z: ${right.trim()}`); }
const triggers = left.split(",").map((t) => "!" + t.trim().replace(/^!/, ""));
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 + 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);
}
function move(step) {
at = Math.min(state.files.length - 1, Math.max(0, at + step));
draw();
}
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 (/^[1-9]$/.test(event.key)) {
const person = entries()[Number(event.key) - 1];
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);
start();
</script>
</body>
</html>