Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34fb9f50f7 | ||
|
|
3f98cf2663 | ||
|
|
c01da71468 | ||
|
|
7d3f99c7b8 | ||
|
|
b5f784c120 | ||
|
|
214a3eb910 | ||
|
|
055b6d10c4 | ||
|
|
91d5f3d422 | ||
|
|
8ba41eb71e | ||
|
|
2d88c64e48 | ||
|
|
5c7401f3bc | ||
|
|
01b4be301f | ||
|
|
9abf97a922 | ||
|
|
b5233bb6eb | ||
|
|
9c34e504ca | ||
|
|
ab58e13b88 | ||
|
|
9c3aef4a96 | ||
|
|
a238ba1a55 | ||
|
|
e8305771f8 | ||
|
|
6988aee875 | ||
|
|
2e33f2da38 | ||
|
|
494bfa8884 | ||
|
|
37e7e4e367 | ||
|
|
0314bced40 | ||
|
|
3c56459cb8 | ||
|
|
643d1a49d1 |
@@ -23,6 +23,7 @@ web/intranet_repo/projects/contentpanel/insertion/news/*
|
|||||||
|
|
||||||
# Generierte Dashboard-Terminbilder
|
# Generierte Dashboard-Terminbilder
|
||||||
web/intranet_repo/projects/dashboard/month/*
|
web/intranet_repo/projects/dashboard/month/*
|
||||||
|
web/intranet_repo/projects/dashboard/cache/
|
||||||
|
|
||||||
# Lokale Backup-Dateien
|
# Lokale Backup-Dateien
|
||||||
*.bak
|
*.bak
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ RUN mkdir -p \
|
|||||||
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \
|
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \
|
||||||
/var/www/html/intranet_repo/projects/developerpanel/work \
|
/var/www/html/intranet_repo/projects/developerpanel/work \
|
||||||
/var/www/html/intranet_repo/projects/seelsorge/data \
|
/var/www/html/intranet_repo/projects/seelsorge/data \
|
||||||
|
/var/www/html/intranet_repo/projects/dashboard/cache \
|
||||||
/var/www/html/intranet_repo/projects/dashboard/month \
|
/var/www/html/intranet_repo/projects/dashboard/month \
|
||||||
&& chown -R www-data:www-data \
|
&& chown -R www-data:www-data \
|
||||||
/data \
|
/data \
|
||||||
@@ -58,6 +59,7 @@ RUN mkdir -p \
|
|||||||
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \
|
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \
|
||||||
/var/www/html/intranet_repo/projects/developerpanel/work \
|
/var/www/html/intranet_repo/projects/developerpanel/work \
|
||||||
/var/www/html/intranet_repo/projects/seelsorge/data \
|
/var/www/html/intranet_repo/projects/seelsorge/data \
|
||||||
|
/var/www/html/intranet_repo/projects/dashboard/cache \
|
||||||
/var/www/html/intranet_repo/projects/dashboard/month \
|
/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
|
&& 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
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -4,16 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
// Zentrale Anzeige- und Lesbarkeitswerte, ohne Build-Schritt.
|
// Zentrale Anzeige- und Lesbarkeitswerte, ohne Build-Schritt.
|
||||||
return [
|
return [
|
||||||
// Original: (97vh - 7px) × 47,5% Feld × 90,5% Leiste × 95% Bildhöhe.
|
|
||||||
'POSTER_MIN_RENDER_HEIGHT_VH' => 39.61298125,
|
'POSTER_MIN_RENDER_HEIGHT_VH' => 39.61298125,
|
||||||
'POSTER_MIN_RENDER_HEIGHT_OFFSET_PX' => 2.85866875,
|
'POSTER_MIN_RENDER_HEIGHT_OFFSET_PX' => 2.85866875,
|
||||||
'POSTER_MAX_VISIBLE_PAGES' => 12,
|
'POSTER_MAX_VISIBLE_PAGES' => 12,
|
||||||
'POSTER_MIN_DWELL_TIME_S' => 12,
|
'POSTER_MIN_DWELL_TIME_S' => 12,
|
||||||
'ANNOUNCEMENT_MIN_FONT_SIZE_PX' => 20,
|
|
||||||
'ANNOUNCEMENT_META_MIN_FONT_SIZE_PX' => 15,
|
|
||||||
'BIBLE_FONT_SIZE_PX' => 23,
|
|
||||||
'ANNOUNCEMENT_LINE_HEIGHT' => 1.35,
|
|
||||||
'ANNOUNCEMENT_MAX_LINE_LENGTH_CH' => 85,
|
|
||||||
'CONTENT_MIN_CONTRAST_RATIO' => 4.5,
|
'CONTENT_MIN_CONTRAST_RATIO' => 4.5,
|
||||||
// Ohne Aushänge gibt es keinen Durchlauf; dann weiterhin regelmäßig prüfen.
|
// Ohne Aushänge gibt es keinen Durchlauf; dann weiterhin regelmäßig prüfen.
|
||||||
'EMPTY_REFRESH_MS' => 30000,
|
'EMPTY_REFRESH_MS' => 30000,
|
||||||
|
|||||||
@@ -3,13 +3,6 @@
|
|||||||
|
|
||||||
const config = JSON.parse(document.getElementById('dashboard-config').textContent);
|
const config = JSON.parse(document.getElementById('dashboard-config').textContent);
|
||||||
const root = document.body;
|
const root = document.body;
|
||||||
for (const [property, value] of Object.entries({
|
|
||||||
'--announcement-detail-font': 'calc(' + config.ANNOUNCEMENT_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))',
|
|
||||||
'--announcement-meta-font': 'calc(' + config.ANNOUNCEMENT_META_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))',
|
|
||||||
'--announcement-leading': config.ANNOUNCEMENT_LINE_HEIGHT,
|
|
||||||
'--bible-font': 'calc(' + config.BIBLE_FONT_SIZE_PX + ' * var(--dashboard-unit))',
|
|
||||||
'--announcement-measure': config.ANNOUNCEMENT_MAX_LINE_LENGTH_CH + 'ch',
|
|
||||||
})) root.style.setProperty(property, value);
|
|
||||||
|
|
||||||
function node(tag, className, text) {
|
function node(tag, className, text) {
|
||||||
const element = document.createElement(tag);
|
const element = document.createElement(tag);
|
||||||
@@ -35,7 +28,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const aspectRatio = pages => pages.reduce((sum, item) => sum + item.width / item.height, 0);
|
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 layoutKey = layout => [layout.width, layout.height, layout.groups.map(group => group.length)].join('/');
|
||||||
|
|
||||||
const dayFormatter = new Intl.DateTimeFormat('en-CA', {
|
const dayFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
@@ -63,9 +58,9 @@
|
|||||||
message, rotate, items: [], current: [], images: new Map(),
|
message, rotate, items: [], current: [], images: new Map(),
|
||||||
index: 0, generation: 0, signature: '', layoutKey: '', failed: false, pending: false,
|
index: 0, generation: 0, signature: '', layoutKey: '', failed: false, pending: false,
|
||||||
});
|
});
|
||||||
this.panel = document.getElementById(id);
|
const panel = document.getElementById(id);
|
||||||
this.stage = this.panel.querySelector('.image-stage');
|
this.stage = panel.querySelector('.image-stage');
|
||||||
this.progress = this.panel.querySelector('.poster-progress');
|
this.progress = panel.querySelector('.poster-progress');
|
||||||
new ResizeObserver(() => this.resize()).observe(this.stage);
|
new ResizeObserver(() => this.resize()).observe(this.stage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +70,7 @@
|
|||||||
const style = getComputedStyle(this.stage);
|
const style = getComputedStyle(this.stage);
|
||||||
const width = bounds.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
const width = bounds.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
||||||
const height = bounds.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
|
const height = bounds.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
|
||||||
const gap = this.rotate ? parseFloat(style.columnGap) : 0;
|
const gap = parseFloat(style.columnGap);
|
||||||
const frame = this.rotate ? parseFloat(style.getPropertyValue('--poster-frame-width')) : 0;
|
const frame = this.rotate ? parseFloat(style.getPropertyValue('--poster-frame-width')) : 0;
|
||||||
if (!this.rotate) return { groups: [this.items], width, height, gap, frame };
|
if (!this.rotate) return { groups: [this.items], width, height, gap, frame };
|
||||||
const minimum = innerHeight * config.POSTER_MIN_RENDER_HEIGHT_VH / 100
|
const minimum = innerHeight * config.POSTER_MIN_RENDER_HEIGHT_VH / 100
|
||||||
@@ -134,6 +129,23 @@
|
|||||||
image.dataset.source = item.url;
|
image.dataset.source = item.url;
|
||||||
image.src = localUrl;
|
image.src = localUrl;
|
||||||
await image.decode();
|
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;
|
return image;
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (localUrl) URL.revokeObjectURL(localUrl);
|
if (localUrl) URL.revokeObjectURL(localUrl);
|
||||||
@@ -159,7 +171,7 @@
|
|||||||
this.message = message;
|
this.message = message;
|
||||||
this.items = items;
|
this.items = items;
|
||||||
// Nur neue Bildversionen laden; unveränderte Blob-URLs wiederverwenden.
|
// Nur neue Bildversionen laden; unveränderte Blob-URLs wiederverwenden.
|
||||||
for (const item of items) this.loadImage(item);
|
const loading = items.map(item => this.loadImage(item));
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
this.generation++;
|
this.generation++;
|
||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
@@ -180,7 +192,7 @@
|
|||||||
this.pending = true;
|
this.pending = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return this.show(0);
|
return this.rotate ? this.show(0) : Promise.all(loading).then(() => this.show(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
resize() {
|
resize() {
|
||||||
@@ -191,9 +203,10 @@
|
|||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
if (!this.rotate) return;
|
if (!this.rotate) return;
|
||||||
const duration = this.items.length ? config.POSTER_MIN_DWELL_TIME_S * 1000 : config.EMPTY_REFRESH_MS;
|
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.progressAnimation?.cancel();
|
||||||
this.progress.toggleAttribute('hidden', !this.items.length);
|
this.progress.toggleAttribute('hidden', !changing);
|
||||||
if (this.items.length) {
|
if (changing) {
|
||||||
this.progressAnimation = this.progress.querySelector('.poster-progress-value').animate(
|
this.progressAnimation = this.progress.querySelector('.poster-progress-value').animate(
|
||||||
[{ strokeDashoffset: 100 }, { strokeDashoffset: 0 }],
|
[{ strokeDashoffset: 100 }, { strokeDashoffset: 0 }],
|
||||||
{ duration, easing: 'linear', fill: 'forwards' });
|
{ duration, easing: 'linear', fill: 'forwards' });
|
||||||
@@ -219,6 +232,8 @@
|
|||||||
const documents = this.rotate ? groupDocuments(batch) : [batch];
|
const documents = this.rotate ? groupDocuments(batch) : [batch];
|
||||||
const renderHeight = Math.min(layout.height - 2 * layout.frame,
|
const renderHeight = Math.min(layout.height - 2 * layout.frame,
|
||||||
(layout.width - (documents.length - 1) * layout.gap - documents.length * 2 * layout.frame) / aspectRatio(batch));
|
(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;
|
const generation = ++this.generation;
|
||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
const sheet = node('div', 'image-sheet');
|
const sheet = node('div', 'image-sheet');
|
||||||
@@ -229,13 +244,23 @@
|
|||||||
if (this.rotate) sheet.append(wrapper);
|
if (this.rotate) sheet.append(wrapper);
|
||||||
return pages.map(async item => {
|
return pages.map(async item => {
|
||||||
const cell = node('div', 'image-cell');
|
const cell = node('div', 'image-cell');
|
||||||
cell.style.height = renderHeight + 'px';
|
const size = imageSize(item);
|
||||||
cell.style.width = (renderHeight * item.width / item.height) + 'px';
|
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);
|
wrapper.append(cell);
|
||||||
const copy = (await this.loadImage(item))?.cloneNode();
|
const copy = (await this.loadImage(item))?.cloneNode();
|
||||||
if (copy) {
|
if (copy) {
|
||||||
try {
|
try {
|
||||||
await copy.decode();
|
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);
|
cell.append(copy);
|
||||||
return;
|
return;
|
||||||
} catch { /* Die übrigen Seiten bleiben unabhängig darstellbar. */ }
|
} catch { /* Die übrigen Seiten bleiben unabhängig darstellbar. */ }
|
||||||
@@ -324,7 +349,8 @@
|
|||||||
if (!imageData) return;
|
if (!imageData) return;
|
||||||
// Monatsplan dauerhaft separat: keine Vermischung mit der Aushangrotation.
|
// Monatsplan dauerhaft separat: keine Vermischung mit der Aushangrotation.
|
||||||
const months = imageData.month_images;
|
const months = imageData.month_images;
|
||||||
if (months.length) root.style.setProperty('--month-aspect', aspectRatio(months));
|
if (months.length) root.style.setProperty('--month-aspect',
|
||||||
|
months.reduce((sum, item) => sum + item.width / item.height, 0));
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
month.update(months),
|
month.update(months),
|
||||||
posters.update(imageData.posters.filter(item => item.expires >= currentDay)),
|
posters.update(imageData.posters.filter(item => item.expires >= currentDay)),
|
||||||
@@ -371,13 +397,76 @@
|
|||||||
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||||
timeZone: 'Europe/Berlin', day: '2-digit', month: 'long', year: 'numeric',
|
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() {
|
function tick() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const nextDay = berlinDay(now);
|
const nextDay = berlinDay(now);
|
||||||
document.getElementById('clock').textContent = timeFormat.format(now);
|
clock.textContent = timeFormat.format(now);
|
||||||
document.getElementById('clock').dateTime = now.toISOString();
|
clock.dateTime = now.toISOString();
|
||||||
document.getElementById('date').textContent = dateFormat.format(now);
|
date.textContent = dateFormat.format(now);
|
||||||
document.getElementById('date').dateTime = nextDay;
|
date.dateTime = nextDay;
|
||||||
|
fitClock();
|
||||||
|
renderDepartures(now.getTime());
|
||||||
|
if (!document.hidden && now.getTime() >= nextDepartureRequest) {
|
||||||
|
nextDepartureRequest = now.getTime() + 1000;
|
||||||
|
refreshDepartures();
|
||||||
|
}
|
||||||
if (nextDay !== currentDay) {
|
if (nextDay !== currentDay) {
|
||||||
currentDay = nextDay;
|
currentDay = nextDay;
|
||||||
// Texte erst mit einer erfolgreichen Antwort austauschen; abgelaufene
|
// Texte erst mit einer erfolgreichen Antwort austauschen; abgelaufene
|
||||||
@@ -392,7 +481,25 @@
|
|||||||
imageData = data;
|
imageData = data;
|
||||||
return layoutImages();
|
return layoutImages();
|
||||||
}, imagesUnavailable);
|
}, 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() {
|
function refreshAll() {
|
||||||
|
// Langsame Temperatursensoren dürfen den Aushangswechsel nicht verzögern.
|
||||||
|
refreshTemperatures.forEach(refresh => refresh());
|
||||||
return Promise.all([refreshContent(), refreshImages()]);
|
return Promise.all([refreshContent(), refreshImages()]);
|
||||||
}
|
}
|
||||||
posters.onCycle = refreshAll;
|
posters.onCycle = refreshAll;
|
||||||
@@ -400,6 +507,7 @@
|
|||||||
tick();
|
tick();
|
||||||
setInterval(tick, 1000);
|
setInterval(tick, 1000);
|
||||||
document.fonts.ready.then(() => {
|
document.fonts.ready.then(() => {
|
||||||
|
fitClock();
|
||||||
announcements.rebuild();
|
announcements.rebuild();
|
||||||
bible.rebuild();
|
bible.rebuild();
|
||||||
// Ein Mindestkontrast wird auch bei später geänderten zentralen Tokens geprüft.
|
// Ein Mindestkontrast wird auch bei später geänderten zentralen Tokens geprüft.
|
||||||
|
|||||||
@@ -20,29 +20,34 @@ $config = require __DIR__ . '/config.php';
|
|||||||
</head>
|
</head>
|
||||||
<body class="dashboard">
|
<body class="dashboard">
|
||||||
<main class="dashboard-main" aria-label="Gemeindeinformationen">
|
<main class="dashboard-main" aria-label="Gemeindeinformationen">
|
||||||
<section class="dashboard-panel text-panel" id="announcements-panel" aria-labelledby="announcements-title">
|
<div class="dashboard-column announcements-column">
|
||||||
<div class="panel-heading"><h1 id="announcements-title">Ansagen</h1></div>
|
<section class="dashboard-overview" aria-label="Datum, Uhrzeit und Temperaturen">
|
||||||
<div class="text-stage"><p class="empty-state">Ansagen werden geladen …</p></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="community-column">
|
|
||||||
<section class="dashboard-overview" aria-label="Gemeinde, Datum und Uhrzeit">
|
|
||||||
<div class="dashboard-brand">
|
|
||||||
<img src="../_central/img/nak-logo.svg" alt="Neuapostolische Kirche" width="64" height="64">
|
|
||||||
<div class="dashboard-wordmark">
|
|
||||||
<span>Neuapostolische Kirche</span>
|
|
||||||
<strong>Berlin-Reinickendorf</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="dashboard-clock">
|
<div class="dashboard-clock">
|
||||||
<time id="clock" aria-label="Uhrzeit Europe/Berlin">–:–</time>
|
<time id="clock" aria-label="Uhrzeit Europe/Berlin">–:–</time>
|
||||||
<time id="date">Datum wird geladen</time>
|
<time id="date">Datum wird geladen</time>
|
||||||
</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>
|
||||||
|
<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="dashboard-column bible-column">
|
||||||
<section class="dashboard-panel text-panel bible-panel" id="bible-panel" aria-labelledby="bible-title">
|
<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="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>
|
<div class="text-stage"><p class="empty-state">Bibelwort wird geladen …</p></div>
|
||||||
</section>
|
</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 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">
|
<section class="dashboard-panel image-panel" id="month-panel" aria-labelledby="month-title">
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ date_default_timezone_set('Europe/Berlin');
|
|||||||
require_once __DIR__ . '/util/dashboard_helpers.php';
|
require_once __DIR__ . '/util/dashboard_helpers.php';
|
||||||
|
|
||||||
$resource = (string) ($_GET['resource'] ?? 'content');
|
$resource = (string) ($_GET['resource'] ?? 'content');
|
||||||
$pdo = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch ($resource) {
|
switch ($resource) {
|
||||||
@@ -29,6 +28,10 @@ try {
|
|||||||
case 'outdoor':
|
case 'outdoor':
|
||||||
$payload = ['outdoor' => dashboard_fetch_outdoor_weather()];
|
$payload = ['outdoor' => dashboard_fetch_outdoor_weather()];
|
||||||
break;
|
break;
|
||||||
|
case 'departures_eich':
|
||||||
|
require_once __DIR__ . '/util/bvg.php';
|
||||||
|
$payload = dashboard_fetch_departures();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
$payload = ['error' => 'Unbekannte Dashboard-Ressource.'];
|
$payload = ['error' => 'Unbekannte Dashboard-Ressource.'];
|
||||||
@@ -39,6 +42,4 @@ try {
|
|||||||
http_response_code(502);
|
http_response_code(502);
|
||||||
error_log('Dashboard data endpoint (' . $resource . '): ' . $exception->getMessage());
|
error_log('Dashboard data endpoint (' . $resource . '): ' . $exception->getMessage());
|
||||||
echo json_encode(['error' => 'Dashboard-Daten konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE);
|
echo json_encode(['error' => 'Dashboard-Daten konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE);
|
||||||
} finally {
|
|
||||||
$pdo = null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ body.dashboard {
|
|||||||
--announcement-detail-font: calc(20 * var(--dashboard-unit));
|
--announcement-detail-font: calc(20 * var(--dashboard-unit));
|
||||||
--announcement-meta-font: calc(15 * var(--dashboard-unit));
|
--announcement-meta-font: calc(15 * var(--dashboard-unit));
|
||||||
--announcement-leading: 1.35;
|
--announcement-leading: 1.35;
|
||||||
--announcement-measure: 85ch;
|
|
||||||
--bible-font: calc(23 * var(--dashboard-unit));
|
--bible-font: calc(23 * var(--dashboard-unit));
|
||||||
display: block;
|
display: block;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -27,49 +26,55 @@ body.dashboard {
|
|||||||
.dashboard [hidden] { display: none !important; }
|
.dashboard [hidden] { display: none !important; }
|
||||||
.dashboard-main {
|
.dashboard-main {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.85fr) calc(var(--month-image-height) * var(--month-aspect) + 2px);
|
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) + 2px) minmax(0, 1fr);
|
grid-template-rows: calc(var(--month-image-height) + var(--panel-heading-height)) minmax(0, 1fr);
|
||||||
grid-template-areas: "community announcements month" "posters posters posters";
|
grid-template-areas: "announcements bible month" "posters posters posters";
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: var(--space-sm);
|
padding: var(--space-sm);
|
||||||
}
|
}
|
||||||
#month-panel { grid-area: month; --panel-accent: var(--nak-darkblue); }
|
#month-panel { grid-area: month; --panel-accent: var(--nak-purple); }
|
||||||
#poster-panel { grid-area: posters; --panel-accent: var(--nak-orange); --poster-frame-width: 2px; }
|
#poster-panel { grid-area: posters; --panel-accent: var(--nak-orange); --poster-frame-width: 1px; }
|
||||||
#announcements-panel { grid-area: announcements; --panel-accent: var(--nak-teal); }
|
#announcements-panel { --panel-accent: var(--nak-teal); }
|
||||||
.community-column { grid-area: community; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: var(--space-sm); min-height: 0; min-width: 0; }
|
.dashboard-column { display: grid; gap: var(--space-sm); min-height: 0; min-width: 0; }
|
||||||
.dashboard-overview { background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--nak-blue); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: calc(12 * var(--dashboard-unit)); padding: calc(8 * var(--dashboard-unit)) calc(12 * var(--dashboard-unit)); }
|
.announcements-column { grid-area: announcements; grid-template-rows: auto minmax(0, 1fr); }
|
||||||
.dashboard-brand { display: flex; align-items: flex-end; gap: calc(10 * var(--dashboard-unit)); }
|
.bible-column { grid-area: bible; grid-template-rows: minmax(0, 1fr) auto; }
|
||||||
.dashboard-brand img { display: block; flex: 0 0 auto; width: calc(64 * var(--dashboard-unit)); height: calc(64 * var(--dashboard-unit)); }
|
.dashboard-panel { min-width: 0; min-height: 0; background: var(--panel-surface); }
|
||||||
.dashboard-wordmark { font-size: var(--announcement-meta-font); line-height: 1.2; transform: translateY(calc(4 * var(--dashboard-unit))); }
|
.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-wordmark span, .dashboard-wordmark strong { display: block; white-space: nowrap; }
|
.dashboard-clock { display: grid; flex: 0 0 auto; justify-items: start; gap: calc(4 * var(--dashboard-unit)); }
|
||||||
.dashboard-wordmark strong { font-weight: 500; }
|
#clock, #date { width: max-content; }
|
||||||
.dashboard-clock { margin-left: auto; transform: translateY(calc(3 * var(--dashboard-unit))); }
|
#clock { font-size: calc(64 * var(--dashboard-unit)); line-height: 0.9; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||||
.dashboard-clock time { display: block; }
|
|
||||||
#clock { font-size: calc(48 * var(--dashboard-unit)); line-height: 1; font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
||||||
#date { font-size: var(--announcement-meta-font); white-space: nowrap; }
|
#date { font-size: var(--announcement-meta-font); white-space: nowrap; }
|
||||||
.dashboard-panel { min-width: 0; min-height: 0; background: var(--panel-surface); border: 1px solid var(--gray-300); border-radius: 0; }
|
.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); }
|
.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; border-bottom: 1px solid var(--gray-300); background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--panel-accent); }
|
.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; }
|
.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 { 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-track { stroke: var(--gray-300); }
|
||||||
.poster-progress-value { stroke: var(--panel-accent); stroke-dasharray: 100; stroke-dashoffset: 100; transform: rotate(-90deg); transform-origin: center; }
|
.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); }
|
.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)); }
|
#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; }
|
.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 { gap: 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; }
|
.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); }
|
.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 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); }
|
.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; }
|
.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 { margin: 0; }
|
||||||
#announcements-panel .text-stage > .empty-state { padding: 0 calc(10 * var(--dashboard-unit)); }
|
#announcements-panel .text-stage > .empty-state { padding: 0 calc(10 * var(--dashboard-unit)); }
|
||||||
.text-page { max-width: var(--announcement-measure); color: var(--dashboard-text); }
|
.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 { 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 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-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-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); }
|
.text-page .announcement-meta { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); }
|
||||||
@@ -83,7 +88,21 @@ body.dashboard {
|
|||||||
.announcement-when { min-width: 0; font-weight: 300; color: var(--dashboard-meta); font-variant-numeric: tabular-nums; }
|
.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); }
|
.announcement-when p { margin-bottom: 0; font-size: var(--announcement-detail-font); }
|
||||||
.bible-panel { --panel-accent: var(--nak-yellow); }
|
.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; }
|
.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 {
|
.text-stage.is-overflowing::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -100,7 +119,7 @@ body.dashboard {
|
|||||||
.dashboard-main {
|
.dashboard-main {
|
||||||
height: auto;
|
height: auto;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-areas: "community" "announcements" "month" "posters";
|
grid-template-areas: "announcements" "bible" "month" "posters";
|
||||||
grid-template-rows: 520px 320px 65vh 55vh;
|
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®ional=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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ require_once __DIR__ . '/../../../general_files/ini/runtime_env.php';
|
|||||||
|
|
||||||
const DASHBOARD_TIMEZONE = 'Europe/Berlin';
|
const DASHBOARD_TIMEZONE = 'Europe/Berlin';
|
||||||
const DASHBOARD_HA_ENTITY_ID = 'sensor.temp_temperatur';
|
const DASHBOARD_HA_ENTITY_ID = 'sensor.temp_temperatur';
|
||||||
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571291';
|
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571524';
|
||||||
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.310422';
|
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.312494';
|
||||||
const DASHBOARD_NEWS_URL = '/intranet_repo/projects/contentpanel/insertion/news/png/';
|
const DASHBOARD_NEWS_URL = '/intranet_repo/projects/contentpanel/insertion/news/png/';
|
||||||
|
|
||||||
function dashboard_now(): DateTimeImmutable
|
function dashboard_now(): DateTimeImmutable
|
||||||
@@ -91,15 +91,12 @@ function dashboard_prepare_announcement(array $announcement, DateTimeImmutable $
|
|||||||
$place = trim((string) ($announcement['ansage_ort'] ?? ''));
|
$place = trim((string) ($announcement['ansage_ort'] ?? ''));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'start' => $start,
|
|
||||||
'end' => $end,
|
|
||||||
'date_label' => $label,
|
'date_label' => $label,
|
||||||
'end_label' => $endLabel,
|
'end_label' => $endLabel,
|
||||||
'time' => $time,
|
'time' => $time,
|
||||||
'place' => $place,
|
'place' => $place,
|
||||||
'title' => trim((string) ($announcement['ansage_titel'] ?? '')),
|
'title' => trim((string) ($announcement['ansage_titel'] ?? '')),
|
||||||
'subtitle' => trim((string) ($announcement['ansage_untertitel'] ?? '')),
|
'subtitle' => trim((string) ($announcement['ansage_untertitel'] ?? '')),
|
||||||
'meta' => implode(' · ', array_filter([$label, $endLabel, $time !== '' ? $time . ' Uhr' : '', $place])),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,8 +143,8 @@ function dashboard_list_posters(DateTimeImmutable $now, string $directory = CONT
|
|||||||
$title = str_replace('_', ' ', substr($match[1], 11));
|
$title = str_replace('_', ' ', substr($match[1], 11));
|
||||||
$posters[] = $image + [
|
$posters[] = $image + [
|
||||||
'document' => $match[1],
|
'document' => $match[1],
|
||||||
'kind' => 'poster', 'expires' => $expiry, 'page' => (int) $match[2],
|
'expires' => $expiry, 'page' => (int) $match[2],
|
||||||
'title' => $title, 'alt' => $title . ' · Seite ' . (int) $match[2],
|
'alt' => $title . ' · Seite ' . (int) $match[2],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,10 +159,7 @@ function dashboard_month_images(?string $directory = null): array
|
|||||||
$filename = 'terminuebersicht-' . $page . '.png';
|
$filename = 'terminuebersicht-' . $page . '.png';
|
||||||
$image = dashboard_image($directory . '/' . $filename, 'month/' . $filename);
|
$image = dashboard_image($directory . '/' . $filename, 'month/' . $filename);
|
||||||
if ($image) {
|
if ($image) {
|
||||||
$images[] = $image + [
|
$images[] = $image + ['alt' => 'Monatsplan · Seite ' . $page];
|
||||||
'kind' => 'month', 'page' => $page, 'title' => 'Monatsplan',
|
|
||||||
'alt' => 'Monatsplan · Seite ' . $page,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $images;
|
return $images;
|
||||||
@@ -183,7 +177,7 @@ function dashboard_fetch_indoor_temperature(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'value' => round((float) $data['state'], 1),
|
'value' => (int) ceil((float) $data['state'] - 0.4),
|
||||||
'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'),
|
'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def validate_template(template: Path) -> None:
|
|||||||
_placeholders(minidom.parseString(archive.read('word/document.xml')))
|
_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):
|
if any(ord(char) < 32 and char not in '\t\n\r' for char in text):
|
||||||
raise TemplateError('Text enthält ungültige Steuerzeichen.')
|
raise TemplateError('Text enthält ungültige Steuerzeichen.')
|
||||||
nodes = paragraph.getElementsByTagNameNS(W, 't')
|
nodes = paragraph.getElementsByTagNameNS(W, 't')
|
||||||
@@ -44,22 +44,49 @@ def _set_text(paragraph, text):
|
|||||||
node.removeChild(node.firstChild)
|
node.removeChild(node.firstChild)
|
||||||
document, anchor = paragraph.ownerDocument, nodes[0]
|
document, anchor = paragraph.ownerDocument, nodes[0]
|
||||||
text = text.replace('\r\n', '\n').replace('\r', '\n')
|
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'):
|
if part in ('\n', '\t'):
|
||||||
node = document.createElementNS(W, 'w:br' if part == '\n' else 'w:tab')
|
node = document.createElementNS(W, 'w:br' if part == '\n' else 'w:tab')
|
||||||
else:
|
else:
|
||||||
node = document.createElementNS(W, 'w:t')
|
node = document.createElementNS(W, 'w:t')
|
||||||
node.setAttribute('xml:space', 'preserve')
|
node.setAttribute('xml:space', 'preserve')
|
||||||
node.appendChild(document.createTextNode(part))
|
node.appendChild(document.createTextNode(part))
|
||||||
anchor.parentNode.insertBefore(node, anchor)
|
anchor.parentNode.insertBefore(node, anchor)
|
||||||
anchor.parentNode.removeChild(anchor)
|
anchor.parentNode.removeChild(anchor)
|
||||||
# Preserve verse/signature lines without stretching them to the full width.
|
if '\n' in text:
|
||||||
if '\n' in text:
|
paragraph.getElementsByTagNameNS(W, 'jc')[0].setAttributeNS(W, 'w:val', 'left')
|
||||||
for alignment in paragraph.getElementsByTagNameNS(W, 'jc'):
|
|
||||||
alignment.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()]
|
||||||
def fill_template(template: Path, output: Path, title: str, date_label: str, paragraphs: list[str]) -> None:
|
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()]
|
paragraphs = [p.strip() for p in paragraphs if p.strip()]
|
||||||
if not title.strip() or not date_label.strip() or not paragraphs:
|
if not title.strip() or not date_label.strip() or not paragraphs:
|
||||||
raise TemplateError('Titel, Datum und Inhalt dürfen nicht leer sein.')
|
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[TITLE_TOKEN], title.strip())
|
||||||
_set_text(placeholders[DATE_TOKEN], date_label.strip())
|
_set_text(placeholders[DATE_TOKEN], date_label.strip())
|
||||||
body = placeholders[BODY_TOKEN]
|
body = placeholders[BODY_TOKEN]
|
||||||
used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')}
|
used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')}
|
||||||
index = 1
|
index = 1
|
||||||
for text in paragraphs:
|
for text in paragraphs:
|
||||||
paragraph = body.cloneNode(deep=True)
|
for value, compact in _items(text):
|
||||||
while f'{index:08X}' in used:
|
paragraph = body.cloneNode(deep=True)
|
||||||
index += 1
|
while f'{index:08X}' in used:
|
||||||
paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}')
|
index += 1
|
||||||
used.add(f'{index:08X}')
|
paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}')
|
||||||
_set_text(paragraph, text)
|
used.add(f'{index:08X}')
|
||||||
body.parentNode.insertBefore(paragraph, body)
|
_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)
|
body.parentNode.removeChild(body)
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with zipfile.ZipFile(output, 'w') as target:
|
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(DATE_TOKEN), 1)
|
||||||
self.assertEqual(xml.count(BODY_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:
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
target = Path(temp) / 'lines.docx'
|
target = Path(temp) / 'lines.docx'
|
||||||
fill_template(TEMPLATE, target, 'Gruß', _date_label('2026-09-02'), ['Von Herzen\nEure Gastgeber'])
|
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'}
|
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)
|
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(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([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')
|
self.assertEqual(paragraph.find('w:pPr/w:jc', ns).get('{' + ns['w'] + '}val'), 'left')
|
||||||
|
|
||||||
def test_template_fill_creates_multiple_paragraphs(self):
|
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:
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
target = Path(temp) / "filled.docx"
|
target = Path(temp) / "filled.docx"
|
||||||
paragraphs = [f"Absatz {i}: Inhalt für den Aushang." for i in range(1, 8)]
|
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"
|
cls.document = cls.output / "verified.docx"
|
||||||
with zipfile.ZipFile(TEMPLATE) as archive:
|
with zipfile.ZipFile(TEMPLATE) as archive:
|
||||||
(cls.output / "original-logo.png").write_bytes(archive.read("word/media/image1.png"))
|
(cls.output / "original-logo.png").write_bytes(archive.read("word/media/image1.png"))
|
||||||
paragraphs = [
|
paragraphs = [
|
||||||
("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip()
|
("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip()
|
||||||
for _ in range(3)
|
for _ in range(3)
|
||||||
] + ["Erste Gedichtzeile\nZweite Gedichtzeile\n\nVon Herzen\nEure Gastgeber",
|
] + ["• Ein längerer Stichpunkt, dessen zweite Zeile unter dem Text und nicht unter dem Aufzählungszeichen beginnt.\n"
|
||||||
"Abschlussmarker: Dieser letzte Absatz muss vollständig auf der letzten Seite erscheinen."]
|
"• 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,
|
fill_template(TEMPLATE, cls.document,
|
||||||
"Verabschiedung von Diakon Georg aus der Gemeinde Reinickendorf",
|
"Verabschiedung von Diakon Georg aus der Gemeinde Reinickendorf",
|
||||||
_date_label("2026-09-02"), paragraphs)
|
_date_label("2026-09-02"), paragraphs)
|
||||||
|
|||||||
@@ -170,9 +170,10 @@ class WebPreviewTests(unittest.TestCase):
|
|||||||
self.assertIn('Enddatum darf nicht', self.page.locator('.sb-alert-error').inner_text())
|
self.assertIn('Enddatum darf nicht', self.page.locator('.sb-alert-error').inner_text())
|
||||||
|
|
||||||
def test_original_upload_and_delete_through_real_page(self):
|
def test_original_upload_and_delete_through_real_page(self):
|
||||||
self.page.locator('#news_date').fill('2030-12-31')
|
self.page.locator('#news_date').fill('2030-12-31')
|
||||||
self.page.locator('#news_name').fill('Uploadtest')
|
self.page.locator('#news_name').fill('Uploadtest')
|
||||||
self.page.locator('#fileToUpload').set_input_files(self.pdf)
|
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.get_by_role('button', name='Datei hochladen').click()
|
||||||
self.page.wait_for_selector('.sb-alert', timeout=60000)
|
self.page.wait_for_selector('.sb-alert', timeout=60000)
|
||||||
self.assertEqual('Aushang wurde veröffentlicht.', self.page.locator('.sb-alert').inner_text())
|
self.assertEqual('Aushang wurde veröffentlicht.', self.page.locator('.sb-alert').inner_text())
|
||||||
@@ -215,9 +216,9 @@ class WebPreviewTests(unittest.TestCase):
|
|||||||
self.page.get_by_role('button', name='Datei hochladen').click()
|
self.page.get_by_role('button', name='Datei hochladen').click()
|
||||||
self.page.wait_for_selector('.sb-alert')
|
self.page.wait_for_selector('.sb-alert')
|
||||||
self.assertEqual(states[-1], ['block', True])
|
self.assertEqual(states[-1], ['block', True])
|
||||||
self.assertIn('erfolgreich hochgeladen', self.page.locator('.sb-alert').inner_text())
|
message = self.page.locator('.sb-alert').inner_text()
|
||||||
self.assertIn('Hintergrund', self.page.locator('.sb-alert').inner_text())
|
self.assertIn('Hintergrund', message)
|
||||||
self.assertIn('E-Mail', self.page.locator('.sb-alert').inner_text())
|
self.assertIn('E-Mail', message)
|
||||||
self.assertFalse(list(self.news.glob('*' + title + '*')))
|
self.assertFalse(list(self.news.glob('*' + title + '*')))
|
||||||
self.assertTrue(self.page.get_by_role('button', name='Datei hochladen').is_enabled())
|
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)
|
self.assertEqual(self.page.locator('.sb-progress, .sb-processing-note').count(), 0)
|
||||||
|
|||||||
@@ -61,12 +61,11 @@ def has_style(value, declaration):
|
|||||||
|
|
||||||
|
|
||||||
def event_rows(cell):
|
def event_rows(cell):
|
||||||
blocks = cell.find_all("div", class_="calEventBox")
|
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
|
||||||
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
|
|
||||||
|
for title in titles:
|
||||||
for index, title in enumerate(titles):
|
time_text = ""
|
||||||
time_text, place = "", "--"
|
block = title.find_parent("div", class_="calEventBox")
|
||||||
block = blocks[index] if index < len(blocks) else None
|
|
||||||
details = block and block.find(
|
details = block and block.find(
|
||||||
style=lambda value: has_style(value, "font-weight:normal")
|
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()]
|
parts = [part.strip() for part in details.stripped_strings if part.strip()]
|
||||||
if parts and ":" in parts[0]:
|
if parts and ":" in parts[0]:
|
||||||
time_text = parts.pop(0)
|
time_text = parts.pop(0)
|
||||||
if parts:
|
# Nur das Ortsfeld auswerten, nicht Trennzeichen, Datumsbereiche oder Notizen.
|
||||||
place = parts[0].lstrip(", ") or "--"
|
location = block and block.find(attrs={"name": "show_place"})
|
||||||
yield time_text, title.get_text(strip=True), 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):
|
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):
|
def add_month_section(pdf, cells, month, now, today=None, planning_note=False):
|
||||||
name = MONTHS[month.month - 1]
|
name = MONTHS[month.month - 1]
|
||||||
pdf.set_font("helvetica", "B", 16)
|
pdf.set_font("helvetica", "B", 16)
|
||||||
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
|
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
|
||||||
pdf.set_font("helvetica", "", 10)
|
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 = (
|
subtitle = (
|
||||||
f"Die Termine für {name} befinden sich noch in Planung und können sich ändern."
|
f"Die Termine für {name} befinden sich noch in Planung und können sich ändern."
|
||||||
if planning_note else "der Gemeinde Berlin-Reinickendorf"
|
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.set_auto_page_break(auto=True, margin=15)
|
||||||
pdf.add_page()
|
pdf.add_page()
|
||||||
|
|
||||||
logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
|
for index, month in enumerate(months):
|
||||||
if not os.path.isfile(logo):
|
if index and pdf.page_no() == 1:
|
||||||
raise RuntimeError(f"Logo fehlt: {logo}")
|
pdf.add_page()
|
||||||
pdf.image(logo, 188, 9.5, 11)
|
elif index:
|
||||||
|
pdf.cell(0, 6, " ", 0, 1)
|
||||||
for index, month in enumerate(months):
|
|
||||||
if index:
|
|
||||||
pdf.cell(0, 6, " ", 0, 1)
|
|
||||||
add_month_section(
|
add_month_section(
|
||||||
pdf, cells, month, now,
|
pdf, cells, month, now,
|
||||||
today=today if index == 0 else None,
|
today=today if index == 0 else None,
|
||||||
|
|||||||
Reference in New Issue
Block a user