mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
Some checks are pending
CI / check (push) Waiting to run
--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
328 lines
13 KiB
HTML
328 lines
13 KiB
HTML
<!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>
|