Videos: Stufe 7 -- aus einem Wunsch wird ein Video, mit Namen

"Ein Video aus einem Wunsch zeigt den Namen." Wer sich etwas
gewuenscht hat, soll sehen, dass daraus etwas geworden ist -- das ist
der Sinn eines Wunschbretts. Ohne die Verbindung ist die Galerie eine
Sammlung, und der Wunsch von letzter Woche bleibt unbeantwortet im
Raum stehen.

DER KNOPF STEHT AM WUNSCH, nicht im Formular oben. Das ist dieselbe
Entscheidung wie bei den Kettenknoepfen daneben, aus demselben Grund
(dort woertlich): "Wer ein Formular ausfuellt, denkt nicht an den
Wunsch von letzter Woche." Hier denkt er an nichts anderes.

DAS LECK, das es nicht geben darf: Der Titel der Quelle wird auf der
Kachel ANGEZEIGT. Eine Kette in die Content-Planung waere deshalb kein
Schoenheitsfehler, sondern ein Weg, interne Arbeit in die Community zu
tragen. Die Quelle muss ein Community-Brett sein -- dieselbe Regel wie
beim "Daraus"-Knopf.

ZWEI WEGE, EIN ZIEL: "Daraus ein Highlight machen" gibt es laenger.
Wer ihn geht und DANACH das Video einfuegt, bekaeme sonst ein zweites
Highlight aus demselben Wunsch -- zwei Wege zum selben Ziel, die
nichts voneinander wissen. Das Video haengt sich jetzt an den
vorhandenen Eintrag. Der Titel bleibt dabei der des Wunsches: Er
stammt von einem Menschen, der von TikTok ist eine Bildunterschrift
mit Schlagwoertern.

Geprueft: pruef-video 51 -> 67. Gegenprobe (Brett-Pruefung und
Anhaengen ausgebaut) macht 6 rot, darunter "ein Eintrag aus der
Content-Planung kommt NICHT als Quelle durch (201)".

UND DAS BILDSCHIRMFOTO HAT ETWAS GEFUNDEN, das keine Zahl gemeldet
haette: Das Eingabefeld erschien am ENDE der Karte, unter der
Fusszeile -- getrennt von dem Knopf, den man gerade gedrueckt hatte.
Alle Pruefungen waren gruen, das Feld war da und reagierte. Jetzt
haengt es an der Knopfreihe.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-18 17:00:16 +02:00
co-authored by Claude Opus 5
parent fb55377e19
commit 59c84bd248
38 changed files with 726 additions and 413 deletions
+125
View File
@@ -421,6 +421,131 @@ melde("=== 5b. Wenn ein Video bei TikTok verschwindet ===");
void l1;
}
/* =======================================================================
5c. DER KREISLAUF (Stufe 7)
„Ein Video aus einem Wunsch zeigt den Namen." Wer sich etwas
gewuenscht hat, soll sehen, dass daraus etwas geworden ist.
DER WICHTIGSTE TEIL IST DER ZWEITE: Der Titel der Quelle wird auf
der Kachel ANGEZEIGT. Eine Kette in die Content-Planung waere
deshalb kein Schoenheitsfehler, sondern ein Leck -- dort steht
Arbeit, die die Community nichts angeht.
======================================================================= */
melde("");
melde("=== 5c. Aus einem Wunsch wird ein Video ===");
{
const db3 = new DatabaseSync(process.env.WORKSPACE_DB);
const nun = new Date().toISOString();
const anlegen = (bereich, art, titel) => {
const { lastInsertRowid } = db3.prepare(`
INSERT INTO eintraege (bereich, art, titel, datum, status, erstellt, erstellt_von)
VALUES (?,?,?,?, 'offen', ?, (SELECT id FROM personen LIMIT 1))`)
.run(bereich, art, titel, nun.slice(0, 10), nun);
return Number(lastInsertRowid);
};
const wunschId = anlegen("wunsch", "wunsch", "Bitte mal Casper beim Baden zeigen");
/* Ein Eintrag aus der Content-Planung -- NICHT aus der Community.
Er ist der Koeder fuer die Leck-Pruefung weiter unten. */
const internId = anlegen("content", "idee", "Interne Planung: Kampagne Q4");
/* ---- Der gute Weg ------------------------------------------------ */
{
const a = await fetch(`${BASIS}/workspace/api/bereich/highlight/video`, {
method: "POST", headers: kopf,
body: JSON.stringify({ url: videoVon("dogfather0804", "7a"), aus: wunschId }),
});
const r = await a.json().catch(() => ({}));
ok(a.status === 201 && r.aus === wunschId,
`ein Video zu einem Wunsch wird angenommen (${a.status})`);
const e = (await liste()).find((x) => x.id === r.id);
ok(e?.aus?.id === wunschId, "der Eintrag kennt seinen Wunsch");
ok(e?.aus?.titel === "Bitte mal Casper beim Baden zeigen",
`und zeigt dessen Titel (${e?.aus?.titel})`);
ok(!!e?.aus?.von_name, `und den Namen dessen, der ihn geaeussert hat (${e?.aus?.von_name})`);
}
/* ---- DAS LECK, das es nicht geben darf --------------------------- */
{
const a = await fetch(`${BASIS}/workspace/api/bereich/highlight/video`, {
method: "POST", headers: kopf,
body: JSON.stringify({ url: videoVon("dogfather0804", "7b"), aus: internId }),
});
ok(a.status === 404,
`ein Eintrag aus der Content-Planung kommt NICHT als Quelle durch (${a.status})`);
const bleibt = db3.prepare(
"SELECT COUNT(*) AS n FROM eintraege WHERE aus_eintrag_id = ?").get(internId);
ok(bleibt.n === 0, `und es ist auch nichts entstanden (${bleibt.n})`);
}
{
const a = await fetch(`${BASIS}/workspace/api/bereich/highlight/video`, {
method: "POST", headers: kopf,
body: JSON.stringify({ url: videoVon("dogfather0804", "7c"), aus: 999999 }),
});
ok(a.status === 404, `eine Nummer, die es nicht gibt, ebenfalls nicht (${a.status})`);
}
{
const a = await fetch(`${BASIS}/workspace/api/bereich/highlight/video`, {
method: "POST", headers: kopf,
body: JSON.stringify({ url: videoVon("dogfather0804", "7d"), aus: "irgendwas" }),
});
ok(a.status === 400, `und etwas, das keine Nummer ist, wird abgewiesen (${a.status})`);
}
/* ---- ZWEI WEGE, EIN ZIEL ----------------------------------------- */
{
/* „Daraus ein Highlight machen" gibt es laenger. Wer ihn geht und
DANACH das Video einfuegt, darf kein zweites Highlight bekommen. */
const wunsch2 = anlegen("wunsch", "wunsch", "Ein Video vom Gassigehen");
const d = await fetch(`${BASIS}/workspace/api/bereich/wunsch/${wunsch2}/daraus`, {
method: "POST", headers: kopf, body: JSON.stringify({ nach: "highlight" }),
});
const dr = await d.json().catch(() => ({}));
ok(d.status === 201 && !!dr.id, `erst „Daraus ein Highlight machen" (${d.status})`);
const v = await fetch(`${BASIS}/workspace/api/bereich/highlight/video`, {
method: "POST", headers: kopf,
body: JSON.stringify({ url: videoVon("dogfather0804", "7e"), aus: wunsch2 }),
});
const vr = await v.json().catch(() => ({}));
ok(v.status === 201 && vr.id === dr.id && vr.angehaengt === true,
`dann das Video -- es haengt sich an den vorhandenen Eintrag (#${vr.id} vs #${dr.id})`);
const wieviele = db3.prepare(
"SELECT COUNT(*) AS n FROM eintraege WHERE aus_eintrag_id = ? AND bereich = 'highlight'")
.get(wunsch2);
ok(wieviele.n === 1,
`aus einem Wunsch ist GENAU EIN Highlight geworden, nicht zwei (${wieviele.n})`);
const e2 = (await liste()).find((x) => x.id === dr.id);
ok(e2?.titel === "Ein Video vom Gassigehen",
`und der Titel bleibt der des Wunsches, nicht der von TikTok (${e2?.titel})`);
ok(!!e2?.quelle_url, "das Video ist trotzdem dran");
/* Noch einmal dasselbe: nichts Neues, kein Fehler. */
const w = await fetch(`${BASIS}/workspace/api/bereich/highlight/video`, {
method: "POST", headers: kopf,
body: JSON.stringify({ url: videoVon("dogfather0804", "7f"), aus: wunsch2 }),
});
const wr = await w.json().catch(() => ({}));
ok(w.status === 200 && wr.schon === true,
`ein zweites Video zum selben Wunsch aendert nichts (${w.status})`);
/* Und die Wunschkarte weiss, dass es schon ein Video gibt --
daran haengt, ob der Knopf noch angeboten wird. */
const wl = await fetch(`${BASIS}/workspace/api/bereich/wunsch`, { headers: { cookie: keks } });
const wliste = (await wl.json().catch(() => null))?.eintraege || [];
const karte = wliste.find((x) => x.id === wunsch2);
ok((karte?.wurde || []).some((z) => z.bereich === "highlight" && z.hat_video === true),
"die Wunschkarte sieht, dass daraus ein Highlight MIT Video geworden ist");
const karte1 = wliste.find((x) => x.id === wunschId);
ok(!!karte1, "und der erste Wunsch steht auch noch in der Liste");
}
db3.close();
}
/* =======================================================================
6. UND ES ÜBERLEBT, WENN TIKTOK WEG IST ← der eigentliche Punkt
======================================================================= */
+12 -2
View File
@@ -1293,13 +1293,23 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => {
}
}
const p3 = eintraege.map(() => "?").join(", ");
/* `quelle_url` kommt mit, damit die Karte unterscheiden kann:
Aus dem Wunsch ist ein Highlight geworden -- hat es auch schon
ein Video? Sonst waere der Knopf „Video dazu" entweder immer da
(auch wenn es laengst eins gibt) oder nie (sobald irgendein
Highlight daraus entstanden ist). */
const draus = db().prepare(
`SELECT id, bereich, titel, aus_eintrag_id FROM eintraege
`SELECT id, bereich, titel, aus_eintrag_id, quelle_url FROM eintraege
WHERE aus_eintrag_id IN (${p3})`).all(...eintraege.map((e) => e.id));
const jeQuelle = new Map();
for (const z of draus) {
if (!jeQuelle.has(z.aus_eintrag_id)) jeQuelle.set(z.aus_eintrag_id, []);
jeQuelle.get(z.aus_eintrag_id).push({ id: z.id, bereich: z.bereich, titel: z.titel });
jeQuelle.get(z.aus_eintrag_id).push({
id: z.id, bereich: z.bereich, titel: z.titel,
/* Nur ja/nein -- die Adresse selbst gehoert nicht in eine
Liste, die nur „was wurde daraus" beantworten soll. */
hat_video: !!z.quelle_url,
});
}
for (const e of eintraege) e.wurde = jeQuelle.get(e.id) || [];
+70 -9
View File
@@ -49,6 +49,7 @@ import {
KANAELE, kanalVonHandle, TREFF_BRETTER,
} from "./workspace.js";
import { darfSchreiben } from "./workspace-treff.js";
import { sichtbar } from "./workspace-bereiche.js";
import { bildArtVon } from "./workspace-dateien.js";
export const videoRouter = express.Router();
@@ -170,6 +171,36 @@ videoRouter.post("/workspace/api/bereich/:bereich/video", async (req, res) => {
return res.status(400).json({ fehler: "Das ist keine TikTok-Adresse." });
}
/* ---- DER KREISLAUF (Stufe 7, 18.09.2026) --------------------------
„Ein Video aus einem Wunsch zeigt den Namen." Wer sich etwas
gewuenscht hat, soll sehen, dass daraus etwas geworden ist --
das ist der ganze Sinn eines Wunschbretts. Ohne diese Verbindung
ist die Galerie eine Sammlung, und der Wunsch von letzter Woche
bleibt unbeantwortet im Raum stehen.
DIE QUELLE MUSS EIN COMMUNITY-BRETT SEIN. Eine Kette, die in die
Content-Planung eines Creators fuehrt, waere ein Leck: Dort steht
Arbeit, die die Community nichts angeht -- und der TITEL der
Quelle wird auf der Kachel angezeigt. Dieselbe Regel wie beim
„Daraus"-Knopf, aus demselben Grund. */
const aus = Number(req.body?.aus || 0);
let ausQuelle = null;
if (req.body?.aus !== undefined && req.body?.aus !== null && req.body?.aus !== "") {
if (!Number.isInteger(aus) || aus <= 0) {
return res.status(400).json({ fehler: "Die Quelle ist keine Nummer." });
}
if (!sichtbar(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
ausQuelle = db().prepare(
"SELECT id, bereich, titel FROM eintraege WHERE id = ?").get(aus);
/* Nicht da, oder kein Community-Brett: dieselbe Antwort. Sonst
liesse sich durch Ausprobieren herausfinden, welche Nummern es
gibt und in welchem Bereich sie liegen. */
if (!ausQuelle || !TREFF_BRETTER.includes(ausQuelle.bereich)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
}
/* SCHON DA? Derselbe Link zweimal soll dasselbe Ergebnis geben,
nicht zwei Eintraege. Wer den Knopf zweimal drueckt, meint nicht
zwei Videos. */
@@ -177,6 +208,22 @@ videoRouter.post("/workspace/api/bereich/:bereich/video", async (req, res) => {
"SELECT id FROM eintraege WHERE quelle_url = ? AND bereich = ?").get(url, bereich);
if (schon) return res.status(200).json({ id: schon.id, schon: true });
/* IST AUS DIESEM WUNSCH SCHON ETWAS GEWORDEN?
Den Weg „Daraus ein Highlight machen" gibt es laenger als diesen
hier. Wer ihn gegangen ist und DANACH das Video einfuegt, bekaeme
sonst ein ZWEITES Highlight aus demselben Wunsch -- zwei Wege zum
selben Ziel, die nichts voneinander wissen. Das Video wird
deshalb an den vorhandenen Eintrag gehaengt. */
let anhaengenAn = 0;
if (ausQuelle) {
const da = db().prepare(
"SELECT id, quelle_url FROM eintraege WHERE aus_eintrag_id = ? AND bereich = ?")
.get(aus, bereich);
if (da?.quelle_url) return res.status(200).json({ id: da.id, schon: true });
if (da) anhaengenAn = da.id;
}
const auskunft = await videoAuskunft(url);
if (auskunft.fehler) return res.status(502).json({ fehler: auskunft.fehler });
@@ -194,14 +241,27 @@ videoRouter.post("/workspace/api/bereich/:bereich/video", async (req, res) => {
const jetzt = new Date().toISOString();
const arten = { highlight: "clip", anschlag: "hinweis", treff: "lob" };
const { lastInsertRowid } = db().prepare(`
INSERT INTO eintraege
(bereich, art, titel, datum, status, erstellt, erstellt_von,
quelle_url, quelle_kanal, quelle_geholt_am)
VALUES (?,?,?,?, 'offen', ?,?,?,?,?)`)
.run(bereich, arten[bereich] || "clip", auskunft.titel, jetzt.slice(0, 10),
jetzt, req.person.id, url, auskunft.kanal, jetzt);
const eintragId = Number(lastInsertRowid);
let eintragId;
if (anhaengenAn) {
/* DER TITEL WIRD NICHT UEBERSCHRIEBEN. Er stammt vom Wunsch und
damit von einem Menschen; der von TikTok ist eine Bildunter-
schrift mit Schlagwoertern. Wer den ersetzte, naehme dem
Eintrag genau das, was ihn mit dem Wunsch verbindet. */
db().prepare(`UPDATE eintraege SET quelle_url = ?, quelle_kanal = ?,
quelle_geholt_am = ?, geaendert = ?
WHERE id = ?`)
.run(url, auskunft.kanal, jetzt, jetzt, anhaengenAn);
eintragId = anhaengenAn;
} else {
const { lastInsertRowid } = db().prepare(`
INSERT INTO eintraege
(bereich, art, titel, datum, status, erstellt, erstellt_von,
quelle_url, quelle_kanal, quelle_geholt_am, aus_eintrag_id)
VALUES (?,?,?,?, 'offen', ?,?,?,?,?,?)`)
.run(bereich, arten[bereich] || "clip", auskunft.titel, jetzt.slice(0, 10),
jetzt, req.person.id, url, auskunft.kanal, jetzt, ausQuelle ? aus : null);
eintragId = Number(lastInsertRowid);
}
if (abgelegt) {
db().prepare(`
@@ -215,11 +275,12 @@ videoRouter.post("/workspace/api/bereich/:bereich/video", async (req, res) => {
protokolliere("video_eingelesen", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `${auskunft.kanal} ${url}`.slice(0, 120),
detail: `${auskunft.kanal} ${url}${ausQuelle ? ` (aus #${aus})` : ""}`.slice(0, 120),
});
res.status(201).json({
id: eintragId, schon: false, kanal: auskunft.kanal,
titel: auskunft.titel, cover: !!abgelegt || undefined,
...(ausQuelle ? { aus: aus, angehaengt: !!anhaengenAn } : {}),
});
} catch (fehler) {
/* GING ETWAS SCHIEF, NACHDEM DIE DATEI SCHON LAG: wegraeumen. Eine