diff --git a/server/index.js b/server/index.js index 63f9af33..5a719d54 100644 --- a/server/index.js +++ b/server/index.js @@ -54,7 +54,7 @@ import { teamlageRouter } from "./workspace-teamlage.js"; import { pushRouter, pushStarten } from "./workspace-push.js"; import { chatRouter } from "./workspace-chat.js"; import { anrufRouter } from "./workspace-anruf.js"; -import { videoRouter } from "./workspace-video.js"; +import { videoRouter, videoWacheStarten } from "./workspace-video.js"; import { bewerbungRouter } from "./workspace-bewerbung.js"; import { leistungRouter } from "./workspace-leistung.js"; import { fruehwarnungRouter } from "./workspace-fruehwarnung.js"; @@ -309,6 +309,10 @@ app.use(contentRouter); Systemdienst laeuft und warum eine Dateikopie hier falsch waere. */ sicherungStarten(); aufbewahrungStarten(); +/* Sieht taeglich nach, ob die verlinkten Videos bei TikTok noch da + sind. Ein Knopf, der ins Leere fuehrt, ist schlimmer als kein + Knopf -- man klickt ihn ja. */ +videoWacheStarten(); /* Wiederkehrende Termine einmal auffuellen. Kein Takt, kein Dienst -- siehe workspace-serien.js: Der Nachfueller legt nur an, was fehlt, und diff --git a/server/pruef-video.mjs b/server/pruef-video.mjs index f6044b4b..23007ff9 100644 --- a/server/pruef-video.mjs +++ b/server/pruef-video.mjs @@ -63,6 +63,10 @@ const PNG = Buffer.from( "base64"); let coverAbrufe = 0; let oembedAbrufe = 0; +/* Welche Videos gerade „geloescht" sind und welche „stoeren". + Zwei getrennte Mengen, weil es zwei getrennte Faelle sind. */ +const verschwunden = new Set(); +const stoerung = new Set(); const coverAdresse = (was) => `http://127.0.0.1:${TIKTOK}/${was}` + `?x-expires=${Math.floor(Date.now() / 1000) + 172800}&x-signature=abc`; @@ -75,6 +79,11 @@ const tiktok = createServer((q, a) => { Dienst ist es genauso. */ const wer = (ziel.match(/@([^/?]+)/) || [])[1] || ""; if (!wer) { a.writeHead(404); a.end("{}"); return; } + /* UMSCHALTBAR FUER DIE WACHE (Abschnitt 5b): Ein Video kann + verschwinden und wiederkommen, und TikTok kann stoeren. Beides + sind verschiedene Dinge, und genau das muss messbar sein. */ + if (verschwunden.has(ziel)) { a.writeHead(404); a.end("{}"); return; } + if (stoerung.has(ziel)) { a.writeHead(503); a.end("{}"); return; } /* Welches Cover? Am Videonamen. So lassen sich die Sonderfälle auslösen, ohne den Dienst umzubauen. */ let bild = coverAdresse("cover.jpg"); @@ -303,6 +312,115 @@ melde("=== 5. Die Byteprüfung ==="); ok(b.status === 201, `ein Video ohne Coverbild geht auch (${b.status})`); } +/* ======================================================================= + 5b. FUEHRT DER KNOPF NOCH IRGENDWOHIN? (Stufe 5) + + Der wichtigste Satz dieses Abschnitts ist nicht „geloeschte Videos + werden erkannt", sondern: EINE STOERUNG BEI TIKTOK AENDERT NICHTS. + Die naheliegende Fassung setzt bei jedem misslungenen Abruf „weg" -- + und markiert an einem schlechten Nachmittag die halbe Galerie als + geloescht. Wer das hinterher sucht, sucht lange, denn im Protokoll + steht dann sauber, dass TikTok gefragt wurde. + ======================================================================= */ +melde(""); +melde("=== 5b. Wenn ein Video bei TikTok verschwindet ==="); +{ + const video = await import("./workspace-video.js"); + const db2 = new DatabaseSync(process.env.WORKSPACE_DB); + const nimm = (id) => db2.prepare( + "SELECT quelle_geprueft_am, quelle_fehlt_seit," + + " COALESCE(quelle_fehlversuche,0) AS fehl FROM eintraege WHERE id = ?").get(id); + /* Stellt den naechsten Tag dar. Die Wache sieht verdaechtige Videos + TAEGLICH nach -- ohne Zurueckdatieren waeren alle drei Laeufe am + selben Tag, und nur der erste taete etwas. Genau so ist es + gewollt; die Pruefung muss deshalb die Uhr weiterstellen, nicht + die Regel umgehen. */ + const morgen = (id, stunden = 36) => db2.prepare( + "UPDATE eintraege SET quelle_geprueft_am = ? WHERE id = ?") + .run(new Date(Date.now() - stunden * 3600_000).toISOString(), id); + + /* Derselbe Account wie in Abschnitt 1 -- ein ERFUNDENER wird von der + Route zu Recht abgewiesen, und die Pruefung haette dann gemessen, + dass sie sich selbst vertippt hat. */ + const adresse = videoVon("dogfather0804", "5b1"); + const ein = await holen(adresse); + const id5b = ein.daten?.id; + ok(ein.status === 201 && !!id5b, `ein Video zum Beobachten ist da (#${id5b})`); + + /* ---- Die drei Antworten einzeln ---------------------------------- */ + ok((await video.videoNochDa(adresse)).da === true, + "ein vorhandenes Video meldet: da"); + stoerung.add(adresse); + const beiStoerung = await video.videoNochDa(adresse); + ok(beiStoerung.da === null, + `eine Stoerung meldet WEISS NICHT, nicht „weg" (${beiStoerung.grund})`); + stoerung.delete(adresse); + verschwunden.add(adresse); + const beiWeg = await video.videoNochDa(adresse); + ok(beiWeg.da === false, `ein geloeschtes meldet: weg (${beiWeg.grund})`); + + /* ---- Ein einzelner Fehlversuch markiert NICHT --------------------- */ + const l1 = await video.videoWacheLauf(); + const n1 = nimm(id5b); + ok(n1.fehl === 1 && !n1.quelle_fehlt_seit, + `nach dem ersten Fehlversuch ist es noch NICHT „weg" (Zaehler ${n1.fehl})`); + + morgen(id5b); + await video.videoWacheLauf(); + const n2 = nimm(id5b); + ok(n2.fehl === 2 && !n2.quelle_fehlt_seit, + `nach dem zweiten auch nicht (Zaehler ${n2.fehl})`); + + morgen(id5b); + const l3 = await video.videoWacheLauf(); + const n3 = nimm(id5b); + ok(n3.fehl === 3 && !!n3.quelle_fehlt_seit, + `erst der dritte markiert es (Zaehler ${n3.fehl}, seit ${String(n3.quelle_fehlt_seit).slice(0, 10)})`); + ok(l3.weg === 1, `und der Lauf sagt es auch (weg: ${l3.weg})`); + + /* ---- Die Oberflaeche erfaehrt davon ------------------------------- */ + const e5b = (await liste()).find((x) => x.id === id5b); + ok(!!e5b?.quelle_fehlt_seit, + "die Liste liefert den Zustand mit -- sonst zeigte die Kachel weiter den Knopf"); + + /* ---- DER KERN: eine Stoerung aendert NICHTS ----------------------- */ + verschwunden.delete(adresse); + /* ACHT TAGE, nicht 36 Stunden: Ein bereits markiertes Video wird + absichtlich nur noch woechentlich nachgefragt (siehe die drei + Geschwindigkeiten in workspace-video.js). Die Pruefung muss die + echte Regel abbilden, nicht die bequemere. */ + morgen(id5b, 8 * 24); + await video.videoWacheLauf(); /* wieder da -> Zaehler zurueck */ + const zurueck = nimm(id5b); + ok(zurueck.fehl === 0 && !zurueck.quelle_fehlt_seit, + "kommt es zurueck (privat gestellt und wieder oeffentlich), faellt die Markierung weg"); + + /* Ebenfalls acht Tage: Nach der Rueckkehr steht der Zaehler auf + null, das Video gilt also wieder als unauffaellig -- und + unauffaellige werden nur woechentlich gefragt. */ + morgen(id5b, 8 * 24); + stoerung.add(adresse); + const vorher5b = nimm(id5b); + const lS = await video.videoWacheLauf(); + const nachher5b = nimm(id5b); + stoerung.delete(adresse); + ok(nachher5b.fehl === vorher5b.fehl && !nachher5b.quelle_fehlt_seit, + `eine Stoerung erhoeht den Zaehler NICHT (${vorher5b.fehl} -> ${nachher5b.fehl})`); + ok(nachher5b.quelle_geprueft_am === vorher5b.quelle_geprueft_am, + "und verschiebt auch den Zeitpunkt nicht -- sonst waere sieben Tage Ruhe," + + " obwohl nichts gemessen wurde"); + ok(lS.unklar >= 1 && lS.geprueft === 0, + `der Lauf zaehlt sie als „weiss nicht" (unklar ${lS.unklar}, geprueft ${lS.geprueft})`); + + /* ---- Und ein gesundes Video bleibt unberuehrt --------------------- */ + const heil = (await liste()).find((x) => x.id === eintragId); + ok(!heil?.quelle_fehlt_seit, + "ein Video, das es noch gibt, wird nicht angefasst"); + + db2.close(); + void l1; +} + /* ======================================================================= 6. UND ES ÜBERLEBT, WENN TIKTOK WEG IST ← der eigentliche Punkt ======================================================================= */ diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index 201e1d16..472436e4 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -1116,7 +1116,7 @@ function sichtbarRoh2(person, praefix = "e") { const SPALTEN = ` e.id, e.bereich, e.art, e.titel, e.text, e.datum, e.uhrzeit, e.aus_eintrag_id, - e.quelle_url, e.quelle_kanal, e.bewertung, + e.quelle_url, e.quelle_kanal, e.quelle_fehlt_seit, e.bewertung, e.dringlichkeit, e.status, e.creator_id, e.erstellt, e.erstellt_von, e.geaendert, e.hook, e.format, e.saeule_id, e.geplant, e.event_ende, e.event_aufgaben, e.event_regeln, diff --git a/server/workspace-video.js b/server/workspace-video.js index 5e44e796..d3ec8179 100644 --- a/server/workspace-video.js +++ b/server/workspace-video.js @@ -230,3 +230,186 @@ videoRouter.post("/workspace/api/bereich/:bereich/video", async (req, res) => { res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); + + +/* ===================================================================== + STUFE 5: FUEHRT DER KNOPF NOCH IRGENDWOHIN? (18.09.2026) + + Ein Video kann bei TikTok geloescht oder auf privat gestellt werden. + Bei uns bleibt der Eintrag stehen -- Cover, Text, und ein Knopf „Auf + TikTok ansehen", der auf eine Fehlerseite fuehrt. Das faellt beim + Bauen nicht auf und beim Testen nicht; es faellt dem auf, der + klickt, und das ist die Community. + + --------------------------------------------------------------------- + DREI ANTWORTEN, NICHT ZWEI + + Die naheliegende Fassung fragt TikTok und setzt bei einem Fehlschlag + „weg". Die waere an einem einzigen schlechten Nachmittag von TikTok + in der Lage, die halbe Galerie als geloescht zu markieren -- und + niemand haette einen Anhaltspunkt, warum ploetzlich alles fort ist. + + Deshalb wird unterschieden: + + TikTok schickt Daten -> DA. Zaehler zurueck auf null, eine + frueher gesetzte Markierung faellt + weg (ein privat gestelltes Video + kann wiederkommen). + TikTok sagt „gibt es nicht" -> ein FEHLVERSUCH. Erst der dritte an + drei verschiedenen Tagen markiert. + Niemand antwortet / 5xx -> WEISS NICHT. Aendert gar nichts, + nicht einmal den Zaehler. + + Der dritte Fall ist der wichtige: Unerreichbar ist kein Befund. + ===================================================================== */ + +/** Ab wie vielen Fehlversuchen ein Video als „nicht mehr da" gilt. */ +const FEHLT_AB = 3; + +/** Wie viele je Lauf. Nicht alle auf einmal: Ein Schwall Anfragen an + * einen fremden Dienst ist das, was man von einem Programm erwartet, + * das man nicht mehr bedienen will. */ +const WACHE_JE_LAUF = 40; + +/** Abstand zwischen zwei Abfragen. */ +const WACHE_PAUSE_MS = 300; + +/** Wie oft ein unauffaelliges Video nachgesehen wird (Tage). + * + * DREI GESCHWINDIGKEITEN, und jede hat ihren Grund: + * + * unauffaellig alle sieben Tage -- es gibt keinen Anlass. + * verdaechtig taeglich. Sonst dauerte es drei Wochen, bis + * (1-2 Fehlversuche) ein geloeschtes Video als geloescht dasteht. + * schon markiert wieder alle sieben Tage. Die Frage ist + * beantwortet; taeglich nachzufragen waere + * Hammern auf einem fremden Dienst fuer eine + * Auskunft, die wir schon haben. Dass eine + * Rueckkehr dann bis zu eine Woche braucht, ist + * der richtige Preis. */ +const WACHE_ABSTAND_TAGE = 7; + +/** Fragt nach, ob es das Video noch gibt. + * + * @returns {Promise<{da: boolean|null, grund: string}>} `da === null` + * heisst ausdruecklich „konnte nicht nachsehen" -- nicht „weg". + */ +export async function videoNochDa(url) { + let a; + try { + a = await holeMitFrist(`${OEMBED_BASIS}?url=${encodeURIComponent(url)}`, + { accept: "application/json" }); + } catch (f) { + /* Zeitueberschreitung, DNS, kein Netz -- alles Aussagen ueber die + Leitung, keine ueber das Video. */ + return { da: null, grund: `nicht erreichbar (${f?.name || "Fehler"})` }; + } + if (a.status >= 500) return { da: null, grund: `TikTok stoert (${a.status})` }; + /* 400 und 404 sind die Antworten, mit denen TikTok auf geloeschte und + auf privat gestellte Videos antwortet. 403 ebenso. */ + if (a.status === 400 || a.status === 403 || a.status === 404) { + return { da: false, grund: `TikTok kennt es nicht (${a.status})` }; + } + if (!a.ok) return { da: null, grund: `unerwartet (${a.status})` }; + const j = await a.json().catch(() => null); + /* Antwort ohne Titel: TikTok liefert das auch fuer entfernte Videos + mit Status 200. Ein leerer Titel ist deshalb eine Aussage. */ + if (!j || !j.title) return { da: false, grund: "Antwort ohne Titel" }; + return { da: true, grund: "" }; +} + +/** Sieht die faelligen Videos durch. + * + * Getrennt vom Zeitgeber, damit die Pruefung sie aufrufen kann, ohne + * einen Tag zu warten -- und damit sie gemessen werden kann. + * + * @returns {Promise<{geprueft: number, weg: number, zurueck: number, + * unklar: number}>} + */ +export async function videoWacheLauf() { + const d = db(); + const grenze = new Date(Date.now() - WACHE_ABSTAND_TAGE * 86400_000).toISOString(); + const heute = new Date().toISOString().slice(0, 10); + + /* WELCHE ZUERST: die mit Fehlversuchen (die sind verdaechtig und + sollen schnell geklaert sein), dann die am laengsten ungesehenen. + `quelle_geprueft_am IS NULL` zuerst -- das sind die neuen. */ + let faellig = []; + try { + faellig = d.prepare(` + SELECT id, quelle_url, COALESCE(quelle_fehlversuche, 0) AS fehl, + quelle_geprueft_am, quelle_fehlt_seit + FROM eintraege + WHERE quelle_url IS NOT NULL AND quelle_url <> '' + AND ( quelle_geprueft_am IS NULL + OR ( COALESCE(quelle_fehlversuche, 0) > 0 + AND quelle_fehlt_seit IS NULL + AND substr(quelle_geprueft_am, 1, 10) < ? ) + OR quelle_geprueft_am < ? ) + ORDER BY COALESCE(quelle_fehlversuche, 0) DESC, + COALESCE(quelle_geprueft_am, '') ASC + LIMIT ?`).all(heute, grenze, WACHE_JE_LAUF); + } catch (f) { + /* Die Spalten gibt es erst nach dem ersten angemeldeten Aufruf + (sie werden nachgeruestet). Das ist kein Fehler, nur zu frueh. */ + return { geprueft: 0, weg: 0, zurueck: 0, unklar: 0, grund: String(f?.message || f) }; + } + + let weg = 0, zurueck = 0, unklar = 0, geprueft = 0; + for (const e of faellig) { + const { da, grund } = await videoNochDa(e.quelle_url); + const jetzt = new Date().toISOString(); + + if (da === null) { + /* WEISS NICHT AENDERT NICHTS -- auch nicht den Zeitstempel. Wer + ihn setzte, verschoebe die naechste Nachfrage um sieben Tage, + obwohl nichts gemessen wurde. */ + unklar++; + continue; + } + geprueft++; + + if (da) { + /* Wieder da -- oder war nie weg. Beides raeumt auf. */ + if (e.quelle_fehlt_seit || e.fehl) zurueck++; + d.prepare(`UPDATE eintraege SET quelle_geprueft_am = ?, + quelle_fehlversuche = 0, quelle_fehlt_seit = NULL + WHERE id = ?`).run(jetzt, e.id); + continue; + } + + const fehl = e.fehl + 1; + const markieren = fehl >= FEHLT_AB && !e.quelle_fehlt_seit; + d.prepare(`UPDATE eintraege SET quelle_geprueft_am = ?, + quelle_fehlversuche = ?, quelle_fehlt_seit = ? + WHERE id = ?`) + .run(jetzt, fehl, markieren ? jetzt : (e.quelle_fehlt_seit || null), e.id); + if (markieren) { + weg++; + protokolliere("video_nicht_mehr_da", { + detail: `#${e.id} nach ${fehl} Versuchen: ${grund}`.slice(0, 120), + }); + } + if (WACHE_PAUSE_MS) await new Promise((f) => setTimeout(f, WACHE_PAUSE_MS)); + } + + return { geprueft, weg, zurueck, unklar }; +} + +/** Einmal am Tag, erstmals fuenf Minuten nach dem Start. + * + * Fuenf Minuten und nicht neunzig Sekunden wie beim Aufraeumen: Dieser + * Lauf geht nach DRAUSSEN. Er soll nicht mit dem Start um die ersten + * Anfragen der Leute konkurrieren, und er hat es nie eilig -- ein + * Video, das gestern geloescht wurde, darf noch fuenf Minuten laenger + * verlinkt sein. */ +export function videoWacheStarten() { + const erster = setTimeout(() => { + videoWacheLauf().catch((f) => console.error("[video-wache]", f?.message)); + }, 5 * 60_000); + const takt = setInterval(() => { + videoWacheLauf().catch((f) => console.error("[video-wache]", f?.message)); + }, 24 * 3600_000); + erster.unref?.(); + takt.unref?.(); +} diff --git a/server/workspace.js b/server/workspace.js index 2e250f95..5ef70edb 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1800,7 +1800,21 @@ function umstellungen(d) { try { const spalten = d.prepare("PRAGMA table_info(eintraege)").all().map((s) => s.name); for (const [name, typ] of [["quelle_url", "TEXT"], ["quelle_kanal", "TEXT"], - ["quelle_geholt_am", "TEXT"]]) { + ["quelle_geholt_am", "TEXT"], + /* ---- Stufe 5: fuehrt der Knopf noch irgendwohin? (18.09.2026) ---- + + Ein Video kann bei TikTok geloescht oder auf privat gestellt + werden. Bei uns bleibt der Eintrag stehen -- mit Cover, Text + und einem Knopf, der ins Leere fuehrt. Das faellt niemandem + auf, der die Seite baut; es faellt dem auf, der klickt. + + `quelle_fehlversuche` ist der Grund, warum es drei Spalten + sind und nicht eine: Ein einzelner misslungener Abruf heisst + NICHT, dass das Video weg ist -- er heisst vielleicht, dass + TikTok gerade Schluckauf hat. Wer daraus sofort „geloescht" + macht, leert bei der naechsten Stoerung die halbe Galerie. */ + ["quelle_geprueft_am", "TEXT"], ["quelle_fehlt_seit", "TEXT"], + ["quelle_fehlversuche", "INTEGER"]]) { if (spalten.length && !spalten.includes(name)) { d.exec(`ALTER TABLE eintraege ADD COLUMN ${name} ${typ}`); console.log(`[workspace] Spalte '${name}' an den Eintraegen ergaenzt.`); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index a4044584..35a1f0a8 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -22,25 +22,25 @@ }, "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609181521", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609181607", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609181521", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609181607", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609181521", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609181607", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609181521", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609181607", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index caaa1993..6443391a 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -1165,6 +1165,25 @@ .videozeile__weg { flex: 0 0 auto; text-decoration: none; } +/* Ein Video, das es bei TikTok nicht mehr gibt. + + BEWUSST KEIN ROT und keine Warnfarbe: Es ist kein Fehler, es ist + eine Auskunft. Rot an dieser Stelle liesse die ganze Kachel wie + einen Schaden aussehen, obwohl Titel, Text und Cover weiter + stimmen -- die liegen bei uns. + + Gestrichelter Rand statt Flaeche: Der Platz des Knopfes bleibt + sichtbar, aber nichts darin laedt zum Tippen ein. */ +.videozeile__fort { + flex: 0 0 auto; + padding: .34rem .7rem; + border: 1px dashed rgba(150, 170, 195, .45); + border-radius: .6rem; + color: #8fa3bb; + font-size: .84rem; + cursor: default; +} + /* --------------------------------------------------------------------- DIE KANALZEILE (17.09.2026) -- Schritt 3 aus dem Video-Plan diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index 3abd1a35..310157a5 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -451,11 +451,29 @@ k.dataset.kanal = e.quelle_kanal; z.append(k); } - const a = el('a', 'schritt videozeile__weg', 'Auf TikTok ansehen →'); - a.href = e.quelle_url; - a.target = '_blank'; - a.rel = 'noopener noreferrer'; - z.append(a); + /* IST DAS VIDEO BEI TIKTOK NOCH DA? (Stufe 5, 18.09.2026) + + Wird es dort geloescht oder auf privat gestellt, bleibt der + Eintrag hier stehen. Der Knopf fuehrt dann auf eine + Fehlerseite -- und wer darauf tippt, haelt UNS fuer kaputt. + + Der Knopf verschwindet trotzdem nicht: Ein Eintrag, bei dem + wortlos etwas fehlt, wirft die Frage auf, ob man sich + taeuscht. Es steht jetzt einfach da. Das Cover bleibt + ohnehin -- es liegt bei uns, nicht bei TikTok. */ + if (e.quelle_fehlt_seit) { + const f = el('span', 'videozeile__weg videozeile__fort', + 'Bei TikTok nicht mehr da'); + f.title = 'Seit ' + (e.quelle_fehlt_seit || '').slice(0, 10) + + ' antwortet TikTok zu diesem Video nicht mehr.'; + z.append(f); + } else { + const a = el('a', 'schritt videozeile__weg', 'Auf TikTok ansehen →'); + a.href = e.quelle_url; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + z.append(a); + } k.append(z); } diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 9a9ecacd..a3d860e5 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - +
@@ -291,10 +291,10 @@ - - - - - + + + + +