19 changed files with 1059 additions and 881 deletions
+1
View File
@@ -23,6 +23,7 @@ web/intranet_repo/projects/contentpanel/insertion/news/*
# Generierte Dashboard-Terminbilder
web/intranet_repo/projects/dashboard/month/*
web/intranet_repo/projects/dashboard/cache/
# Lokale Backup-Dateien
*.bak
+2
View File
@@ -51,6 +51,7 @@ RUN mkdir -p \
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \
/var/www/html/intranet_repo/projects/developerpanel/work \
/var/www/html/intranet_repo/projects/seelsorge/data \
/var/www/html/intranet_repo/projects/dashboard/cache \
/var/www/html/intranet_repo/projects/dashboard/month \
&& chown -R www-data:www-data \
/data \
@@ -58,6 +59,7 @@ RUN mkdir -p \
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \
/var/www/html/intranet_repo/projects/developerpanel/work \
/var/www/html/intranet_repo/projects/seelsorge/data \
/var/www/html/intranet_repo/projects/dashboard/cache \
/var/www/html/intranet_repo/projects/dashboard/month \
&& chmod +x /opt/intranet/docker/scheduler.py /opt/intranet/worker/pdf_python/pdf_to_png.sh /opt/intranet/worker/contentpanel_aushang/process.py /opt/intranet/worker/contentpanel_aushang/betteroffice_render.py
@@ -9,7 +9,9 @@ if (!in_array($generalPermission, [1, '1', true, 't', 'true'], true)) {
exit();
}
contentpanel_cleanup_expired_news();
$today = date('Y-m-d');
contentpanel_cleanup_expired_news();
contentpanel_cleanup_expired_announcements($pdo, $today);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = trim((string) ($_POST['action'] ?? ''));
@@ -52,8 +54,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$flash = $_SESSION['contentpanel_flash'] ?? null;
unset($_SESSION['contentpanel_flash']);
$today = date('Y-m-d');
$services = contentpanel_fetch_services($pdo, $today);
$services = contentpanel_fetch_services($pdo, $today);
$announcements = contentpanel_fetch_announcements($pdo, $today);
$newsItems = contentpanel_list_news();
$pdo = null;
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
function contentpanel_cleanup_expired_announcements(PDO $pdo, string $today): void
{
// Das Enddatum ist einschließlich gültig; ohne Enddatum zählt das Startdatum.
// Datensätze ohne beide Datumswerte werden nicht als abgelaufen gelöscht.
$statement = $pdo->prepare(
'DELETE FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?'
);
$statement->execute([$today]);
}
@@ -2,6 +2,7 @@
require_once __DIR__ . '/news.php';
require_once __DIR__ . '/news_queue.php';
require_once __DIR__ . '/announcements.php';
function contentpanel_html($value): string
{
@@ -294,6 +295,6 @@ function contentpanel_fetch_announcements(PDO $pdo, string $today): array
return contentpanel_query($pdo,
'SELECT id, ansage_titel AS title, ansage_untertitel AS subtitle, ansage_datum_von AS date,
ansage_datum_bis AS end_date, ansage_uhrzeit AS time, ansage_ort AS location
FROM ansagen WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ? ORDER BY ansage_datum_von',
[$today, $today])->fetchAll(PDO::FETCH_ASSOC);
FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) >= ? ORDER BY ansage_datum_von',
[$today])->fetchAll(PDO::FETCH_ASSOC);
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
// Zentrale Anzeige- und Lesbarkeitswerte, ohne Build-Schritt.
return [
'POSTER_MIN_RENDER_HEIGHT_VH' => 39.61298125,
'POSTER_MIN_RENDER_HEIGHT_OFFSET_PX' => 2.85866875,
'POSTER_MAX_VISIBLE_PAGES' => 12,
'POSTER_MIN_DWELL_TIME_S' => 12,
'CONTENT_MIN_CONTRAST_RATIO' => 4.5,
// Ohne Aushänge gibt es keinen Durchlauf; dann weiterhin regelmäßig prüfen.
'EMPTY_REFRESH_MS' => 30000,
'REQUEST_TIMEOUT_MS' => 8000,
];
+524 -217
View File
@@ -1,236 +1,543 @@
(() => {
'use strict';
const CONTENT_REFRESH_MS = 30_000;
const INDOOR_REFRESH_MS = 10_000;
const OUTDOOR_REFRESH_MS = 30_000;
const VERTICAL_SPEED_PX_PER_SECOND = 7;
const HORIZONTAL_SPEED_PX_PER_SECOND = 10;
const config = JSON.parse(document.getElementById('dashboard-config').textContent);
const root = document.body;
const WEATHER_ICONS = {
cloudy: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><defs><filter id="weather-blur" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur in="SourceAlpha" stdDeviation="2.5"/><feOffset dx="0" dy="2" result="off"/><feComponentTransfer><feFuncA type="linear" slope="0.15"/></feComponentTransfer><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><g filter="url(#weather-blur)"><path d="M22 46h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 20 29a9 9 0 0 0 2 17z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M18 46h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 17 31a8 8 0 0 0 1 15z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></svg>`,
sun: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><circle cx="32" cy="32" r="10" fill="#FFD34D" stroke="#fff"/><path d="M32 6v8M32 50v8M6 32h8M50 32h8M13.5 13.5l5.7 5.7M44.8 44.8l5.7 5.7M50.5 13.5l-5.7 5.7M19.2 44.8l-5.7 5.7"/></g></svg>`,
moon: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><path d="M41 8a22 22 0 1 0 15 35A18 18 0 0 1 41 8z" fill="#DDE7FF" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></svg>`,
cloudy_day: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><g transform="translate(4,0)"><circle cx="22" cy="22" r="9" fill="#FFD34D" stroke="#fff" stroke-width="2"/><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M22 6v6M22 32v6M6 22h6M32 22h6M10.5 10.5l4.2 4.2M29.3 29.3l4.2 4.2M33.5 10.5l-4.2 4.2M14.7 29.3l-4.2 4.2"/></g></g><g transform="translate(8,10)"><path d="M18 36h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 16 19a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M22 36h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 21 21a8 8 0 0 0 1 15z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></g></svg>`,
cloudy_night: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M38 10a16 16 0 1 0 11 26A13 13 0 0 1 38 10z" fill="#DDE7FF" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g transform="translate(10,18)"><path d="M14 28h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 12 11a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M18 28h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 17 13a8 8 0 0 0 1 15z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></g></svg>`,
rain_shower: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 36h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 19a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M24 42l-3 6"/><path d="M34 42l-3 6"/><path d="M44 42l-3 6"/></g></g></svg>`,
rain: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 34h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 17a9 9 0 0 0 2 17z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M22 40l-3 8"/><path d="M32 40l-3 8"/><path d="M42 40l-3 8"/><path d="M52 40l-3 8"/></g></g></svg>`,
thunder: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M18 34h28a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 16 17a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M32 36l-6 12h7l-3 10 12-16h-7l3-6z" fill="#FFD34D" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></g></svg>`,
snowy: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 34h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 17a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g fill="#fff"><circle cx="24" cy="44" r="2"/><circle cx="34" cy="44" r="2"/><circle cx="44" cy="44" r="2"/><circle cx="29" cy="52" r="2"/><circle cx="39" cy="52" r="2"/></g></g></svg>`,
mist: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity="0.95"><path d="M14 24h36"/><path d="M10 32h44"/><path d="M14 40h36"/><path d="M18 48h28"/></g></svg>`,
};
function node(tag, className, text) {
const element = document.createElement(tag);
element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function setText(selector, value) {
const element = document.querySelector(selector);
if (element) {
element.textContent = value ?? '';
function empty(stage, text) {
stage.replaceChildren(node('p', 'empty-state', text));
}
// Timeout und HTTP-Prüfung gelten für Inhalte und Bilder gleichermaßen.
async function request(url, read, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
if (!response.ok) throw new Error('HTTP ' + response.status);
return await read(response);
} finally {
clearTimeout(timeout);
}
}
function updateClock() {
const imageSize = item => item.crop || item;
const aspectRatio = pages => pages.reduce((sum, item) =>
sum + imageSize(item).width / imageSize(item).height, 0);
const layoutKey = layout => [layout.width, layout.height, layout.groups.map(group => group.length)].join('/');
const dayFormatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit',
});
function berlinDay(date = new Date()) {
const parts = Object.fromEntries(dayFormatter.formatToParts(date).map(part => [part.type, part.value]));
return parts.year + '-' + parts.month + '-' + parts.day;
}
let currentDay = berlinDay();
function groupDocuments(pages) {
const entries = new Map();
for (const item of pages) {
const key = item.document ?? item.url;
if (!entries.has(key)) entries.set(key, []);
entries.get(key).push(item);
}
return [...entries.values()].map(pages => pages.sort((a, b) => a.page - b.page));
}
class ImageGallery {
constructor(id, message, rotate = false) {
Object.assign(this, {
message, rotate, items: [], current: [], images: new Map(),
index: 0, generation: 0, signature: '', layoutKey: '', failed: false, pending: false,
});
const panel = document.getElementById(id);
this.stage = panel.querySelector('.image-stage');
this.progress = panel.querySelector('.poster-progress');
new ResizeObserver(() => this.resize()).observe(this.stage);
}
layout() {
// Keine Ganzzahlrundung durch clientHeight: Original-Bildhöhe exakt erhalten.
const bounds = this.stage.getBoundingClientRect();
const style = getComputedStyle(this.stage);
const width = bounds.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
const height = bounds.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
const gap = parseFloat(style.columnGap);
const frame = this.rotate ? parseFloat(style.getPropertyValue('--poster-frame-width')) : 0;
if (!this.rotate) return { groups: [this.items], width, height, gap, frame };
const minimum = innerHeight * config.POSTER_MIN_RENDER_HEIGHT_VH / 100
- config.POSTER_MIN_RENDER_HEIGHT_OFFSET_PX;
const occupied = pages => minimum * aspectRatio(pages) + groupDocuments(pages).length * (2 * frame + gap);
const fits = pages => pages.length <= config.POSTER_MAX_VISIBLE_PAGES
&& occupied(pages) <= width + gap;
// Nur Aushänge aufteilen, die selbst nicht in eine Ansicht passen.
const chunks = [];
for (const pages of groupDocuments(this.items)) {
let chunk = [];
for (const item of pages) {
if (chunk.length && !fits([...chunk, item])) {
chunks.push(chunk);
chunk = [];
}
chunk.push(item);
}
if (chunk.length) chunks.push(chunk);
}
// Große Dokumente zuerst, kleinere in die Lücken: Datum ist nachrangig.
chunks.sort((a, b) => occupied(b) - occupied(a));
const groups = [];
for (const chunk of chunks) {
const group = groups.find(group => fits([...group, ...chunk]));
if (group) group.push(...chunk);
else groups.push([...chunk]);
}
// Zuerst vollständige Dokumente aus der vorigen Ansicht übernehmen,
// verbleibende Plätze nötigenfalls mit einzelnen Seiten auffüllen.
const primaryGroups = groups.map(group => [...group]);
for (let index = 0; groups.length > 1 && index < groups.length; index++) {
const current = groups[index];
const previous = primaryGroups[(index + groups.length - 1) % groups.length];
const available = previous.filter(item =>
!current.some(entry => entry.url === item.url));
for (const pages of groupDocuments(available).reverse()) {
if (fits([...current, ...pages])) current.unshift(...pages);
}
for (const item of [...available].reverse()) {
if (!current.some(entry => entry.url === item.url)
&& fits([...current, item])) current.unshift(item);
}
groups[index] = groupDocuments(current).flat();
}
return { groups, width, height, gap, frame };
}
loadImage(item) {
if (!this.images.has(item.url)) {
let localUrl;
this.images.set(item.url, request(item.url, async response => {
localUrl = URL.createObjectURL(await response.blob());
const image = new Image();
image.alt = item.alt;
image.dataset.source = item.url;
image.src = localUrl;
await image.decode();
if (!this.rotate) {
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
let left = canvas.width, top = canvas.height, right = 0, bottom = 0;
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) {
if (!pixels[(y * canvas.width + x) * 4 + 3]) continue;
left = Math.min(left, x); top = Math.min(top, y);
right = Math.max(right, x + 1); bottom = Math.max(bottom, y + 1);
}
if (right) Object.defineProperty(item, 'crop', {
value: { left, top, width: right - left, height: bottom - top }, configurable: true,
});
}
return image;
}).catch(() => {
if (localUrl) URL.revokeObjectURL(localUrl);
this.images.delete(item.url);
return null;
}));
}
return this.images.get(item.url);
}
pruneImages() {
for (const [url, pending] of this.images) {
if (this.items.some(item => item.url === url) || this.current.some(item => item.url === url)) continue;
this.images.delete(url);
pending.then(image => { if (image) URL.revokeObjectURL(image.src); });
}
}
update(items, message = this.message) {
const signature = JSON.stringify([items, message]);
if (signature === this.signature && !this.failed) return;
this.signature = signature;
this.message = message;
this.items = items;
// Nur neue Bildversionen laden; unveränderte Blob-URLs wiederverwenden.
const loading = items.map(item => this.loadImage(item));
if (!items.length) {
this.generation++;
clearTimeout(this.timer);
this.current = [];
this.pending = false;
empty(this.stage, message);
this.pruneImages();
this.schedule();
return;
}
// Nur abgelaufene Seiten sofort entfernen; sonst bleiben vorhandene
// Bilder bis zum vollständig geladenen Ersatz sichtbar.
if (this.rotate && this.current.some(item => item.expires < currentDay)) {
this.current = [];
empty(this.stage, this.message);
}
if (this.rotate && this.current.length) {
this.pending = true;
return;
}
return this.rotate ? this.show(0) : Promise.all(loading).then(() => this.show(0));
}
resize() {
if (this.items.length && layoutKey(this.layout()) !== this.layoutKey) this.show(this.index);
}
schedule() {
clearTimeout(this.timer);
if (!this.rotate) return;
const duration = this.items.length ? config.POSTER_MIN_DWELL_TIME_S * 1000 : config.EMPTY_REFRESH_MS;
const changing = this.items.length && this.layout().groups.length > 1;
this.progressAnimation?.cancel();
this.progress.toggleAttribute('hidden', !changing);
if (changing) {
this.progressAnimation = this.progress.querySelector('.poster-progress-value').animate(
[{ strokeDashoffset: 100 }, { strokeDashoffset: 0 }],
{ duration, easing: 'linear', fill: 'forwards' });
}
this.timer = setTimeout(async () => {
if (this.items.length && !this.pending && this.index + 1 < this.layout().groups.length) {
return this.show(this.index + 1);
}
// Texte und Bildbestand nach einem vollständigen Durchlauf prüfen.
await this.onCycle?.();
if (this.items.length && (this.pending || this.failed || this.index !== 0)) this.show(0);
else this.schedule();
}, duration);
}
async show(index) {
if (!this.items.length) return;
this.pending = false;
const layout = this.layout();
this.layoutKey = layoutKey(layout);
this.index = index % layout.groups.length;
const batch = layout.groups[this.index];
const documents = this.rotate ? groupDocuments(batch) : [batch];
const renderHeight = Math.min(layout.height - 2 * layout.frame,
(layout.width - (documents.length - 1) * layout.gap - documents.length * 2 * layout.frame) / aspectRatio(batch));
const monthScale = this.rotate ? 0 : Math.min(layout.height / Math.max(...batch.map(item => imageSize(item).height)),
(layout.width - (batch.length - 1) * layout.gap) / batch.reduce((sum, item) => sum + imageSize(item).width, 0));
const generation = ++this.generation;
clearTimeout(this.timer);
const sheet = node('div', 'image-sheet');
let failures = 0;
// Gemeinsam gerahmte Seiten laden unabhängig; erst die fertige Ansicht einblenden.
await Promise.all(documents.flatMap(pages => {
const wrapper = this.rotate ? node('div', 'poster-document') : sheet;
if (this.rotate) sheet.append(wrapper);
return pages.map(async item => {
const cell = node('div', 'image-cell');
const size = imageSize(item);
cell.style.height = ((this.rotate ? renderHeight : size.height * monthScale)) + 'px';
cell.style.width = ((this.rotate ? renderHeight * item.width / item.height : size.width * monthScale)) + 'px';
wrapper.append(cell);
const copy = (await this.loadImage(item))?.cloneNode();
if (copy) {
try {
await copy.decode();
if (item.crop) {
cell.classList.add('is-cropped');
Object.assign(copy.style, {
width: item.width / size.width * 100 + '%',
height: item.height / size.height * 100 + '%',
left: -size.left / size.width * 100 + '%',
top: -size.top / size.height * 100 + '%',
});
}
cell.append(copy);
return;
} catch { /* Die übrigen Seiten bleiben unabhängig darstellbar. */ }
}
failures++;
empty(cell, 'Seite zurzeit nicht verfügbar.');
});
}));
if (generation !== this.generation) return;
if (!batch.every(item => this.items.some(entry => entry.url === item.url))) return this.show(0);
this.failed = failures > 0;
// Einen vorhandenen Monatsplan bei defektem Ersatz nicht wegblenden.
if (!this.rotate && failures && this.stage.querySelector('img')) return;
this.current = batch;
this.stage.replaceChildren(sheet);
this.pruneImages();
if (failures === batch.length) empty(this.stage, 'Bilder zurzeit nicht verfügbar.');
this.schedule(); // Mindeststandzeit erst nach vollständigem Laden der Gruppe.
}
}
// Eine Textseite enthält ausschließlich DOM-Text, niemals HTML aus der Datenbank.
function paragraphs(className, entries) {
const container = node('div', className);
for (const [name, text] of entries) if (text) container.append(node('p', name, text));
return container;
}
function announcementList(items) {
const list = node('ul', 'text-page announcement-list');
for (const item of items) {
const entry = node('li', 'announcement-entry');
entry.append(paragraphs('announcement-when', [
['announcement-date', item.date_label || 'Ohne Datum'],
['announcement-end', item.end_label],
]), paragraphs('announcement-content', [
['announcement-title', item.title],
['announcement-subtitle', item.subtitle],
['announcement-meta', [item.time ? item.time + ' Uhr' : '', item.place].filter(Boolean).join(', ')],
]));
list.append(entry);
}
return list;
}
class FixedText {
constructor(id, render = entries => paragraphs('text-page', entries)) {
this.stage = document.getElementById(id).querySelector('.text-stage');
this.render = render;
this.signature = '';
this.loaded = false;
new ResizeObserver(() => this.rebuild()).observe(this.stage);
}
update(documents, message) {
this.loaded = true;
const signature = JSON.stringify([documents, message]);
if (signature === this.signature) return;
this.signature = signature;
if (documents.length) this.stage.replaceChildren(this.render(documents));
else empty(this.stage, message);
this.rebuild();
}
unavailable(message) {
// Auch ein erfolgreich gelesener Leerzustand bleibt bei Abruffehlern stehen.
if (this.loaded) return;
empty(this.stage, message);
this.rebuild();
}
rebuild() {
const page = this.stage.querySelector('.text-page');
this.stage.classList.toggle('is-overflowing', Boolean(page && this.stage.clientHeight
&& page.getBoundingClientRect().height > this.stage.clientHeight + 1));
}
}
const month = new ImageGallery('month-panel', 'Kein Monatsplan vorhanden.');
const posters = new ImageGallery('poster-panel', 'Keine aktuellen Aushänge.', true);
const announcements = new FixedText('announcements-panel', announcementList);
const bible = new FixedText('bible-panel');
let imageData = null;
function layoutImages() {
if (!imageData) return;
// Monatsplan dauerhaft separat: keine Vermischung mit der Aushangrotation.
const months = imageData.month_images;
if (months.length) root.style.setProperty('--month-aspect',
months.reduce((sum, item) => sum + item.width / item.height, 0));
return Promise.all([
month.update(months),
posters.update(imageData.posters.filter(item => item.expires >= currentDay)),
]);
}
function contentLoaded(data) {
if (data.date !== currentDay) return contentUnavailable();
if (data.errors.includes('announcements')) announcements.unavailable('Ansagen zurzeit nicht verfügbar.');
else announcements.update(data.announcements, 'Es sind keine Ansagen eingetragen.');
const service = data.service;
const blocks = service ? [
['bible-reference', service.reference || 'Bibelstelle nicht eingetragen.'],
['bible-verse', service.text || 'Bibelvers nicht eingetragen.'],
] : [];
if (data.errors.includes('service')) bible.unavailable('Bibelwort zurzeit nicht verfügbar.');
else bible.update(blocks, 'Für heute ist kein Bibelwort eingetragen.');
}
function contentUnavailable() {
announcements.unavailable('Ansagen zurzeit nicht verfügbar.');
bible.unavailable('Bibelwort zurzeit nicht verfügbar.');
}
function imagesUnavailable() {
if (!imageData) {
month.update([], 'Monatsplan zurzeit nicht verfügbar.');
posters.update([], 'Aushänge zurzeit nicht verfügbar.');
}
}
// Unabhängige Hintergrundabrufe, ohne Ladezustand und ohne doppelte Anfragen.
function createRefresh(resource, onData, onError) {
let inFlight = null;
return () => inFlight ??= request('data.php?resource=' + resource,
async response => onData(await response.json()), { cache: 'no-store' })
.catch(onError).finally(() => { inFlight = null; });
}
const timeFormat = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit',
});
const dateFormat = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin', day: '2-digit', month: 'long', year: 'numeric',
});
const clock = document.getElementById('clock');
const date = document.getElementById('date');
function fitClock() {
const width = clock.getBoundingClientRect().width;
const target = date.getBoundingClientRect().width;
if (width && Math.abs(width - target) > 1.5) {
clock.style.fontSize = parseFloat(getComputedStyle(clock).fontSize) * target / width + 'px';
}
}
new ResizeObserver(fitClock).observe(date);
const departures = new FixedText('departures-panel', rows => {
const list = node('ul', 'text-page departures-list');
for (const item of rows) {
const row = node('li', 'departure-row is-' + item.state);
row.title = item.detail;
row.append(node('span', 'departure-line', item.line), node('span', 'departure-direction', item.direction),
node('span', 'departure-remaining', item.remaining), node('span', 'departure-status', item.status));
list.append(row);
}
return list;
});
const departureData = { departures: [], updatedAt: 0 };
let nextDepartureRequest = 0;
function renderDepartures(now) {
const age = now - departureData.updatedAt;
// Der globale Feed-Zeitstempel kann älter sein; entscheidend sind die
// Prognosen der einzelnen Fahrten und das Alter unseres letzten Abrufs.
const fresh = age < 300000;
const rows = (age < 7200000 ? departureData.departures : [])
.map(d => ({ ...d, timestamp: Date.parse(d.when || d.plannedWhen) }))
.filter(d => Number.isFinite(d.timestamp) && d.timestamp >= now)
.sort((a, b) => a.timestamp - b.timestamp).slice(0, 5).map(d => {
const live = fresh && Number.isFinite(d.delay) && Boolean(d.when);
const state = !fresh ? 'stale' : d.cancelled ? 'cancelled' : !live ? 'scheduled'
: d.delay > 0 ? 'delayed' : d.delay < 0 ? 'early' : 'ontime';
const status = { stale: 'veraltet', cancelled: 'entfällt', scheduled: 'pünktlich', ontime: 'pünktlich',
delayed: '+' + Math.ceil(d.delay / 60) + ' Min.', early: '−' + Math.ceil(-d.delay / 60) + ' Min.' }[state];
const minutes = Math.ceil((d.timestamp - now) / 60000);
const planned = Date.parse(d.plannedWhen);
return { state, status, line: d.line?.name || 'Bus', direction: d.direction || 'Richtung unbekannt',
remaining: d.cancelled ? '' : minutes ? minutes + ' Min' : 'jetzt',
detail: (Number.isFinite(planned) ? 'Geplant ' + timeFormat.format(planned) + ' · ' : '')
+ (d.direction || '') + ' · ' + status };
});
departures.update(rows, fresh ? 'Keine Busabfahrten in den nächsten 120 Minuten.' : 'Live-Abfahrten derzeit nicht verfügbar.');
}
const refreshDepartures = createRefresh('departures_eich', data => {
if (!Array.isArray(data.departures) || !Number.isFinite(data.updatedAt)) throw new Error('Ungültige BVG-Antwort');
Object.assign(departureData, data);
nextDepartureRequest = Number(data.retryAt) * 1000 || 0;
renderDepartures(Date.now());
}, () => {
departureData.unavailable = true;
nextDepartureRequest = Date.now() + 30000;
renderDepartures(Date.now());
});
function tick() {
const now = new Date();
const clock = document.getElementById('uhrzeit');
if (clock) {
clock.textContent = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const nextDay = berlinDay(now);
clock.textContent = timeFormat.format(now);
clock.dateTime = now.toISOString();
date.textContent = dateFormat.format(now);
date.dateTime = nextDay;
fitClock();
renderDepartures(now.getTime());
if (!document.hidden && now.getTime() >= nextDepartureRequest) {
nextDepartureRequest = now.getTime() + 1000;
refreshDepartures();
}
if (nextDay !== currentDay) {
currentDay = nextDay;
// Texte erst mit einer erfolgreichen Antwort austauschen; abgelaufene
// Aushänge weiterhin sofort entfernen.
layoutImages();
refreshAll();
}
}
function renderAnnouncements(announcements) {
const container = document.querySelector('.news-scroll');
if (!container || !Array.isArray(announcements)) {
return;
}
container.replaceChildren();
announcements.forEach((announcement) => {
const box = document.createElement('div');
box.className = 'news-box';
const date = document.createElement('div');
date.className = 'news-date';
date.append(document.createTextNode(announcement.date_label || ''));
if (announcement.end_label) {
date.append(document.createElement('br'), document.createTextNode(announcement.end_label));
}
const body = document.createElement('div');
body.append(document.createTextNode(announcement.title || ''));
if (announcement.subtitle) {
const subtitle = document.createElement('div');
subtitle.className = 'news-subtitle';
subtitle.textContent = announcement.subtitle;
body.append(subtitle);
}
if (announcement.meta) {
const meta = document.createElement('div');
meta.className = 'news-subtitle';
const italic = document.createElement('i');
italic.textContent = announcement.meta;
meta.append(italic);
body.append(meta);
}
box.append(date, body);
container.append(box);
const refreshContent = createRefresh('content', contentLoaded, contentUnavailable);
const refreshImages = createRefresh('images', data => {
imageData = data;
return layoutImages();
}, imagesUnavailable);
const temperatureFormat = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 1 });
const refreshTemperatures = [
['indoor', data => [data.indoor.value, data.indoor.unit]],
['outdoor', data => [data.outdoor.temperature, '°C']],
].map(([id, read]) => {
const output = document.getElementById(id);
return createRefresh(id, data => {
const [value, unit] = read(data);
if (!Number.isFinite(value)) throw new Error('Ungültige Temperatur');
const text = temperatureFormat.format(value) + ' ' + unit;
if (output.textContent !== text) output.textContent = text;
output.classList.remove('is-unavailable');
}, () => {
if (output.classList.contains('is-unavailable')) output.textContent = 'Nicht verfügbar';
});
});
function refreshAll() {
// Langsame Temperatursensoren dürfen den Aushangswechsel nicht verzögern.
refreshTemperatures.forEach(refresh => refresh());
return Promise.all([refreshContent(), refreshImages()]);
}
function renderPosters(posters) {
const container = document.querySelector('.aushang-flex');
if (!container || !Array.isArray(posters)) {
return;
posters.onCycle = refreshAll;
refreshAll();
tick();
setInterval(tick, 1000);
document.fonts.ready.then(() => {
fitClock();
announcements.rebuild();
bible.rebuild();
// Ein Mindestkontrast wird auch bei später geänderten zentralen Tokens geprüft.
const style = getComputedStyle(root);
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const context = canvas.getContext('2d', { willReadFrequently: true });
function luminance(value) {
context.fillStyle = value;
context.fillRect(0, 0, 1, 1);
const channels = Array.from(context.getImageData(0, 0, 1, 1).data).slice(0, 3);
return channels.map(channel => {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}).reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
}
container.replaceChildren();
if (posters.length === 0) {
const empty = document.createElement('p');
empty.style.textAlign = 'left';
empty.textContent = 'Keine Aushänge vorgeplant.';
container.append(empty);
return;
const background = Math.min(...Array.from(document.querySelectorAll(
'.dashboard-panel, .dashboard-overview, .panel-heading, .announcement-entry'),
panel => luminance(getComputedStyle(panel).backgroundColor)));
for (const [property, token] of [['--dashboard-text', '--nak-black'], ['--dashboard-meta', '--gray-700']]) {
const contrast = (background + 0.05) / (luminance(style.getPropertyValue(token)) + 0.05);
if (contrast < config.CONTENT_MIN_CONTRAST_RATIO) root.style.setProperty(property, 'var(--nak-black)');
}
});
posters.forEach((poster) => {
const image = document.createElement('img');
image.src = poster.url;
image.alt = poster.alt || 'Aushang';
image.style.height = '95%';
image.addEventListener('load', () => scheduleScrollingSetup(0), { once: true });
container.append(image);
});
}
function renderMonthImages(images) {
if (!Array.isArray(images)) {
return;
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
tick();
// Nach Rückkehr bekommt die sichtbare Seite erneut ihre volle Lesezeit.
posters.schedule();
}
images.forEach((image) => {
const element = document.querySelector(`[data-month-page="${image.page}"]`);
if (element && image.url && element.getAttribute('src') !== image.url) {
element.src = image.url;
}
});
}
function renderIndoor(indoor) {
if (indoor) {
setText('#temperature_txt', `${indoor.value}${indoor.unit || '°C'}`);
}
}
function renderOutdoor(outdoor) {
const weather = document.getElementById('wetter');
if (!weather || !outdoor) {
return;
}
const icon = WEATHER_ICONS[outdoor.icon] || WEATHER_ICONS.cloudy;
weather.replaceChildren();
const iconContainer = document.createElement('div');
iconContainer.innerHTML = icon;
const temperature = document.createElement('div');
temperature.className = 'text-outdoor';
temperature.textContent = `${outdoor.temperature}°C`;
weather.append(iconContainer, temperature);
}
function renderContent(data) {
setText('[data-dashboard-date]', data.date_label);
if (data.service) {
setText('.bible', data.service.bibelwort);
setText('.verse', data.service.bibelvers);
setText('.GB', data.service.eingang_nummer);
setText('.GB-title', data.service.eingang_titel);
}
renderAnnouncements(data.announcements);
renderPosters(data.posters);
renderMonthImages(data.month_images);
scheduleScrollingSetup();
}
function startPolling(resource, interval, render) {
const refresh = async () => {
try {
const response = await fetch(`data.php?resource=${encodeURIComponent(resource)}`, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
render(data);
} catch (error) {
console.error(`Dashboard-Aktualisierung (${resource}) fehlgeschlagen:`, error);
} finally {
window.setTimeout(refresh, interval);
}
};
refresh();
}
function applyAnimation(element, distance, speed, animationName) {
element.style.animation = 'none';
if (distance <= 0) {
return;
}
const variable = animationName === 'scroll-h' ? '--scroll-distance-h' : '--scroll-distance-v';
element.style.setProperty(variable, `-${distance}px`);
element.style.animationName = animationName;
element.style.animationDuration = `${Math.max(1, distance / speed)}s`;
element.style.animationTimingFunction = 'linear';
element.style.animationIterationCount = 'infinite';
}
function setupScrolling() {
const posterContainer = document.querySelector('.aushang-flex');
if (posterContainer) {
const images = Array.from(posterContainer.querySelectorAll('img'));
const totalWidth = images.reduce((sum, image) => {
const styles = window.getComputedStyle(image);
return sum + image.offsetWidth + (parseFloat(styles.marginRight) || 0);
}, 0);
applyAnimation(
posterContainer,
Math.max(0, totalWidth - posterContainer.offsetWidth),
HORIZONTAL_SPEED_PX_PER_SECOND,
'scroll-h'
);
}
const newsContainer = document.querySelector('.news-container');
const news = document.querySelector('.news-scroll');
if (newsContainer && news) {
applyAnimation(
news,
Math.max(0, news.scrollHeight - newsContainer.offsetHeight),
VERTICAL_SPEED_PX_PER_SECOND,
'scroll-v'
);
}
}
let scrollingTimer = null;
function scheduleScrollingSetup(delay = 80) {
if (scrollingTimer !== null) {
window.clearTimeout(scrollingTimer);
}
scrollingTimer = window.setTimeout(setupScrolling, delay);
}
updateClock();
window.setInterval(updateClock, 1000);
window.addEventListener('load', () => scheduleScrollingSetup(50));
window.addEventListener('resize', () => scheduleScrollingSetup(100));
startPolling('content', CONTENT_REFRESH_MS, renderContent);
startPolling('indoor', INDOOR_REFRESH_MS, (data) => renderIndoor(data.indoor));
startPolling('outdoor', OUTDOOR_REFRESH_MS, (data) => renderOutdoor(data.outdoor));
});
})();
@@ -2,139 +2,70 @@
declare(strict_types=1);
date_default_timezone_set('Europe/Berlin');
require_once __DIR__ . '/util/dashboard_helpers.php';
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
$content = dashboard_fetch_content($pdo, dashboard_now());
$pdo = null;
$service = $content['service'];
$announcements = $content['announcements'];
$posters = $content['posters'];
$monthImages = $content['month_images'];
$stylesheetVersion = (int) filemtime(__DIR__ . '/stylo.css');
$scriptVersion = (int) filemtime(__DIR__ . '/dashboard.js');
// Die Anzeigefläche muss auch ohne Datenbank oder externe APIs starten können.
$config = require __DIR__ . '/config.php';
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NAK Reinickendorf</title>
<link rel="icon" type="image/svg+xml" href="/intranet_repo/projects/_central/img/nak-logo.svg">
<link rel="shortcut icon" href="/intranet_repo/projects/_central/img/favicon.ico">
<link rel="stylesheet" href="stylo.css?v=<?= $stylesheetVersion ?>">
<link href="https://fonts.googleapis.com/css2?family=SUSE:wght@400;700&display=swap" rel="stylesheet">
<script src="dashboard.js?v=<?= $scriptVersion ?>" defer></script>
<meta name="description" content="Aktuelle Informationen der Neuapostolischen Kirche Berlin-Reinickendorf.">
<title>Gemeindeinformationen · NAK Berlin-Reinickendorf</title>
<link rel="icon" type="image/svg+xml" href="../_central/img/nak-logo.svg">
<link rel="stylesheet" href="../_central/layouts/nak-design.css">
<link rel="stylesheet" href="stylo.css?v=<?= (int) filemtime(__DIR__ . '/stylo.css') ?>">
<script id="dashboard-config" type="application/json"><?= json_encode($config, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?></script>
<script src="dashboard.js?v=<?= (int) filemtime(__DIR__ . '/dashboard.js') ?>" defer></script>
</head>
<body>
<div class="content-board">
<div class="content-column-right">
<div class="content-column-right-upper-box">
<div class="content-column-right-upper-box-right">
<div class="content-item-time">
<div class="time"><div id="uhrzeit"></div></div>
<div class="date" style="text-align: center" data-dashboard-date><?= dashboard_html($content['date_label']) ?></div>
</div>
<div class="content-item-daily">
<div class="header-sticky-note"><div>Heutige Ansagen</div></div>
<div class="news-container">
<div class="news-scroll">
<?php foreach ($announcements as $announcement): ?>
<div class="news-box">
<div class="news-date">
<?= dashboard_html($announcement['date_label']) ?>
<?php if ($announcement['end_label'] !== ''): ?>
<br><?= dashboard_html($announcement['end_label']) ?>
<?php endif; ?>
</div>
<div>
<?= dashboard_html($announcement['title']) ?>
<?php if ($announcement['subtitle'] !== ''): ?>
<div class="news-subtitle"><?= dashboard_html($announcement['subtitle']) ?></div>
<?php endif; ?>
<?php if ($announcement['meta'] !== ''): ?>
<div class="news-subtitle"><i><?= dashboard_html($announcement['meta']) ?></i></div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<body class="dashboard">
<main class="dashboard-main" aria-label="Gemeindeinformationen">
<div class="dashboard-column announcements-column">
<section class="dashboard-overview" aria-label="Datum, Uhrzeit und Temperaturen">
<div class="dashboard-clock">
<time id="clock" aria-label="Uhrzeit Europe/Berlin">–:–</time>
<time id="date">Datum wird geladen</time>
</div>
<div class="content-column-right-upper-box-left">
<div class="content-item-middle-top">
<div class="header-sticky-note"><div>Bibelwort</div></div>
<div class="bible"><?= dashboard_html($service['bibelwort']) ?></div>
<div class="verse"><?= dashboard_html($service['bibelvers']) ?></div>
</div>
<div class="content-column-double-item">
<div class="content-item">
<div class="header-sticky-note"><div>Eingangslied</div></div>
<div class="GB"><?= dashboard_html($service['eingang_nummer']) ?></div>
<div class="GB-title"><?= dashboard_html($service['eingang_titel']) ?></div>
</div>
<div class="content-item">
<div class="header-sticky-note"><div>Temperatur</div></div>
<div class="temperature">
<div class="container"><div id="wetter"></div></div>
<div class="container">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 60 60" width="64px" height="64px" baseProfile="basic" aria-hidden="true">
<polygon fill="#66e1ff" points="46,22.11 46,24.42 43,26.15 43,34.24 25,44.63 14,38.28 14,35.97 12,34.81 10,35.97 7,34.24 7,20.38 4,20.96 4,18.65 22,8.26 34,5.95"/>
<polygon fill="#4292a6" points="25,30.774 28,34.815 43,26.155 43,34.238 25,44.63"/>
<polygon fill="#ff5757" points="16,18.649 4,20.959 4,18.649 16,16.34 28,32.506 28,34.815"/>
<polygon fill="#4292a6" points="34,8.257 22,10.566 22,8.257 34,5.947 46,22.113 46,24.423"/>
<polygon fill="#e64e4e" points="4,18.649 22,8.257 34,5.947 16,16.34"/>
<polygon fill="#cc4545" points="28,32.506 46,22.113 34,5.947 16,16.34"/>
<polygon fill="#b33d3d" points="28,32.506 46,22.113 46,24.423 28,34.815"/>
<polygon fill="#4292a6" points="17,34.238 22,37.124 22,31.351 17,28.464"/>
<polygon fill="#4292a6" points="10,35.97 12,34.815 12,27.887 10,26.732"/>
<polygon fill="#4ad8ff" points="19,33.083 22,34.815 22,31.351 19,29.619"/>
<polygon fill="#52b1cc" points="17,34.238 22,37.124 22,34.815 19,33.083"/>
<polygon fill="#ff5757" points="12,34.815 14,35.97 14,29.041 12,27.887"/>
</svg>
<div class="text-indoor" id="temperature_txt"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="content-item-bottom-box">
<div class="header-sticky-note"><div>Aushänge</div></div>
<div class="aushang-flex">
<?php if ($posters === []): ?>
<p style="text-align: left">Keine Aushänge vorgeplant.</p>
<?php else: ?>
<?php foreach ($posters as $poster): ?>
<img src="<?= dashboard_html($poster['url']) ?>" alt="<?= dashboard_html($poster['alt']) ?>" height="95%">
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<dl class="temperatures">
<div><dt>Innen</dt><dd id="indoor" class="is-unavailable">Wird geladen …</dd></div>
<div><dt>Außen</dt><dd id="outdoor" class="is-unavailable">Wird geladen …</dd></div>
</dl>
</section>
<section class="dashboard-panel text-panel" id="announcements-panel" aria-labelledby="announcements-title">
<div class="panel-heading"><h1 id="announcements-title">Ansagen</h1></div>
<div class="text-stage"><p class="empty-state">Ansagen werden geladen …</p></div>
</section>
</div>
<div class="content-column-left">
<div class="content-item-video">
<div class="video-container" aria-label="Leer"></div>
</div>
<div class="content-item-month">
<div class="header-sticky-note"><div>Monatsplan</div></div>
<div class="content-item-month-cell">
<?php foreach ($monthImages as $image): ?>
<img src="<?= dashboard_html($image['url']) ?>" data-month-page="<?= (int) $image['page'] ?>" alt="Monatsplan Seite <?= (int) $image['page'] ?>">
<?php endforeach; ?>
<div class="dashboard-column bible-column">
<section class="dashboard-panel text-panel bible-panel" id="bible-panel" aria-labelledby="bible-title">
<div class="panel-heading"><h2 id="bible-title">Bibelwort für heute</h2></div>
<div class="text-stage"><p class="empty-state">Bibelwort wird geladen …</p></div>
</section>
<section class="dashboard-panel text-panel" id="departures-panel" aria-labelledby="departures-title">
<div class="panel-heading">
<h2 id="departures-title">Abfahrten: Eichborndamm/Antonienstr.</h2>
</div>
</div>
<div class="text-stage"><p class="empty-state">Abfahrten werden geladen …</p></div>
</section>
</div>
</div>
<section class="dashboard-panel image-panel" id="month-panel" aria-labelledby="month-title">
<div class="panel-heading"><h2 id="month-title">Monatsplan</h2></div>
<div class="image-stage"><p class="empty-state">Monatsplan wird geladen …</p></div>
</section>
<section class="dashboard-panel image-panel" id="poster-panel" aria-labelledby="poster-title">
<div class="panel-heading">
<h2 id="poster-title">Aushänge</h2>
<svg class="poster-progress" viewBox="0 0 24 24" aria-hidden="true" hidden>
<circle class="poster-progress-track" cx="12" cy="12" r="10" />
<circle class="poster-progress-value" cx="12" cy="12" r="10" pathLength="100" />
</svg>
</div>
<div class="image-stage"><p class="empty-state">Aushänge werden geladen …</p></div>
</section>
</main>
<noscript>Bitte JavaScript aktivieren, damit Uhrzeit, Inhalte und Seitenwechsel angezeigt werden.</noscript>
</body>
</html>
+10 -3
View File
@@ -9,7 +9,6 @@ date_default_timezone_set('Europe/Berlin');
require_once __DIR__ . '/util/dashboard_helpers.php';
$resource = (string) ($_GET['resource'] ?? 'content');
$pdo = null;
try {
switch ($resource) {
@@ -17,12 +16,22 @@ try {
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
$payload = dashboard_fetch_content($pdo, dashboard_now());
break;
case 'images':
$payload = [
'posters' => dashboard_list_posters(dashboard_now()),
'month_images' => dashboard_month_images(),
];
break;
case 'indoor':
$payload = ['indoor' => dashboard_fetch_indoor_temperature()];
break;
case 'outdoor':
$payload = ['outdoor' => dashboard_fetch_outdoor_weather()];
break;
case 'departures_eich':
require_once __DIR__ . '/util/bvg.php';
$payload = dashboard_fetch_departures();
break;
default:
http_response_code(400);
$payload = ['error' => 'Unbekannte Dashboard-Ressource.'];
@@ -33,6 +42,4 @@ try {
http_response_code(502);
error_log('Dashboard data endpoint (' . $resource . '): ' . $exception->getMessage());
echo json_encode(['error' => 'Dashboard-Daten konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE);
} finally {
$pdo = null;
}
+125 -340
View File
@@ -1,340 +1,125 @@
/* Gesamtes Seiten-Layout */
body {
margin: 0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-family: 'SUSE', sans-serif;
font-size: 20px;
background-image: url('cloudy.jpg');
background-size: cover;
background-repeat: no-repeat;
background-position: center center;
overflow: hidden;
}
/* Content Board Layout */
.content-board {
background-color: #a8a6a7a7;
width: 99%;
height: 97%;
box-sizing: border-box;
display: flex;
justify-content: space-around;
align-items: center;
border-radius: 0;
padding: 0 6px 7px 3px;
}
.content-column-left {
width: 40%;
height: 100%;
box-sizing: border-box;
}
.content-column-right {
width: 58%;
height: 100%;
box-sizing: border-box;
}
.temperature {
display: flex;
justify-content: space-around;
align-items: center;
padding: 5px 0 0 0;
height: 70%;
}
.verse {
text-align: justify;
color: rgb(67, 67, 67);
padding: 5px 15px 5px 15px;
}
.date {
color: rgb(67, 67, 67);
font-weight: bold;
}
.content-column-right-upper-box-left {
width: 60%;
height: 100%;
box-sizing: border-box;
}
.content-column-right-upper-box-right {
width: 39%;
height: 100%;
box-sizing: border-box;
}
.content-column-right-upper-box {
width: 100%;
height: 51%;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
}
.content-item-time {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #ffffffab;
width: 100%;
height: 30%;
margin: 15px 0 15px 0;
box-sizing: border-box;
}
.content-item-news {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #ffffffab;
width: 100%;
height: 63%;
margin: 15px 0 15px 0;
box-sizing: border-box;
}
.content-item-video {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #cbc5bc;
width: 100%;
height: 40%;
margin: 15px 0 15px 0;
box-sizing: border-box;
}
.content-item-middle-top {
position: relative;
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #cbc5bc;
width: 100%;
height: 60%;
margin: 15px 0 15px 0;
box-sizing: border-box;
overflow: hidden;
}
/* Pseudoelement für den animierten border-bottom Effekt */
.content-item-middle-top::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 15px;
background: linear-gradient(to bottom, transparent, #cbc5bc);
}
/* Monatsplan: jetzt mit weißem Hintergrund */
.content-item-month {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #cbc5bc7d;
width: 100%;
height: 56%;
margin: 15px 0 15px 0;
box-sizing: border-box;
overflow: hidden;
}
.content-item-month-cell {
display: flex;
background-color: #ffffffe5;
justify-content: center; /* Zentriere die Bilder horizontal */
height: 94%;
align-items: center; /* Zentriere die Bilder vertikal */
}
.content-item-month-cell img {
max-width: 100%; /* Verhindert, dass das Bild größer als der Container wird */
max-height: 100%; /* Verhindert Verzerrungen, indem es die maximale Höhe auf die Containerhöhe beschränkt */
object-fit: contain; /* Stellt sicher, dass das Bild im Container bleibt, ohne gestreckt zu werden */
}
.content-item-daily {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #edebe8;
width: 100%;
height: 63%;
margin: 15px 0 15px 0;
box-sizing: border-box;
overflow: hidden;
}
.header-sticky-note {
font-size: 25px;
display: flex;
flex-direction: column;
align-items: center;
height: 20px;
width: 100%;
padding: 5px 0 15px 0;
border-bottom: 2px solid #38312430;
background-color: #cbc5bc;
border-radius: 0;
}
.time {
font-size: 80px;
text-align: center;
padding: 8px 0 0 0;
}
.bible {
text-align: center;
font-size: 60px;
padding: 10px 0 10px 0;
border-bottom: 2px solid #38312456;
background-color: #edebe8;
}
.GB {
text-align: center;
font-size: 60px;
padding: 5px 0 0 0;
}
.GB-title {
text-align: center;
color: rgb(67, 67, 67);
}
.content-column-double-item {
width: 100%;
height: 33%;
margin: 15px 0 15px 0;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Elemente innerhalb des Content Boards */
.content-item {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #edebe8;
height: 100%;
width: 49%;
}
.content-item-bottom-box {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #edebe8;
margin: 6px 0 0 0;
height: 47.5%;
width: 100%;
overflow: hidden;
}
.video-container {
border-radius: 0;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
border: 5px solid #cbc5bc;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
}
.video-container video {
width: 100%;
height: 100%;
transform: scale(1.2);
object-fit: cover;
border-radius: 0;
}
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 40%;
background-color: #38312431;
border-radius: 0;
border: 2px solid #38312440;
}
.text-outdoor {
font-weight: bold;
padding: 9px 0 0 0;
margin: 0 0 15px 0;
}
.text-indoor {
font-weight: bold;
margin: 0 0 15px 0;
}
.news-box {
display: flex;
justify-content: flex-start;
padding: 5px 15px 5px 15px;
border-bottom: 2px solid #38312456;
background-color: #edebe8;
word-wrap: break-word;
}
.news-date {
font-weight: bold;
margin: 0 10px 0 0;
}
.news-subtitle {
color: rgba(67, 67, 67, 0.657);
}
.news-container {
height: 290px;
overflow: hidden;
position: relative;
}
.news-scroll {
display: flex;
flex-direction: column;
animation-name: scroll-v;
animation-timing-function: linear;
animation-iteration-count: infinite;
--scroll-distance-v: 0px;
}
@keyframes scroll-v {
0% { transform: translateY(0); }
100% { transform: translateY(var(--scroll-distance-v)); }
}
.aushang-flex img {
border-radius: 0;
margin: 0 10px 0 0;
border: 2px solid #38312440;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.08);
}
.aushang-flex {
padding: 0 5px 0 5px;
display: flex;
justify-content: space-evenly;
align-items: center;
height: 90.5%;
border-radius: 0;
animation-name: scroll-h;
animation-timing-function: linear;
animation-iteration-count: infinite;
--scroll-distance-h: 0px;
}
@keyframes scroll-h {
0% { transform: translateX(0); }
100% { transform: translateX(var(--scroll-distance-h)); }
}
/* Bildgrößen aus dem Original; Schrift und Farben aus den zentralen CD-Tokens. */
body.dashboard {
--dashboard-text: var(--nak-black);
--dashboard-meta: var(--gray-700);
--panel-surface: #fff;
--dashboard-unit: clamp(1px, 0.052083333vw, 2px);
--panel-heading-height: calc(31 * var(--dashboard-unit));
/* Original: Board 97vh minus 7px Innenabstand, Monatsfeld 56%, Bildbereich 94%. */
--original-board-height: calc(97vh - 7px);
--month-image-height: calc(var(--original-board-height) * 0.56 * 0.94);
--month-aspect: 1.414;
--announcement-font: var(--bible-font);
--announcement-detail-font: calc(20 * var(--dashboard-unit));
--announcement-meta-font: calc(15 * var(--dashboard-unit));
--announcement-leading: 1.35;
--bible-font: calc(23 * var(--dashboard-unit));
display: block;
height: 100vh;
height: 100dvh;
min-height: 0;
padding: 0;
color: var(--dashboard-text);
background: var(--nak-blue-light) url('cloudy.jpg') center / cover no-repeat;
overflow: hidden;
}
.dashboard [hidden] { display: none !important; }
.dashboard-main {
display: grid;
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.05fr) calc(var(--month-image-height) * var(--month-aspect));
grid-template-rows: calc(var(--month-image-height) + var(--panel-heading-height)) minmax(0, 1fr);
grid-template-areas: "announcements bible month" "posters posters posters";
gap: var(--space-sm);
height: 100%;
min-height: 0;
padding: var(--space-sm);
}
#month-panel { grid-area: month; --panel-accent: var(--nak-purple); }
#poster-panel { grid-area: posters; --panel-accent: var(--nak-orange); --poster-frame-width: 1px; }
#announcements-panel { --panel-accent: var(--nak-teal); }
.dashboard-column { display: grid; gap: var(--space-sm); min-height: 0; min-width: 0; }
.announcements-column { grid-area: announcements; grid-template-rows: auto minmax(0, 1fr); }
.bible-column { grid-area: bible; grid-template-rows: minmax(0, 1fr) auto; }
.dashboard-panel { min-width: 0; min-height: 0; background: var(--panel-surface); }
.dashboard-overview { display: flex; align-items: flex-end; justify-content: space-between; gap: calc(8 * var(--dashboard-unit)); padding: calc(16 * var(--dashboard-unit)) calc(12 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)); background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--nak-blue); }
.dashboard-clock { display: grid; flex: 0 0 auto; justify-items: start; gap: calc(4 * var(--dashboard-unit)); }
#clock, #date { width: max-content; }
#clock { font-size: calc(64 * var(--dashboard-unit)); line-height: 0.9; font-weight: 500; font-variant-numeric: tabular-nums; }
#date { font-size: var(--announcement-meta-font); white-space: nowrap; }
.temperatures { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); min-width: 0; gap: calc(8 * var(--dashboard-unit)); margin: 0; }
.temperatures > div { min-width: 0; }
.temperatures dt { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); }
.temperatures dd { margin: 0; font-size: var(--announcement-detail-font); line-height: var(--announcement-leading); font-weight: 500; font-variant-numeric: tabular-nums; }
.temperatures .is-unavailable { font-size: calc(12 * var(--dashboard-unit)); font-weight: 300; color: var(--dashboard-meta); }
.image-panel, .text-panel { display: grid; grid-template-rows: var(--panel-heading-height) minmax(0, 1fr); }
.panel-heading { display: flex; align-items: center; padding: calc(3 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)) 0; background: var(--panel-surface); background: color-mix(in srgb, var(--panel-accent) 12%, white); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--panel-accent); }
.panel-heading h1, .panel-heading h2 { margin: 0; color: var(--dashboard-text); font-size: calc(20 * var(--dashboard-unit)); line-height: 1.1; font-weight: 500; }
.poster-progress { width: calc(18 * var(--dashboard-unit)); height: calc(18 * var(--dashboard-unit)); margin-left: auto; flex: 0 0 auto; fill: none; stroke-width: 2; }
.poster-progress-track { stroke: var(--gray-300); }
.poster-progress-value { stroke: var(--panel-accent); stroke-dasharray: 100; stroke-dashoffset: 100; transform: rotate(-90deg); transform-origin: center; }
.image-stage { min-height: 0; min-width: 0; padding: 0; column-gap: var(--space-sm); }
#month-panel .image-stage,
#poster-panel .image-stage { padding: calc(4 * var(--dashboard-unit)); }
.image-sheet { display: flex; justify-content: center; align-items: center; gap: var(--space-sm); width: 100%; height: 100%; min-height: 0; }
#month-panel .image-sheet { align-items: flex-start; gap: calc(4 * var(--dashboard-unit)); }
.image-cell { margin: 0; min-height: 0; min-width: 0; flex: 0 0 auto; }
.poster-document { display: flex; flex: 0 0 auto; gap: 0; background: #fff; border: var(--poster-frame-width) solid var(--gray-500); }
.image-cell img { display: block; width: 100%; height: 100%; object-fit: contain; }
.image-cell.is-cropped { position: relative; overflow: hidden; }
.image-cell.is-cropped img { position: absolute; max-width: none; }
.image-cell .empty-state { padding: var(--space-sm); font-size: var(--announcement-font); }
.text-stage { min-height: 0; margin: calc(6 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)); overflow: hidden; position: relative; }
#announcements-panel .text-stage { margin: 0; }
#announcements-panel .text-stage > .empty-state { padding: 0 calc(10 * var(--dashboard-unit)); }
.text-page { color: var(--dashboard-text); }
.text-page p { margin: 0 0 var(--space-xs); white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--announcement-font); line-height: var(--announcement-leading); }
.text-page p:last-child { margin-bottom: 0; }
.text-page .announcement-title { font-size: var(--announcement-detail-font); }
.text-page .announcement-title, .text-page .bible-reference { font-weight: 500; }
.text-page .announcement-subtitle { font-size: var(--announcement-detail-font); color: var(--dashboard-meta); }
.text-page .announcement-meta { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); }
.announcement-list { max-width: none; margin: 0; padding: 0; list-style: none; }
/* Originale Zeilenaufteilung: Datum links, Inhalt mit Zeit/Ort rechts. */
.announcement-entry { display: grid; grid-template-columns: calc(100 * var(--dashboard-unit)) minmax(0, 1fr); gap: calc(12 * var(--dashboard-unit)); padding: calc(6 * var(--dashboard-unit)) calc(10 * var(--dashboard-unit)); border-bottom: 1px solid var(--gray-300); background: var(--panel-surface); }
.announcement-entry:nth-child(even) { background: var(--gray-50); }
.announcement-content { min-width: 0; }
.announcement-entry p { margin: 0; }
.announcement-content .announcement-meta { font-style: italic; }
.announcement-when { min-width: 0; font-weight: 300; color: var(--dashboard-meta); font-variant-numeric: tabular-nums; }
.announcement-when p { margin-bottom: 0; font-size: var(--announcement-detail-font); }
.bible-panel { --panel-accent: var(--nak-yellow); }
.bible-panel .text-stage { margin-inline: calc(24 * var(--dashboard-unit)); margin-top: calc(12 * var(--dashboard-unit)); }
.bible-panel .text-page { max-width: none; }
#departures-panel { --panel-accent: var(--nak-darkblue); height: calc(161 * var(--dashboard-unit)); }
#departures-panel .text-stage { margin: 0; }
#departures-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(18 * var(--dashboard-unit)); }
.departures-list { max-width: none; margin: 0; padding: 0; list-style: none; }
.departure-row { --departure-color: var(--gray-500); display: grid; grid-template-columns: calc(38 * var(--dashboard-unit)) minmax(0, 1fr) calc(52 * var(--dashboard-unit)) calc(80 * var(--dashboard-unit)); align-items: center; gap: calc(6 * var(--dashboard-unit)); height: calc(26 * var(--dashboard-unit)); padding: 0 calc(5 * var(--dashboard-unit)) 0 calc(8 * var(--dashboard-unit)); border-right: calc(6 * var(--dashboard-unit)) solid var(--departure-color); background: var(--panel-surface); font-size: var(--announcement-meta-font); line-height: 1.2; }
.departure-row:nth-child(even) { background: var(--gray-50); }
.departure-row.is-ontime, .departure-row.is-scheduled { --departure-color: var(--nak-darkgreen); }
.departure-row.is-delayed { --departure-color: var(--nak-orange); }
.departure-row.is-early, .departure-row.is-cancelled { --departure-color: var(--nak-red); }
.departure-line { font-weight: 500; }
.departure-direction { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.departure-remaining, .departure-status { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
#departures-panel .empty-state { padding: calc(8 * var(--dashboard-unit)); font-size: var(--announcement-meta-font); }
.text-stage.is-overflowing::after {
content: "";
position: absolute;
inset: auto 0 0;
height: calc(24 * var(--dashboard-unit));
background: linear-gradient(to bottom, transparent, var(--panel-surface));
pointer-events: none;
}
.empty-state { margin: 0; font-size: var(--announcement-font); line-height: var(--announcement-leading); color: var(--dashboard-meta); }
.image-stage > .empty-state { padding: var(--space-md); }
.dashboard noscript { position: fixed; bottom: 0; padding: var(--space-lg); background: #fff; color: var(--nak-red); }
@media (max-width: 999px), (max-height: 599px) {
body.dashboard { height: auto; min-height: 100vh; overflow: visible; }
.dashboard-main {
height: auto;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "announcements" "bible" "month" "posters";
grid-template-rows: 520px 520px 65vh 55vh;
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
const DASHBOARD_BVG_STOP = '900086103';
function dashboard_fetch_departures(): array
{
$directory = __DIR__ . '/../cache';
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('BVG-Cache nicht beschreibbar.');
}
$file = fopen($directory . '/bvg-' . DASHBOARD_BVG_STOP . '.json', 'c+');
if (!$file) {
throw new RuntimeException('BVG-Cache nicht verfügbar.');
}
try {
// Gemeinsamer Cache: alle 12 Sekunden und zusätzlich zum Minutenwechsel.
if (!flock($file, LOCK_EX)) {
throw new RuntimeException('BVG-Cache konnte nicht gesperrt werden.');
}
$cache = json_decode(stream_get_contents($file), true) ?: [];
if (microtime(true) >= ($cache['retryAt'] ?? 0)) {
try {
$data = dashboard_http_json('https://v6.bvg.transport.rest/stops/' . DASHBOARD_BVG_STOP . '/departures?'
. 'duration=120&results=30&bus=true&suburban=false&subway=false&tram=false'
. '&ferry=false&express=false&regional=false&remarks=false&language=de&pretty=false');
if (!isset($data['departures']) || !is_array($data['departures'])) {
throw new RuntimeException('Unerwartete BVG-Antwort.');
}
$now = microtime(true);
$cache = ['departures' => array_values(array_filter($data['departures'], static fn(array $d): bool =>
($d['line']['product'] ?? '') === 'bus'
&& in_array($d['stop']['id'] ?? '', [DASHBOARD_BVG_STOP, '900000' . substr(DASHBOARD_BVG_STOP, 3)], true))),
'updatedAt' => (int) round($now * 1000), 'retryAt' => min($now + 12, (floor($now / 60) + 1) * 60),
'unavailable' => false];
} catch (Throwable $error) {
// Letzte Antwort behalten; auch bei 429/503 nicht sofort erneut anfragen.
error_log('Dashboard BVG: ' . $error->getMessage());
$cache['unavailable'] = true;
$cache['retryAt'] = microtime(true) + 30;
}
rewind($file);
$json = json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (!ftruncate($file, 0) || fwrite($file, $json) !== strlen($json) || !fflush($file)) {
throw new RuntimeException('BVG-Cache konnte nicht gespeichert werden.');
}
}
return $cache + ['departures' => [], 'updatedAt' => 0];
} finally {
fclose($file);
}
}
@@ -1,12 +1,15 @@
<?php
require_once __DIR__ . '/../../contentpanel/insertion/util/contentpanel_helpers.php';
declare(strict_types=1);
require_once __DIR__ . '/../../contentpanel/insertion/util/news.php';
require_once __DIR__ . '/../../contentpanel/insertion/util/announcements.php';
require_once __DIR__ . '/../../../general_files/ini/runtime_env.php';
const DASHBOARD_TIMEZONE = 'Europe/Berlin';
const DASHBOARD_HA_ENTITY_ID = 'sensor.temp_temperatur';
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571291';
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.310422';
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571524';
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.312494';
const DASHBOARD_NEWS_URL = '/intranet_repo/projects/contentpanel/insertion/news/png/';
function dashboard_now(): DateTimeImmutable
@@ -14,141 +17,151 @@ function dashboard_now(): DateTimeImmutable
return new DateTimeImmutable('now', new DateTimeZone(DASHBOARD_TIMEZONE));
}
function dashboard_html($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function dashboard_fetch_content(PDO $pdo, DateTimeImmutable $now): array
{
$now = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE));
$today = $now->format('Y-m-d');
$payload = ['date' => $today, 'service' => null, 'announcements' => [], 'errors' => []];
$serviceStatement = $pdo->prepare(
'SELECT bibelwort, bibelvers, eingang_nummer, eingang_titel
FROM gottesdienst
WHERE bibeldatum = ?'
);
$serviceStatement->execute([$today]);
$service = $serviceStatement->fetch(PDO::FETCH_ASSOC) ?: [];
$announcementStatement = $pdo->prepare(
'SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort, ansage_titel, ansage_untertitel
FROM ansagen
WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ?
ORDER BY ansage_datum_von ASC'
);
$announcementStatement->execute([$today, $today]);
$announcements = $announcementStatement->fetchAll(PDO::FETCH_ASSOC);
foreach ($announcements as &$announcement) {
$announcement = dashboard_prepare_announcement($announcement, $now);
// Unabhängige Abfragen: ein fehlerhafter Bereich blockiert den anderen nicht.
try {
$statement = $pdo->prepare(
'SELECT bibelwort, bibelvers FROM gottesdienst
WHERE bibeldatum = ? ORDER BY id ASC LIMIT 1'
);
$statement->execute([$today]);
$service = $statement->fetch(PDO::FETCH_ASSOC);
if ($service) {
// Leitgedanken-Import: bibelwort = Referenz, bibelvers = eigentlicher Text.
$payload['service'] = [
'reference' => trim((string) ($service['bibelwort'] ?? '')),
'text' => trim((string) ($service['bibelvers'] ?? '')),
];
}
} catch (Throwable $exception) {
error_log('Dashboard Bibelwort: ' . $exception->getMessage());
$payload['errors'][] = 'service';
}
unset($announcement);
contentpanel_cleanup_expired_news();
try {
$statement = $pdo->prepare(
'SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort,
ansage_titel, ansage_untertitel
FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) >= ?
ORDER BY ansage_datum_von ASC NULLS LAST, ansage_uhrzeit ASC NULLS LAST, id ASC'
);
contentpanel_cleanup_expired_announcements($pdo, $today);
$statement->execute([$today]);
foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
$payload['announcements'][] = dashboard_prepare_announcement($row, $now);
}
} catch (Throwable $exception) {
error_log('Dashboard Ansagen: ' . $exception->getMessage());
$payload['errors'][] = 'announcements';
}
return [
'date_label' => dashboard_date_label($now),
'service' => [
'bibelwort' => (string) ($service['bibelwort'] ?? 'Bibelwort nicht eingetragen'),
'bibelvers' => (string) ($service['bibelvers'] ?? 'Bibelvers nicht gefunden.'),
'eingang_nummer' => (string) ($service['eingang_nummer'] ?? 'N/A'),
'eingang_titel' => (string) ($service['eingang_titel'] ?? 'Eingangslied nicht gefunden'),
],
'announcements' => $announcements,
'posters' => dashboard_list_posters(),
'month_images' => dashboard_month_images(),
];
return $payload;
}
function dashboard_date_label(DateTimeImmutable $date): string
function dashboard_date(string $raw): ?DateTimeImmutable
{
static $months = [
1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
return sprintf('%02d. %s', (int) $date->format('d'), $months[(int) $date->format('n')]);
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw, new DateTimeZone(DASHBOARD_TIMEZONE));
return $date && $date->format('Y-m-d') === $raw ? $date : null;
}
function dashboard_prepare_announcement(array $announcement, DateTimeImmutable $now): array
{
$startRaw = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10);
$endRaw = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10);
$timeRaw = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5);
$startDate = DateTimeImmutable::createFromFormat('!Y-m-d', $startRaw, $now->getTimezone());
$tomorrow = $now->setTime(0, 0)->modify('+1 day');
if ($startDate && $startDate->format('Y-m-d') === $now->format('Y-m-d')) {
$dateLabel = 'Heute';
} elseif ($startDate && $startDate->format('Y-m-d') === $tomorrow->format('Y-m-d')) {
$dateLabel = 'Morgen';
} elseif ($startDate) {
static $weekdays = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
$dateLabel = $weekdays[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.');
$now = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE));
$start = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10);
$end = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10);
$today = $now->format('Y-m-d');
$startDate = dashboard_date($start);
$endDate = dashboard_date($end);
if ($start === $today) {
$label = 'Heute';
} elseif ($start === $now->modify('+1 day')->format('Y-m-d')) {
$label = 'Morgen';
} else {
$dateLabel = $startRaw;
$days = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
$label = $startDate ? $days[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.') : '';
}
$endLabel = '';
if ($endRaw !== '') {
$endDate = DateTimeImmutable::createFromFormat('!Y-m-d', $endRaw, $now->getTimezone());
$endLabel = $endDate ? 'bis ' . $endDate->format('d.m.') : 'bis ' . $endRaw;
}
$meta = [];
if ($timeRaw !== '') {
$meta[] = $timeRaw . ' Uhr';
}
$endLabel = $endDate && $end !== $start ? 'bis ' . $endDate->format('d.m.') : '';
$time = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5);
$place = trim((string) ($announcement['ansage_ort'] ?? ''));
if ($place !== '') {
$meta[] = $place;
}
return [
'date_label' => $dateLabel,
'date_label' => $label,
'end_label' => $endLabel,
'title' => (string) ($announcement['ansage_titel'] ?? ''),
'subtitle' => (string) ($announcement['ansage_untertitel'] ?? ''),
'meta' => implode(', ', $meta),
'time' => $time,
'place' => $place,
'title' => trim((string) ($announcement['ansage_titel'] ?? '')),
'subtitle' => trim((string) ($announcement['ansage_untertitel'] ?? '')),
];
}
function dashboard_list_posters(): array
function dashboard_image(string $path, string $url): ?array
{
$files = glob(CONTENTPANEL_NEWS_PNG_DIRECTORY . '/*.png') ?: [];
natsort($files);
$posters = [];
foreach ($files as $file) {
if (!is_file($file)) {
continue;
}
$basename = basename($file);
$posters[] = [
'url' => DASHBOARD_NEWS_URL . rawurlencode($basename) . '?v=' . (int) filemtime($file),
'alt' => 'Aushang',
];
if (!is_file($path)) {
return null;
}
return array_values($posters);
$size = @getimagesize($path);
if (!$size || $size[0] < 1 || $size[1] < 1) {
return null;
}
// Neu erzeugte, aber identische PNGs dürfen keinen sichtbaren Austausch auslösen.
$version = @hash_file('sha256', $path);
if ($version === false) {
return null;
}
return [
'url' => $url . '?v=' . $version,
'width' => $size[0],
'height' => $size[1],
];
}
function dashboard_month_images(): array
function dashboard_list_posters(DateTimeImmutable $now, string $directory = CONTENTPANEL_NEWS_PNG_DIRECTORY): array
{
$today = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE))->format('Y-m-d');
$files = glob($directory . '/*.png') ?: [];
natsort($files);
$posters = [];
foreach ($files as $file) {
$name = basename($file);
if (!preg_match('/^(.+)_page_([1-9][0-9]*)\\.png$/D', $name, $match)
|| !contentpanel_is_valid_news_file_base($match[1])) {
continue;
}
// Dieselbe inklusive Ablauffrist wie im Contentpanel. Nur filtern, nicht löschen.
$expiry = substr($match[1], 0, 10);
if (!dashboard_date($expiry) || $expiry < $today) {
continue;
}
$image = dashboard_image($file, DASHBOARD_NEWS_URL . rawurlencode($name));
if ($image) {
$title = str_replace('_', ' ', substr($match[1], 11));
$posters[] = $image + [
'document' => $match[1],
'expires' => $expiry, 'page' => (int) $match[2],
'alt' => $title . ' · Seite ' . (int) $match[2],
];
}
}
return $posters;
}
function dashboard_month_images(?string $directory = null): array
{
$directory ??= __DIR__ . '/../month';
$images = [];
foreach ([1, 2] as $page) {
$filename = 'terminuebersicht-' . $page . '.png';
$path = __DIR__ . '/../month/' . $filename;
$version = is_file($path) ? (int) filemtime($path) : 0;
$images[] = [
'page' => $page,
'url' => 'month/' . $filename . '?v=' . $version,
];
$image = dashboard_image($directory . '/' . $filename, 'month/' . $filename);
if ($image) {
$images[] = $image + ['alt' => 'Monatsplan · Seite ' . $page];
}
}
return $images;
}
@@ -159,17 +172,12 @@ function dashboard_fetch_indoor_temperature(): array
$url = $baseUrl . '/api/states/' . rawurlencode(DASHBOARD_HA_ENTITY_ID);
$data = dashboard_http_json($url, ['Authorization: Bearer ' . $token]);
if (!array_key_exists('state', $data)) {
if (!isset($data['state']) || !is_numeric($data['state'])) {
throw new RuntimeException('Unerwartete Home-Assistant-Antwort.');
}
$value = $data['state'];
if (is_numeric($value)) {
$value = round((float) $value, 1);
}
return [
'value' => $value,
'value' => (int) ceil((float) $data['state'] - 0.4),
'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'),
];
}
@@ -186,7 +194,6 @@ function dashboard_fetch_outdoor_weather(): array
$data = dashboard_http_json('https://api.openweathermap.org/data/2.5/weather?' . $query);
$temperature = $data['main']['temp'] ?? null;
$icon = (string) ($data['weather'][0]['icon'] ?? '');
if (!is_numeric($temperature)) {
throw new RuntimeException('Unerwartete OpenWeather-Antwort.');
@@ -194,27 +201,9 @@ function dashboard_fetch_outdoor_weather(): array
return [
'temperature' => (int) round((float) $temperature),
'icon' => dashboard_weather_kind($icon),
];
}
function dashboard_weather_kind(string $icon): string
{
return match ($icon) {
'04d', '04n', '03d', '03n' => 'cloudy',
'02d' => 'cloudy_day',
'02n' => 'cloudy_night',
'01d' => 'sun',
'01n' => 'moon',
'09d', '09n', '10n' => 'rain_shower',
'10d' => 'rain',
'11d', '11n' => 'thunder',
'13d', '13n' => 'snowy',
'50d', '50n' => 'mist',
default => 'cloudy',
};
}
function dashboard_http_json(string $url, array $headers = []): array
{
$handle = curl_init($url);
+51 -21
View File
@@ -35,7 +35,7 @@ def validate_template(template: Path) -> None:
_placeholders(minidom.parseString(archive.read('word/document.xml')))
def _set_text(paragraph, text):
def _set_text(paragraph, text):
if any(ord(char) < 32 and char not in '\t\n\r' for char in text):
raise TemplateError('Text enthält ungültige Steuerzeichen.')
nodes = paragraph.getElementsByTagNameNS(W, 't')
@@ -44,22 +44,49 @@ def _set_text(paragraph, text):
node.removeChild(node.firstChild)
document, anchor = paragraph.ownerDocument, nodes[0]
text = text.replace('\r\n', '\n').replace('\r', '\n')
for part in re.split(r'(\n|\t)', text):
for part in re.split(r'(\n|\t)', text):
if part in ('\n', '\t'):
node = document.createElementNS(W, 'w:br' if part == '\n' else 'w:tab')
else:
node = document.createElementNS(W, 'w:t')
node.setAttribute('xml:space', 'preserve')
node.appendChild(document.createTextNode(part))
anchor.parentNode.insertBefore(node, anchor)
anchor.parentNode.removeChild(anchor)
# Preserve verse/signature lines without stretching them to the full width.
if '\n' in text:
for alignment in paragraph.getElementsByTagNameNS(W, 'jc'):
alignment.setAttributeNS(W, 'w:val', 'left')
def fill_template(template: Path, output: Path, title: str, date_label: str, paragraphs: list[str]) -> None:
anchor.parentNode.insertBefore(node, anchor)
anchor.parentNode.removeChild(anchor)
if '\n' in text:
paragraph.getElementsByTagNameNS(W, 'jc')[0].setAttributeNS(W, 'w:val', 'left')
def _items(text):
lines = [line.strip() for line in text.replace('\r\n', '\n').replace('\r', '\n').split('\n') if line.strip()]
bullets = [line[1:].lstrip() if line.startswith('•') and line[1:].strip() else None for line in lines]
if not any(bullets):
return [(text, None)]
return [(bullet, i + 1 < len(lines) and bullets[i + 1] is not None) if bullet else (line, None)
for i, (line, bullet) in enumerate(zip(lines, bullets))]
def _format_bullet(paragraph, compact):
properties = paragraph.getElementsByTagNameNS(W, 'pPr')[0]
anchor = properties.getElementsByTagNameNS(W, 'jc')[0]
document = paragraph.ownerDocument
tabs = document.createElementNS(W, 'w:tabs')
tab = document.createElementNS(W, 'w:tab')
tab.setAttributeNS(W, 'w:val', 'left')
tab.setAttributeNS(W, 'w:pos', '480')
tabs.appendChild(tab)
properties.insertBefore(tabs, anchor)
if compact:
spacing = document.createElementNS(W, 'w:spacing')
spacing.setAttributeNS(W, 'w:after', '0')
properties.insertBefore(spacing, anchor)
indent = document.createElementNS(W, 'w:ind')
indent.setAttributeNS(W, 'w:left', '480')
indent.setAttributeNS(W, 'w:hanging', '360')
properties.insertBefore(indent, anchor)
def fill_template(template: Path, output: Path, title: str, date_label: str, paragraphs: list[str]) -> None:
paragraphs = [p.strip() for p in paragraphs if p.strip()]
if not title.strip() or not date_label.strip() or not paragraphs:
raise TemplateError('Titel, Datum und Inhalt dürfen nicht leer sein.')
@@ -69,16 +96,19 @@ def fill_template(template: Path, output: Path, title: str, date_label: str, par
_set_text(placeholders[TITLE_TOKEN], title.strip())
_set_text(placeholders[DATE_TOKEN], date_label.strip())
body = placeholders[BODY_TOKEN]
used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')}
index = 1
for text in paragraphs:
paragraph = body.cloneNode(deep=True)
while f'{index:08X}' in used:
index += 1
paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}')
used.add(f'{index:08X}')
_set_text(paragraph, text)
body.parentNode.insertBefore(paragraph, body)
used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')}
index = 1
for text in paragraphs:
for value, compact in _items(text):
paragraph = body.cloneNode(deep=True)
while f'{index:08X}' in used:
index += 1
paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}')
used.add(f'{index:08X}')
_set_text(paragraph, f'•\t{value}' if compact is not None else value)
if compact is not None:
_format_bullet(paragraph, compact)
body.parentNode.insertBefore(paragraph, body)
body.parentNode.removeChild(body)
output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output, 'w') as target:
@@ -139,7 +139,7 @@ class PipelineTests(unittest.TestCase):
self.assertEqual(xml.count(DATE_TOKEN), 1)
self.assertEqual(xml.count(BODY_TOKEN), 1)
def test_original_line_breaks_use_word_elements(self):
def test_original_line_breaks_use_word_elements(self):
with tempfile.TemporaryDirectory() as temp:
target = Path(temp) / 'lines.docx'
fill_template(TEMPLATE, target, 'Gruß', _date_label('2026-09-02'), ['Von Herzen\nEure Gastgeber'])
@@ -148,10 +148,34 @@ class PipelineTests(unittest.TestCase):
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
paragraph = next(p for p in document.findall('.//w:p', ns) if p.find('.//w:br', ns) is not None)
self.assertEqual(len(paragraph.findall('.//w:br', ns)), 1)
self.assertEqual([t.text for t in paragraph.findall('.//w:t', ns)], ['Von Herzen', 'Eure Gastgeber'])
self.assertEqual(paragraph.find('w:pPr/w:jc', ns).get('{' + ns['w'] + '}val'), 'left')
def test_template_fill_creates_multiple_paragraphs(self):
self.assertEqual([t.text for t in paragraph.findall('.//w:t', ns)], ['Von Herzen', 'Eure Gastgeber'])
self.assertEqual(paragraph.find('w:pPr/w:jc', ns).get('{' + ns['w'] + '}val'), 'left')
def test_bullet_lines_become_hanging_list_paragraphs(self):
with tempfile.TemporaryDirectory() as temp:
target = Path(temp) / 'bullets.docx'
fill_template(TEMPLATE, target, 'Einladung', _date_label('2026-09-04'), [
'Einleitung.',
'SEPTEMBER\n•05.09.2026: Erster Stichpunkt.\n• Zweiter Stichpunkt.\nOKTOBER\n• Dritter Stichpunkt.',
'Abschluss.',
])
with zipfile.ZipFile(target) as archive:
document = ET.fromstring(archive.read('word/document.xml'))
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
q = lambda name: '{' + ns['w'] + '}' + name
bullets = [paragraph for paragraph in document.findall('.//w:p', ns)
if ''.join(paragraph.itertext()).startswith('•')]
self.assertEqual(len(bullets), 3)
self.assertTrue({'SEPTEMBER', 'OKTOBER'} <= {''.join(p.itertext()) for p in document.findall('.//w:p', ns)})
for paragraph in bullets:
self.assertIsNotNone(paragraph.find('w:r/w:tab', ns))
self.assertEqual(paragraph.find('w:pPr/w:tabs/w:tab', ns).get(q('pos')), '480')
indent = paragraph.find('w:pPr/w:ind', ns)
self.assertEqual((indent.get(q('left')), indent.get(q('hanging'))), ('480', '360'))
self.assertEqual(bullets[0].find('w:pPr/w:spacing', ns).get(q('after')), '0')
self.assertIsNone(bullets[1].find('w:pPr/w:spacing', ns))
def test_template_fill_creates_multiple_paragraphs(self):
with tempfile.TemporaryDirectory() as temp:
target = Path(temp) / "filled.docx"
paragraphs = [f"Absatz {i}: Inhalt für den Aushang." for i in range(1, 8)]
@@ -32,11 +32,13 @@ class RenderingTests(unittest.TestCase):
cls.document = cls.output / "verified.docx"
with zipfile.ZipFile(TEMPLATE) as archive:
(cls.output / "original-logo.png").write_bytes(archive.read("word/media/image1.png"))
paragraphs = [
("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip()
for _ in range(3)
] + ["Erste Gedichtzeile\nZweite Gedichtzeile\n\nVon Herzen\nEure Gastgeber",
"Abschlussmarker: Dieser letzte Absatz muss vollständig auf der letzten Seite erscheinen."]
paragraphs = [
("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip()
for _ in range(3)
] + ["• Ein längerer Stichpunkt, dessen zweite Zeile unter dem Text und nicht unter dem Aufzählungszeichen beginnt.\n"
"• Ein zweiter Stichpunkt bestätigt den gleichmäßigen Abstand der Liste.",
"Erste Gedichtzeile\nZweite Gedichtzeile\n\nVon Herzen\nEure Gastgeber",
"Abschlussmarker: Dieser letzte Absatz muss vollständig auf der letzten Seite erscheinen."]
fill_template(TEMPLATE, cls.document,
"Verabschiedung von Diakon Georg aus der Gemeinde Reinickendorf",
_date_label("2026-09-02"), paragraphs)
@@ -125,15 +125,28 @@ class WebPreviewTests(unittest.TestCase):
self.page.screenshot(path=str(Path(output) / f'web-{width}.png'), full_page=True, animations='disabled')
self.assertEqual(self.page.locator('script:not([src])').count(), 0)
def test_permissions_and_csrf_reject_mutations(self):
def test_permissions_and_csrf_reject_mutations(self):
denied = self.page.request.post(self.url + '?deny=1', form={'action': 'delete_news', 'file_base': '2030-12-31-Mehrseitig'}, max_redirects=0)
self.assertEqual(denied.status, 303)
self.assertIn('/intranet/index.php', denied.headers['location'])
self.page.request.post(self.url, form={'action': 'delete_news', 'file_base': '2030-12-31-Mehrseitig', 'csrf_token': 'wrong'}, max_redirects=0)
self.page.reload()
self.assertIn('Ungültige Anfrage', self.page.locator('.sb-alert-error').inner_text())
self.assertTrue(all((self.news / f'2030-12-31-Mehrseitig_page_{n}.png').is_file() for n in range(1, 11)))
self.assertTrue(all((self.news / f'2030-12-31-Mehrseitig_page_{n}.png').is_file() for n in range(1, 11)))
def test_page_cleans_expired_announcements_on_each_authorized_request(self):
log = self.root / 'cleanup.jsonl'
before = len(log.read_text().splitlines())
self.page.reload()
cleanup = [json.loads(line) for line in log.read_text().splitlines()]
self.assertEqual(len(cleanup), before + 1)
sql, params = cleanup[-1]
self.assertEqual(sql, 'DELETE FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?')
self.assertEqual(len(params), 1)
self.assertRegex(params[0], r'^\d{4}-\d{2}-\d{2}$')
self.page.request.get(self.url + '?deny=1', max_redirects=0)
self.assertEqual(len(log.read_text().splitlines()), len(cleanup))
def test_forms_preserve_fields_validation_and_bound_sql_parameters(self):
for tab, action, fields in (
('gottesdienst', 'create_service', {'bibeldatum': '2030-12-31', 'bibelwort': 'Joh 3,16', 'bibelvers': 'Text & "Zitat"'}),
@@ -157,9 +170,10 @@ class WebPreviewTests(unittest.TestCase):
self.assertIn('Enddatum darf nicht', self.page.locator('.sb-alert-error').inner_text())
def test_original_upload_and_delete_through_real_page(self):
self.page.locator('#news_date').fill('2030-12-31')
self.page.locator('#news_name').fill('Uploadtest')
self.page.locator('#fileToUpload').set_input_files(self.pdf)
self.page.locator('#news_date').fill('2030-12-31')
self.page.locator('#news_name').fill('Uploadtest')
self.page.locator('[name="format_mode"][value="original"]').check()
self.page.locator('#fileToUpload').set_input_files(self.pdf)
self.page.get_by_role('button', name='Datei hochladen').click()
self.page.wait_for_selector('.sb-alert', timeout=60000)
self.assertEqual('Aushang wurde veröffentlicht.', self.page.locator('.sb-alert').inner_text())
@@ -202,9 +216,9 @@ class WebPreviewTests(unittest.TestCase):
self.page.get_by_role('button', name='Datei hochladen').click()
self.page.wait_for_selector('.sb-alert')
self.assertEqual(states[-1], ['block', True])
self.assertIn('erfolgreich hochgeladen', self.page.locator('.sb-alert').inner_text())
self.assertIn('Hintergrund', self.page.locator('.sb-alert').inner_text())
self.assertIn('E-Mail', self.page.locator('.sb-alert').inner_text())
message = self.page.locator('.sb-alert').inner_text()
self.assertIn('Hintergrund', message)
self.assertIn('E-Mail', message)
self.assertFalse(list(self.news.glob('*' + title + '*')))
self.assertTrue(self.page.get_by_role('button', name='Datei hochladen').is_enabled())
self.assertEqual(self.page.locator('.sb-progress, .sb-processing-note').count(), 0)
@@ -8,8 +8,10 @@ class TestStatement extends PDOStatement
public function __construct(private string $sql) {}
public function execute(?array $params = null): bool
{
if (!str_starts_with($this->sql, 'SELECT')) {
file_put_contents($_SERVER['DOCUMENT_ROOT'] . '/writes.jsonl', json_encode([$this->sql, $params]) . "\n", FILE_APPEND);
if (!str_starts_with($this->sql, 'SELECT')) {
$log = str_contains($this->sql, 'WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?')
? '/cleanup.jsonl' : '/writes.jsonl';
file_put_contents($_SERVER['DOCUMENT_ROOT'] . $log, json_encode([$this->sql, $params]) . "\n", FILE_APPEND);
}
return true;
}
+22 -21
View File
@@ -61,12 +61,11 @@ def has_style(value, declaration):
def event_rows(cell):
blocks = cell.find_all("div", class_="calEventBox")
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
for index, title in enumerate(titles):
time_text, place = "", "--"
block = blocks[index] if index < len(blocks) else None
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
for title in titles:
time_text = ""
block = title.find_parent("div", class_="calEventBox")
details = block and block.find(
style=lambda value: has_style(value, "font-weight:normal")
)
@@ -74,9 +73,10 @@ def event_rows(cell):
parts = [part.strip() for part in details.stripped_strings if part.strip()]
if parts and ":" in parts[0]:
time_text = parts.pop(0)
if parts:
place = parts[0].lstrip(", ") or "--"
yield time_text, title.get_text(strip=True), place
# Nur das Ortsfeld auswerten, nicht Trennzeichen, Datumsbereiche oder Notizen.
location = block and block.find(attrs={"name": "show_place"})
place = location.get_text(" ", strip=True).lstrip(", ") if location else ""
yield time_text, title.get_text(strip=True), "" if place == "--" else place
def next_month(month):
@@ -204,10 +204,14 @@ def add_month(pdf, cells, month, today=None):
def add_month_section(pdf, cells, month, now, today=None, planning_note=False):
name = MONTHS[month.month - 1]
pdf.set_font("helvetica", "B", 16)
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
pdf.set_font("helvetica", "", 10)
name = MONTHS[month.month - 1]
pdf.set_font("helvetica", "B", 16)
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
if not os.path.isfile(logo):
raise RuntimeError(f"Logo fehlt: {logo}")
pdf.image(logo, 188, pdf.get_y() - 7.5, 11)
pdf.set_font("helvetica", "", 10)
subtitle = (
f"Die Termine für {name} befinden sich noch in Planung und können sich ändern."
if planning_note else "der Gemeinde Berlin-Reinickendorf"
@@ -230,14 +234,11 @@ def create_pdf(cells, output_path, now=None):
pdf.set_auto_page_break(auto=True, margin=15)
pdf.add_page()
logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
if not os.path.isfile(logo):
raise RuntimeError(f"Logo fehlt: {logo}")
pdf.image(logo, 188, 9.5, 11)
for index, month in enumerate(months):
if index:
pdf.cell(0, 6, " ", 0, 1)
for index, month in enumerate(months):
if index and pdf.page_no() == 1:
pdf.add_page()
elif index:
pdf.cell(0, 6, " ", 0, 1)
add_month_section(
pdf, cells, month, now,
today=today if index == 0 else None,