Merge pull request 'monatsplan - ckalender read fixed, developerpanel - rss feed implemented' (#13) from bugfix/monatsplan into dev
Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
@@ -263,27 +263,21 @@ Dockerfile
|
|||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
docker-compose.local.yml
|
docker-compose.local.yml
|
||||||
web/
|
web/
|
||||||
|
worker/
|
||||||
|
docker/
|
||||||
```
|
```
|
||||||
|
|
||||||
Die produktive `docker-compose.yml` wird lokal **nicht** verwendet. Dadurch werden insbesondere folgende Produktionsbestandteile nicht gestartet:
|
Die `docker-compose.local.yml` bildet die Anwendungsdienste der produktiven `docker-compose.yml` lokal ab und ergänzt die Datenbank. Lokal werden damit gestartet:
|
||||||
|
|
||||||
* `scheduler`
|
|
||||||
* `dokploy-network`
|
|
||||||
* Strato-SSH
|
|
||||||
* produktive Scheduler-Secrets
|
|
||||||
|
|
||||||
Die lokale Compose-Datei startet ausschließlich:
|
|
||||||
|
|
||||||
* `web`
|
* `web`
|
||||||
|
* `scheduler`
|
||||||
* `db` (PostgreSQL 18 mit pgvector)
|
* `db` (PostgreSQL 18 mit pgvector)
|
||||||
|
|
||||||
Sie verwendet denselben `Dockerfile` wie Produktion.
|
Das externe `dokploy-network` wird lokal nicht benötigt. Alle drei Container verwenden stattdessen das lokale Compose-Netzwerk `app`. `web` und `scheduler` greifen über `DB_HOST=db` direkt auf den lokalen PostgreSQL-Container zu.
|
||||||
|
|
||||||
Der Web-Container ist lokal unter `127.0.0.1:8080` erreichbar. PostgreSQL wird für pgAdmin unter `127.0.0.1:5432` freigegeben.
|
Der Web-Container ist unter `127.0.0.1:8080` erreichbar. PostgreSQL wird für pgAdmin unter `127.0.0.1:5432` freigegeben.
|
||||||
|
|
||||||
Für die Datenbank wird das Image `pgvector/pgvector:pg18` verwendet, weil der Produktiv-Dump die PostgreSQL-Erweiterung `vector` benötigt.
|
Für die Datenbank wird das Image `pgvector/pgvector:pg18` verwendet, weil der Produktiv-Dump die PostgreSQL-Erweiterung `vector` benötigt. PostgreSQL 18 verwendet für persistente Daten den Mount-Punkt:
|
||||||
|
|
||||||
PostgreSQL 18 verwendet für persistente Daten den Mount-Punkt:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/var/lib/postgresql
|
/var/lib/postgresql
|
||||||
@@ -301,17 +295,18 @@ Durch den Bind-Mount
|
|||||||
./web → /var/www/html
|
./web → /var/www/html
|
||||||
```
|
```
|
||||||
|
|
||||||
werden Änderungen an PHP, CSS und JavaScript ohne erneuten Image-Build sichtbar. Laufzeitdaten wie Profilbilder und Aushänge liegen weiterhin in lokalen Docker-Volumes.
|
werden Änderungen an PHP, CSS und JavaScript ohne erneuten Image-Build sichtbar. Laufzeitdaten wie Profilbilder, Aushänge, Scheduler-Zustand und generierte Kalenderdateien liegen weiterhin in lokalen Docker-Volumes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Environment-Variablen anlegen
|
## 4. Environment-Variablen anlegen
|
||||||
|
|
||||||
Im Projektroot eine `.env` anlegen:
|
Im Projektroot eine `.env` anlegen. Werte, die lokal nicht benötigt oder noch nicht bekannt sind, bleiben leer.
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
POSTGRES_VERSION=18
|
DB_HOST=
|
||||||
|
DB_PORT=5432
|
||||||
DB_NAME=intranet
|
DB_NAME=intranet
|
||||||
DB_USER=intranet
|
DB_USER=intranet
|
||||||
DB_PASSWORD=local-dev-password
|
DB_PASSWORD=local-dev-password
|
||||||
@@ -319,24 +314,39 @@ DB_PASSWORD=local-dev-password
|
|||||||
# Intranet
|
# Intranet
|
||||||
INTRANET_FQDN=localhost:8080
|
INTRANET_FQDN=localhost:8080
|
||||||
|
|
||||||
# Lokale Dummy-Werte für externe Dienste
|
# SMTP / Benachrichtigungen
|
||||||
SMTP_HOST=127.0.0.1
|
SMTP_HOST=
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=
|
||||||
SMTP_USER=local
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=local
|
SMTP_PASSWORD=
|
||||||
ADMIN_EMAIL=local@example.invalid
|
ADMIN_EMAIL=
|
||||||
|
SMS_GATEWAY_URL=
|
||||||
|
|
||||||
SMS_GATEWAY_URL=http://127.0.0.1:9
|
# Dashboard / externe APIs
|
||||||
HA_BASE_URL=http://127.0.0.1:9
|
HA_BASE_URL=
|
||||||
HA_TOKEN=local
|
HA_TOKEN=
|
||||||
OPENWEATHER_API_KEY=local
|
OPENWEATHER_API_KEY=
|
||||||
|
|
||||||
|
# CKalender / Scheduler
|
||||||
CKAL_USERNAME=
|
CKAL_USERNAME=
|
||||||
CKAL_PASSWORD=
|
CKAL_PASSWORD=
|
||||||
CKAL_CALENDAR_URL=
|
CKAL_CALENDAR_URL=
|
||||||
|
CALENDAR_INTERVAL_SECONDS=60
|
||||||
|
|
||||||
|
# Strato-Synchronisation
|
||||||
|
STRATO_SYNC_ENABLED=false
|
||||||
|
STRATO_SSH_HOST=
|
||||||
|
STRATO_SSH_PORT=
|
||||||
|
STRATO_SSH_USER=
|
||||||
|
STRATO_REMOTE_PATH=
|
||||||
|
STRATO_SSH_PRIVATE_KEY=
|
||||||
|
STRATO_KNOWN_HOSTS=
|
||||||
|
|
||||||
|
# Brandschutz
|
||||||
|
BRANDSCHUTZ_MAIL_ENABLED=false
|
||||||
```
|
```
|
||||||
|
|
||||||
Die `.env` darf keine produktiven Secrets enthalten und sollte nicht ins Repository committed werden.
|
Funktionen, die einen externen Dienst benötigen, funktionieren erst, wenn die zugehörigen Werte gesetzt sind.
|
||||||
|
|
||||||
> Lokal sollte möglichst dieselbe PostgreSQL-Major-Version wie in Produktion verwendet werden. Wir verwenden **PostgreSQL 18**.
|
> Lokal sollte möglichst dieselbe PostgreSQL-Major-Version wie in Produktion verwendet werden. Wir verwenden **PostgreSQL 18**.
|
||||||
|
|
||||||
@@ -379,12 +389,6 @@ docker compose -f docker-compose.local.yml ps
|
|||||||
|
|
||||||
Der Datenbank-Container sollte nach einigen Sekunden den Status `healthy` anzeigen.
|
Der Datenbank-Container sollte nach einigen Sekunden den Status `healthy` anzeigen.
|
||||||
|
|
||||||
Optional kann geprüft werden, ob pgvector verfügbar ist:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it intranet-local-db-1 psql -U intranet -d intranet -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Dump mit pgAdmin importieren
|
## 7. Dump mit pgAdmin importieren
|
||||||
@@ -399,8 +403,6 @@ Falls pgAdmin 4 noch nicht installiert ist, kann es lokal als Desktop-Anwendung
|
|||||||
|
|
||||||
Die Installationspakete und Anleitungen sind unter https://www.pgadmin.org/download/ verfügbar.
|
Die Installationspakete und Anleitungen sind unter https://www.pgadmin.org/download/ verfügbar.
|
||||||
|
|
||||||
Unter Windows mit WSL 2 kann pgAdmin direkt unter Windows installiert werden. Die PostgreSQL-Datenbank aus Docker ist anschließend über `localhost:5432` erreichbar.
|
|
||||||
|
|
||||||
In pgAdmin einen neuen Server registrieren:
|
In pgAdmin einen neuen Server registrieren:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -427,14 +429,8 @@ Danach:
|
|||||||
7. **Clean before restore: No** beim ersten Import.
|
7. **Clean before restore: No** beim ersten Import.
|
||||||
8. **Restore** starten.
|
8. **Restore** starten.
|
||||||
|
|
||||||
Soll die lokale Datenbank später vollständig durch einen neuen Dump ersetzt werden, kann das lokale Setup zurückgesetzt werden:
|
> **Hinweis:** Soll die lokale Datenbank später vollständig durch einen neuen Dump ersetzt werden, kann das lokale Setup zurückgesetzt werden: `docker compose -f docker-compose.local.yml down -v`,
|
||||||
|
`docker compose -f docker-compose.local.yml up -d db`. Danach den Dump erneut importieren.
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.local.yml down -v
|
|
||||||
docker compose -f docker-compose.local.yml up -d db
|
|
||||||
```
|
|
||||||
|
|
||||||
Danach den Dump erneut importieren.
|
|
||||||
|
|
||||||
> **Achtung:** `down -v` löscht nicht nur PostgreSQL, sondern auch alle übrigen lokalen Intranet-Volumes, z. B. Profilbilder, Aushänge und andere Laufzeitdaten. Nur verwenden, wenn diese Daten bewusst verworfen werden sollen.
|
> **Achtung:** `down -v` löscht nicht nur PostgreSQL, sondern auch alle übrigen lokalen Intranet-Volumes, z. B. Profilbilder, Aushänge und andere Laufzeitdaten. Nur verwenden, wenn diese Daten bewusst verworfen werden sollen.
|
||||||
|
|
||||||
@@ -504,9 +500,7 @@ docker compose -f docker-compose.local.yml logs -f db
|
|||||||
|
|
||||||
### Änderungen an PHP, CSS und JavaScript
|
### Änderungen an PHP, CSS und JavaScript
|
||||||
|
|
||||||
Änderungen unter `web/` werden durch den Bind-Mount direkt in den laufenden Container übernommen. Ein Rebuild ist normalerweise nicht notwendig.
|
Änderungen unter `web/` werden durch den Bind-Mount direkt in den laufenden Container übernommen. Ein Rebuild ist normalerweise nicht notwendig. Nach dem Speichern reicht in der Regel ein Reload im Browser.
|
||||||
|
|
||||||
Nach dem Speichern reicht in der Regel ein Reload im Browser.
|
|
||||||
|
|
||||||
### Änderungen am Dockerfile
|
### Änderungen am Dockerfile
|
||||||
|
|
||||||
|
|||||||
+156
-103
@@ -1,103 +1,156 @@
|
|||||||
# LOCAL DEVELOPMENT ONLY
|
# LOCAL DEVELOPMENT ONLY
|
||||||
|
|
||||||
name: intranet-local
|
name: intranet-local
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
restart: unless-stopped
|
||||||
- "127.0.0.1:8080:80"
|
ports:
|
||||||
env_file:
|
- "127.0.0.1:8080:80"
|
||||||
- .env
|
environment:
|
||||||
environment:
|
TZ: Europe/Berlin
|
||||||
TZ: Europe/Berlin
|
INTRANET_FQDN: "${INTRANET_FQDN:-localhost:8080}"
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
DB_PORT: "5432"
|
DB_PORT: "5432"
|
||||||
CKAL_XLSX_DIR: /data/ckal-xlsx
|
DB_NAME: "${DB_NAME:-intranet}"
|
||||||
STRATO_SYNC_ENABLED: "false"
|
DB_USER: "${DB_USER:-intranet}"
|
||||||
BRANDSCHUTZ_MAIL_ENABLED: "false"
|
DB_PASSWORD: "${DB_PASSWORD:-local-dev-password}"
|
||||||
depends_on:
|
SMTP_HOST: "${SMTP_HOST:-}"
|
||||||
db:
|
SMTP_PORT: "${SMTP_PORT:-}"
|
||||||
condition: service_healthy
|
SMTP_USER: "${SMTP_USER:-}"
|
||||||
command: >
|
SMTP_PASSWORD: "${SMTP_PASSWORD:-}"
|
||||||
sh -c "printf '%s\n' 'session.cookie_secure = 0'
|
ADMIN_EMAIL: "${ADMIN_EMAIL:-}"
|
||||||
> /usr/local/etc/php/conf.d/zz-local.ini
|
SMS_GATEWAY_URL: "${SMS_GATEWAY_URL:-}"
|
||||||
&& exec apache2-foreground"
|
HA_BASE_URL: "${HA_BASE_URL:-}"
|
||||||
volumes:
|
HA_TOKEN: "${HA_TOKEN:-}"
|
||||||
- ./web:/var/www/html
|
OPENWEATHER_API_KEY: "${OPENWEATHER_API_KEY:-}"
|
||||||
- profile_pictures:/var/www/html/intranet_repo/general_files/profile_pictures
|
CKAL_XLSX_DIR: /data/ckal-xlsx
|
||||||
- content_news:/var/www/html/intranet_repo/projects/contentpanel/insertion/news
|
STRATO_SYNC_ENABLED: "${STRATO_SYNC_ENABLED:-false}"
|
||||||
- developer_work:/var/www/html/intranet_repo/projects/developerpanel/work
|
BRANDSCHUTZ_MAIL_ENABLED: "${BRANDSCHUTZ_MAIL_ENABLED:-false}"
|
||||||
- seelsorge_data:/var/www/html/intranet_repo/projects/seelsorge/data
|
depends_on:
|
||||||
- dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month
|
db:
|
||||||
- ckal_xlsx:/data/ckal-xlsx
|
condition: service_healthy
|
||||||
- calendar_pdf:/var/www/html/pdf
|
command: >
|
||||||
|
sh -c "printf '%s\n' 'session.cookie_secure = 0'
|
||||||
db:
|
> /usr/local/etc/php/conf.d/zz-local.ini
|
||||||
image: pgvector/pgvector:pg18
|
&& exec apache2-foreground"
|
||||||
ports:
|
volumes:
|
||||||
- "127.0.0.1:5432:5432"
|
- ./web:/var/www/html
|
||||||
environment:
|
- profile_pictures:/var/www/html/intranet_repo/general_files/profile_pictures
|
||||||
TZ: Europe/Berlin
|
- content_news:/var/www/html/intranet_repo/projects/contentpanel/insertion/news
|
||||||
POSTGRES_DB: ${DB_NAME:?set DB_NAME in .env}
|
- developer_work:/var/www/html/intranet_repo/projects/developerpanel/work
|
||||||
POSTGRES_USER: ${DB_USER:?set DB_USER in .env}
|
- seelsorge_data:/var/www/html/intranet_repo/projects/seelsorge/data
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
|
- dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month
|
||||||
volumes:
|
- ckal_xlsx:/data/ckal-xlsx
|
||||||
- postgres_data:/var/lib/postgresql
|
- calendar_pdf:/var/www/html/pdf:ro
|
||||||
healthcheck:
|
networks:
|
||||||
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
- app
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
scheduler:
|
||||||
retries: 12
|
build:
|
||||||
|
context: .
|
||||||
scheduler:
|
dockerfile: Dockerfile
|
||||||
build:
|
restart: unless-stopped
|
||||||
context: .
|
init: true
|
||||||
dockerfile: Dockerfile
|
user: "33:33"
|
||||||
env_file:
|
command:
|
||||||
- .env
|
- /bin/sh
|
||||||
environment:
|
- -ec
|
||||||
TZ: Europe/Berlin
|
- |
|
||||||
HOME: /tmp/intranet-worker
|
if [ -z "$${CKAL_USERNAME}" ] || [ -z "$${CKAL_PASSWORD}" ] || [ -z "$${CKAL_CALENDAR_URL}" ]; then
|
||||||
XDG_CACHE_HOME: /tmp/intranet-worker-cache
|
echo "Scheduler wartet: CKAL_USERNAME, CKAL_PASSWORD und CKAL_CALENDAR_URL sind nicht gesetzt."
|
||||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
exec sleep infinity
|
||||||
|
fi
|
||||||
DB_HOST: db
|
exec /opt/intranet/docker/scheduler.py
|
||||||
DB_PORT: "5432"
|
shm_size: "512m"
|
||||||
|
healthcheck:
|
||||||
CKAL_XLSX_DIR: /data/ckal-xlsx
|
disable: true
|
||||||
PDF_OUTPUT: /data/pdf/terminuebersicht.pdf
|
environment:
|
||||||
DASHBOARD_MONTH_DIR: /var/www/html/intranet_repo/projects/dashboard/month
|
TZ: Europe/Berlin
|
||||||
|
HOME: /tmp/intranet-worker
|
||||||
CALENDAR_INTERVAL_SECONDS: "60"
|
XDG_CACHE_HOME: /tmp/intranet-worker-cache
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||||
STRATO_SYNC_ENABLED: "false"
|
INTRANET_FQDN: "${INTRANET_FQDN:-localhost:8080}"
|
||||||
BRANDSCHUTZ_MAIL_ENABLED: "false"
|
DB_HOST: db
|
||||||
|
DB_PORT: "5432"
|
||||||
command: ["/opt/intranet/docker/scheduler.py"]
|
DB_NAME: "${DB_NAME:-intranet}"
|
||||||
init: true
|
DB_USER: "${DB_USER:-intranet}"
|
||||||
user: "33:33"
|
DB_PASSWORD: "${DB_PASSWORD:-local-dev-password}"
|
||||||
shm_size: "512m"
|
SMTP_HOST: "${SMTP_HOST:-}"
|
||||||
|
SMTP_PORT: "${SMTP_PORT:-}"
|
||||||
depends_on:
|
SMTP_USER: "${SMTP_USER:-}"
|
||||||
db:
|
SMTP_PASSWORD: "${SMTP_PASSWORD:-}"
|
||||||
condition: service_healthy
|
ADMIN_EMAIL: "${ADMIN_EMAIL:-}"
|
||||||
|
CKAL_USERNAME: "${CKAL_USERNAME:-}"
|
||||||
volumes:
|
CKAL_PASSWORD: "${CKAL_PASSWORD:-}"
|
||||||
- dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month
|
CKAL_CALENDAR_URL: "${CKAL_CALENDAR_URL:-}"
|
||||||
- ckal_xlsx:/data/ckal-xlsx
|
CKAL_XLSX_DIR: /data/ckal-xlsx
|
||||||
- calendar_pdf:/data/pdf
|
PDF_OUTPUT: /data/pdf/terminuebersicht.pdf
|
||||||
- scheduler_state:/data/state
|
DASHBOARD_MONTH_DIR: /var/www/html/intranet_repo/projects/dashboard/month
|
||||||
|
CALENDAR_INTERVAL_SECONDS: "${CALENDAR_INTERVAL_SECONDS:-60}"
|
||||||
volumes:
|
STRATO_SSH_HOST: "${STRATO_SSH_HOST:-}"
|
||||||
postgres_data:
|
STRATO_SSH_PORT: "${STRATO_SSH_PORT:-}"
|
||||||
profile_pictures:
|
STRATO_SSH_USER: "${STRATO_SSH_USER:-}"
|
||||||
content_news:
|
STRATO_REMOTE_PATH: "${STRATO_REMOTE_PATH:-}"
|
||||||
developer_work:
|
STRATO_SSH_KEY_FILE: /run/secrets/strato_ed25519
|
||||||
seelsorge_data:
|
STRATO_KNOWN_HOSTS_FILE: /run/secrets/known_hosts
|
||||||
dashboard_month:
|
STRATO_SYNC_ENABLED: "${STRATO_SYNC_ENABLED:-false}"
|
||||||
ckal_xlsx:
|
BRANDSCHUTZ_MAIL_ENABLED: "${BRANDSCHUTZ_MAIL_ENABLED:-false}"
|
||||||
calendar_pdf:
|
depends_on:
|
||||||
scheduler_state:
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month
|
||||||
|
- ckal_xlsx:/data/ckal-xlsx
|
||||||
|
- calendar_pdf:/data/pdf
|
||||||
|
- scheduler_state:/data/state
|
||||||
|
secrets:
|
||||||
|
- source: strato_ed25519
|
||||||
|
target: strato_ed25519
|
||||||
|
- source: strato_known_hosts
|
||||||
|
target: known_hosts
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: pgvector/pgvector:pg18
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
POSTGRES_DB: "${DB_NAME:-intranet}"
|
||||||
|
POSTGRES_USER: "${DB_USER:-intranet}"
|
||||||
|
POSTGRES_PASSWORD: "${DB_PASSWORD:-local-dev-password}"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
profile_pictures:
|
||||||
|
content_news:
|
||||||
|
developer_work:
|
||||||
|
seelsorge_data:
|
||||||
|
dashboard_month:
|
||||||
|
ckal_xlsx:
|
||||||
|
calendar_pdf:
|
||||||
|
scheduler_state:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app:
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
strato_ed25519:
|
||||||
|
environment: STRATO_SSH_PRIVATE_KEY
|
||||||
|
strato_known_hosts:
|
||||||
|
environment: STRATO_KNOWN_HOSTS
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
.developer-feed {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 3px solid var(--theme-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
border-bottom: 1px solid var(--gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_header svg,
|
||||||
|
.developer-feed_icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_header svg {
|
||||||
|
color: var(--theme-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_group + .developer-feed_group {
|
||||||
|
border-top: 1px solid var(--gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_group h3 {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-sm) var(--space-lg);
|
||||||
|
color: var(--gray-700);
|
||||||
|
background: var(--gray-50);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_group ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_group li + li {
|
||||||
|
border-top: 1px solid var(--gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed a {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
|
gap: var(--space-md);
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
color: var(--gray-900);
|
||||||
|
text-decoration: none;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed a:hover,
|
||||||
|
.developer-feed a:focus-visible {
|
||||||
|
background: var(--theme-light);
|
||||||
|
border-left-color: var(--theme-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
color: var(--theme-color);
|
||||||
|
background: var(--theme-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_message {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_message strong,
|
||||||
|
.developer-feed_description {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_message strong {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_description,
|
||||||
|
.developer-feed_meta {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--gray-600);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_meta {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
color: var(--gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_more {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-md);
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--gray-200);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--theme-color);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-feed_more:hover,
|
||||||
|
.developer-feed_more:focus-visible {
|
||||||
|
background: var(--theme-light);
|
||||||
|
}
|
||||||
@@ -4,16 +4,28 @@
|
|||||||
// Initialisierung
|
// Initialisierung
|
||||||
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/initialise.php';
|
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/initialise.php';
|
||||||
|
|
||||||
|
require_once __DIR__.'/util/developer_helpers.php';
|
||||||
|
|
||||||
// Zugriff nur für Entwickler
|
// Zugriff nur für Entwickler
|
||||||
if (!isset($groups["Entwickler"]) || $groups["Entwickler"] != 1) {
|
if (!isset($groups["Entwickler"]) || $groups["Entwickler"] != 1) {
|
||||||
header("Location: {$g_httpHomePath}");
|
header("Location: {$g_httpHomePath}");
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$feedError = '';
|
||||||
|
try {
|
||||||
|
$feedItems = developer_feed_items();
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
$feedItems = [];
|
||||||
|
$feedError = 'Der Git-RSS-Feed ist momentan nicht erreichbar.';
|
||||||
|
}
|
||||||
|
$feedGroups = developer_feed_groups($feedItems);
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de" dir="ltr">
|
<html lang="de" dir="ltr">
|
||||||
<head>
|
<head>
|
||||||
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/html_head.php'; ?>
|
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/html_head.php'; ?>
|
||||||
|
<link rel="stylesheet" href="/intranet_repo/projects/_central/layouts/developerpanel.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="theme-green">
|
<body class="theme-green">
|
||||||
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/header.php'; ?>
|
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/header.php'; ?>
|
||||||
@@ -26,26 +38,81 @@
|
|||||||
<div class="mainpage_container">
|
<div class="mainpage_container">
|
||||||
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet', '_blank', 'noopener,noreferrer');">
|
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet', '_blank', 'noopener,noreferrer');">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M23.546 10.93 13.067.452a1.55 1.55 0 0 0-2.188 0L8.708 2.627l2.76 2.76a1.838 1.838 0 0 1 2.327 2.341l2.658 2.66a1.837 1.837 0 1 1-1.102 1.033L12.87 8.94v6.526a1.837 1.837 0 1 1-1.512-.053V8.828a1.838 1.838 0 0 1-.999-2.409L7.636 3.697.45 10.881a1.55 1.55 0 0 0 0 2.188l10.48 10.48a1.55 1.55 0 0 0 2.186 0l10.43-10.43a1.55 1.55 0 0 0 0-2.188Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M23.546 10.93 13.067.452a1.55 1.55 0 0 0-2.188 0L8.708 2.627l2.76 2.76a1.838 1.838 0 0 1 2.327 2.341l2.658 2.66a1.837 1.837 0 1 1-1.102 1.033L12.87 8.94v6.526a1.837 1.837 0 1 1-1.512-.053V8.828a1.838 1.838 0 0 1-.999-2.409L7.636 3.697.45 10.881a1.55 1.55 0 0 0 0 2.188l10.48 10.48a1.55 1.55 0 0 0 2.186 0l10.43-10.43a1.55 1.55 0 0 0 0-2.188Z"/></svg>
|
||||||
<div class="text">Git Repository</div>
|
<div class="text">Quellcode (Git)</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://pgadmin.pelpino.de/', '_blank', 'noopener,noreferrer');">
|
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://pgadmin.pelpino.de/', '_blank', 'noopener,noreferrer');">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M12 2C7.03 2 3 3.79 3 6v12c0 2.21 4.03 4 9 4s9-1.79 9-4V6c0-2.21-4.03-4-9-4Zm0 2c4.42 0 7 .99 7 2s-2.58 2-7 2-7-.99-7-2 2.58-2 7-2Zm0 16c-4.42 0-7-.99-7-2v-2.13C6.63 16.57 9.14 17 12 17s5.37-.43 7-1.13V18c0 1.01-2.58 2-7 2Zm0-5c-4.42 0-7-.99-7-2v-2.13C6.63 11.57 9.14 12 12 12s5.37-.43 7-1.13V13c0 1.01-2.58 2-7 2Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M12 2C7.03 2 3 3.79 3 6v12c0 2.21 4.03 4 9 4s9-1.79 9-4V6c0-2.21-4.03-4-9-4Zm0 2c4.42 0 7 .99 7 2s-2.58 2-7 2-7-.99-7-2 2.58-2 7-2Zm0 16c-4.42 0-7-.99-7-2v-2.13C6.63 16.57 9.14 17 12 17s5.37-.43 7-1.13V18c0 1.01-2.58 2-7 2Zm0-5c-4.42 0-7-.99-7-2v-2.13C6.63 11.57 9.14 12 12 12s5.37-.43 7-1.13V13c0 1.01-2.58 2-7 2Z"/></svg>
|
||||||
<div class="text">Postgres-Datenbank</div>
|
<div class="text">Datenbank (Postgres)</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet/projects/2', '_blank', 'noopener,noreferrer');">
|
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet/projects/2', '_blank', 'noopener,noreferrer');">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M3 3h5v18H3V3Zm6.5 0h5v11h-5V3ZM16 3h5v7h-5V3Zm0 8.5h5V21h-5v-9.5ZM9.5 15.5h5V21h-5v-5.5Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M3 3h5v18H3V3Zm6.5 0h5v11h-5V3ZM16 3h5v7h-5V3Zm0 8.5h5V21h-5v-9.5ZM9.5 15.5h5V21h-5v-5.5Z"/></svg>
|
||||||
<div class="text">Kanban-Board</div>
|
<div class="text">Projekt-Board</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet/wiki', '_blank', 'noopener,noreferrer');">
|
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet/wiki', '_blank', 'noopener,noreferrer');">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M4 3a3 3 0 0 0-3 3v13.5A1.5 1.5 0 0 0 2.5 21H11V5.5A2.5 2.5 0 0 0 8.5 3H4Zm16 0h-4.5A2.5 2.5 0 0 0 13 5.5V21h8.5a1.5 1.5 0 0 0 1.5-1.5V6a3 3 0 0 0-3-3Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M4 3a3 3 0 0 0-3 3v13.5A1.5 1.5 0 0 0 2.5 21H11V5.5A2.5 2.5 0 0 0 8.5 3H4Zm16 0h-4.5A2.5 2.5 0 0 0 13 5.5V21h8.5a1.5 1.5 0 0 0 1.5-1.5V6a3 3 0 0 0-3-3Z"/></svg>
|
||||||
<div class="text">Wiki</div>
|
<div class="text">Wiki</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<section class="content_panel developer-feed" aria-labelledby="developer-feed-title">
|
||||||
|
<div class="developer-feed_header">
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.002 2.725a.75.75 0 0 1 .797-.699C8.79 2.42 13.58 7.21 13.974 13.201a.75.75 0 0 1-1.497.098 10.5 10.5 0 0 0-9.776-9.776.747.747 0 0 1-.7-.798ZM2.84 7.05h-.002a7 7 0 0 1 6.113 6.111.75.75 0 0 1-1.49.178 5.5 5.5 0 0 0-4.8-4.8.75.75 0 0 1 .179-1.489M2 13a1 1 0 1 1 2 0 1 1 0 0 1-2 0"/></svg>
|
||||||
|
<h2 id="developer-feed-title">Git-Aktivität</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($feedItems === []): ?>
|
||||||
|
<p class="developer-feed_empty"><?= $feedError ?: 'Noch keine Git-Aktivität vorhanden.' ?></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div id="developer-feed-list">
|
||||||
|
<?php $itemIndex = 0; ?>
|
||||||
|
<?php foreach ($feedGroups as $group => $items): ?>
|
||||||
|
<section class="developer-feed_group"<?= $itemIndex >= DEVELOPER_FEED_LIMIT ? ' hidden' : '' ?>>
|
||||||
|
<h3><?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?></h3>
|
||||||
|
<ul>
|
||||||
|
<?php foreach ($items as $item): ?>
|
||||||
|
<li<?= $itemIndex++ >= DEVELOPER_FEED_LIMIT ? ' hidden' : '' ?>>
|
||||||
|
<a href="<?= htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8') ?>" target="_blank" rel="noopener noreferrer">
|
||||||
|
<span class="developer-feed_icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="<?= developer_feed_icon($item['title']) ?>"/></svg></span>
|
||||||
|
<span class="developer-feed_message">
|
||||||
|
<strong><?= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8') ?></strong>
|
||||||
|
<?php if ($item['description'] !== ''): ?>
|
||||||
|
<span class="developer-feed_description"><?= htmlspecialchars($item['description'], ENT_QUOTES, 'UTF-8') ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="developer-feed_meta">
|
||||||
|
<span><?= htmlspecialchars($item['author'], ENT_QUOTES, 'UTF-8') ?></span>
|
||||||
|
<?php if ($item['timestamp'] > 0): ?><span>·</span><time datetime="<?= date('c', $item['timestamp']) ?>"><?= date('d.m.Y · H:i', $item['timestamp']) ?></time><?php endif; ?>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php if (count($feedItems) > DEVELOPER_FEED_LIMIT): ?>
|
||||||
|
<button class="developer-feed_more" type="button" id="developer-feed-more" aria-expanded="false" aria-controls="developer-feed-list">Mehr anzeigen</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/footer.php'; ?>
|
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/footer.php'; ?>
|
||||||
|
|
||||||
|
<?php if (count($feedItems) > DEVELOPER_FEED_LIMIT): ?>
|
||||||
|
<script>
|
||||||
|
document.getElementById('developer-feed-more').addEventListener('click', function () {
|
||||||
|
const expanded = this.getAttribute('aria-expanded') === 'true';
|
||||||
|
const list = document.getElementById('developer-feed-list');
|
||||||
|
list.querySelectorAll('li').forEach((item, index) => item.hidden = expanded && index >= <?= DEVELOPER_FEED_LIMIT ?>);
|
||||||
|
list.querySelectorAll('.developer-feed_group').forEach(group => group.hidden = !group.querySelector('li:not([hidden])'));
|
||||||
|
this.setAttribute('aria-expanded', String(!expanded));
|
||||||
|
this.textContent = expanded ? 'Mehr anzeigen' : 'Weniger anzeigen';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
const DEVELOPER_FEED_URL = 'https://git.pelpino.de/NAK-Reinickendorf/intranet.rss';
|
||||||
|
const DEVELOPER_REPO_URL = 'https://git.pelpino.de/NAK-Reinickendorf/intranet';
|
||||||
|
const DEVELOPER_FEED_LIMIT = 7;
|
||||||
|
|
||||||
|
function developer_feed_plain_text(string $value): string
|
||||||
|
{
|
||||||
|
return trim(preg_replace(
|
||||||
|
'/\s+/u',
|
||||||
|
' ',
|
||||||
|
strip_tags(preg_replace('/>\s*</', '> <', html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8')) ?? '')
|
||||||
|
) ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function developer_feed_items(string $url = DEVELOPER_FEED_URL): array
|
||||||
|
{
|
||||||
|
$handle = curl_init($url);
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new RuntimeException('RSS-Feed konnte nicht initialisiert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_setopt_array($handle, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => ['Accept: application/rss+xml, application/xml;q=0.9'],
|
||||||
|
CURLOPT_USERAGENT => 'NAK-Reinickendorf-Intranet/1.0',
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 2,
|
||||||
|
CURLOPT_TIMEOUT => 4,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 3,
|
||||||
|
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||||
|
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = curl_exec($handle);
|
||||||
|
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
|
||||||
|
$error = curl_error($handle);
|
||||||
|
} finally {
|
||||||
|
curl_close($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response === false || $status < 200 || $status >= 300) {
|
||||||
|
throw new RuntimeException($error !== '' ? $error : 'HTTP ' . $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return developer_parse_feed($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function developer_parse_feed(string $xml): array
|
||||||
|
{
|
||||||
|
$previous = libxml_use_internal_errors(true);
|
||||||
|
try {
|
||||||
|
$feed = simplexml_load_string($xml, SimpleXMLElement::class, LIBXML_NONET | LIBXML_NOCDATA);
|
||||||
|
} finally {
|
||||||
|
libxml_clear_errors();
|
||||||
|
libxml_use_internal_errors($previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($feed === false || !isset($feed->channel->item)) {
|
||||||
|
throw new RuntimeException('Ungültiger RSS-Feed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($feed->channel->item as $item) {
|
||||||
|
$title = developer_feed_plain_text((string) $item->title);
|
||||||
|
if ($title === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$description = developer_feed_plain_text((string) $item->description);
|
||||||
|
$author = trim((string) $item->children('http://purl.org/dc/elements/1.1/')->creator ?: (string) $item->author);
|
||||||
|
if (preg_match('/\(([^)]+)\)\s*$/u', $author, $match) === 1) {
|
||||||
|
$author = trim($match[1]);
|
||||||
|
} else {
|
||||||
|
$author = trim(preg_replace('/<[^>]+>|\S+@\S+/u', '', $author) ?? '');
|
||||||
|
}
|
||||||
|
$link = trim((string) $item->link);
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'title' => $title,
|
||||||
|
'description' => $description !== $title ? $description : '',
|
||||||
|
'url' => filter_var($link, FILTER_VALIDATE_URL) && str_starts_with($link, DEVELOPER_REPO_URL) ? $link : DEVELOPER_REPO_URL,
|
||||||
|
'author' => $author !== '' ? $author : 'Git',
|
||||||
|
'timestamp' => strtotime((string) $item->pubDate) ?: 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($items, static fn(array $a, array $b): int => $b['timestamp'] <=> $a['timestamp']);
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function developer_feed_groups(array $items, ?DateTimeImmutable $now = null): array
|
||||||
|
{
|
||||||
|
$now ??= new DateTimeImmutable();
|
||||||
|
$today = $now->setTime(0, 0);
|
||||||
|
$week = $today->modify('monday this week');
|
||||||
|
$month = $today->modify('first day of this month');
|
||||||
|
$year = $today->setDate((int) $today->format('Y'), 1, 1);
|
||||||
|
$starts = [
|
||||||
|
'Heute' => $today,
|
||||||
|
'Gestern' => $today->modify('-1 day'),
|
||||||
|
'Diese Woche' => $week,
|
||||||
|
'Letzte Woche' => $week->modify('-1 week'),
|
||||||
|
'Diesen Monat' => $month,
|
||||||
|
'Letzten Monat' => $month->modify('-1 month'),
|
||||||
|
'Dieses Jahr' => $year,
|
||||||
|
'Letztes Jahr' => $year->modify('-1 year'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$groups = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$date = (new DateTimeImmutable('@' . (int) $item['timestamp']))->setTimezone($now->getTimezone());
|
||||||
|
$group = 'Älter';
|
||||||
|
foreach ($starts as $label => $start) {
|
||||||
|
if ($date >= $start) {
|
||||||
|
$group = $label;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$groups[$group][] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function developer_feed_icon(string $title): string
|
||||||
|
{
|
||||||
|
$title = strtolower($title);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
str_contains($title, 'merge pull'), str_starts_with($title, 'merge ') => 'M7 3a3 3 0 1 0-2 2.83v12.34a3 3 0 1 0 2 0V9h4a4 4 0 0 1 4 4v1.17a3 3 0 1 0 2 0V13a6 6 0 0 0-6-6H7V5.83A3 3 0 0 0 7 3Z',
|
||||||
|
preg_match('/\b(fix|fixed|bugfix|hotfix|repair)\b/u', $title) === 1 => 'M14.7 6.3a5 5 0 0 0-6.47 6.47l-5.94 5.94a1 1 0 0 0 0 1.42l1.58 1.58a1 1 0 0 0 1.42 0l5.94-5.94A5 5 0 0 0 17.7 9.3l-3 3-3-3 3-3Z',
|
||||||
|
preg_match('/\b(remove|removed|delete|deleted|revert)\b/u', $title) === 1 => 'M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 12H7L6 9Zm3 2v8h2v-8H9Zm4 0v8h2v-8h-2Z',
|
||||||
|
preg_match('/\b(refactor|cleanup|clean up|simplif|optimi[sz])\w*/u', $title) === 1 => 'M7 3a3 3 0 1 0-2 2.83v12.34a3 3 0 1 0 2 0V14h3a5 5 0 0 0 5-5V7.83a3 3 0 1 0-2 0V9a3 3 0 0 1-3 3H7V5.83A3 3 0 0 0 7 3Z',
|
||||||
|
preg_match('/\b(doc|docs|readme|wiki|tutorial)\w*/u', $title) === 1 => 'M4 3h7a2 2 0 0 1 2 2v15a3 3 0 0 0-3-3H4V3Zm16 0h-5v14h5V3Zm0 16h-5a1 1 0 0 0-1 1h6v-1Z',
|
||||||
|
preg_match('/\b(add|added|new|create|created|introduce|introduced|implement|implemented)\b/u', $title) === 1 => 'M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5Z',
|
||||||
|
default => 'M12 3a4 4 0 0 0-3.87 3H3v2h5.13a4 4 0 0 0 7.74 0H21V6h-5.13A4 4 0 0 0 12 3Zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm0 10a4 4 0 0 0-3.87 3H3v2h5.13a4 4 0 0 0 7.74 0H21v-2h-5.13A4 4 0 0 0 12 15Zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
+264
-351
@@ -1,351 +1,264 @@
|
|||||||
#! /usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from calendar import monthrange
|
import datetime as dt
|
||||||
from fpdf import FPDF
|
import os
|
||||||
import datetime
|
import re
|
||||||
from datetime import datetime as datelul
|
from urllib.parse import parse_qsl, urlencode, urlsplit
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
import requests
|
import requests
|
||||||
import os
|
from bs4 import BeautifulSoup
|
||||||
|
from fpdf import FPDF
|
||||||
|
|
||||||
def is_invalid_date(date_str):
|
MONTHS = (
|
||||||
try:
|
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||||
datelul.strptime(date_str, "%Y-%m-%d")
|
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||||
return False
|
)
|
||||||
except ValueError:
|
WEEKDAYS = ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So")
|
||||||
return True
|
DATE_PATTERNS = (
|
||||||
|
(re.compile(r"(?<!\d)(\d{4})-(\d{1,2})-(\d{1,2})(?!\d)"), (1, 2, 3)),
|
||||||
|
(re.compile(r"(?<!\d)(\d{1,2})\.(\d{1,2})\.(\d{4})(?!\d)"), (3, 2, 1)),
|
||||||
def pdf_safe(text):
|
)
|
||||||
if text is None:
|
PDF_TRANSLATION = str.maketrans({
|
||||||
return ""
|
"„": '"', "“": '"', "‚": "'", "‘": "'", "’": "'",
|
||||||
|
"–": "-", "—": "-", "…": "...", "\xa0": " ",
|
||||||
text = str(text)
|
})
|
||||||
|
TABLE_COLUMNS = (
|
||||||
replacements = {
|
(20, "Datum", "C"),
|
||||||
"„": '"',
|
(15, "Tag", "C"),
|
||||||
"“": '"',
|
(20, "Uhrzeit", "C"),
|
||||||
"‚": "'",
|
(75, "Veranstaltung", "L"),
|
||||||
"‘": "'",
|
(60, "Ort", "C"),
|
||||||
"’": "'",
|
)
|
||||||
"–": "-",
|
|
||||||
"—": "-",
|
|
||||||
"…": "...",
|
def pdf_safe(text):
|
||||||
"\xa0": " ",
|
text = "" if text is None else str(text)
|
||||||
}
|
return text.translate(PDF_TRANSLATION).encode("latin-1", "replace").decode("latin-1")
|
||||||
|
|
||||||
for old, new in replacements.items():
|
|
||||||
text = text.replace(old, new)
|
def parse_date(value):
|
||||||
|
for pattern, order in DATE_PATTERNS:
|
||||||
# Alles, was nicht latin-1-kompatibel ist, verwerfen
|
match = pattern.search(str(value or ""))
|
||||||
return text.encode("latin-1", "replace").decode("latin-1")
|
if match:
|
||||||
|
parts = match.groups()
|
||||||
|
try:
|
||||||
#### GENERAL DATE CONFIGURATION ####
|
return dt.date(*(int(parts[index - 1]) for index in order))
|
||||||
months_numeric = ["JANUAR", "FEBRUAR", "MÄRZ", "APRIL", "MAI", "JUNI", "JULI", "AUGUST", "SEPTEMBER", "OKTOBER", "NOVEMBER", "DEZEMBER"]
|
except ValueError:
|
||||||
months_numeric_lower = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
|
return None
|
||||||
date = datetime.datetime.now()
|
return None
|
||||||
month_range = monthrange(date.year, date.month)
|
|
||||||
month = months_numeric[date.month - 1]
|
|
||||||
weekdays_list = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
def calendar_cells(html):
|
||||||
counter = 2
|
cells = {}
|
||||||
actual_day = datetime.date.today()
|
for element in BeautifulSoup(html, "html.parser").find_all(attrs={"data-date": True}):
|
||||||
first_day = actual_day.replace(day=1)
|
day = parse_date(element.get("data-date"))
|
||||||
weekday = first_day.weekday()
|
if day:
|
||||||
|
cells.setdefault(day, element)
|
||||||
#### URL HTML PARSING ####
|
return cells
|
||||||
url = os.environ.get("CKAL_CALENDAR_URL")
|
|
||||||
if not url:
|
|
||||||
raise RuntimeError("CKAL_CALENDAR_URL ist nicht gesetzt.")
|
def has_style(value, declaration):
|
||||||
result = requests.get(url, timeout=30)
|
return bool(value) and declaration in value.replace(" ", "").lower()
|
||||||
result.raise_for_status()
|
|
||||||
|
|
||||||
doc = BeautifulSoup(result.text, "html.parser")
|
def event_rows(cell):
|
||||||
table = doc.find_all("tbody")[0]
|
blocks = cell.find_all("div", class_="calEventBox")
|
||||||
tablerows = table.find_all("tr")
|
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
|
||||||
|
|
||||||
#### PDF GENERATING PROGRESS ####
|
for index, title in enumerate(titles):
|
||||||
pdf = FPDF("P", "mm", "A4")
|
time_text, place = "", "--"
|
||||||
pdf.set_auto_page_break(auto=True, margin=15)
|
block = blocks[index] if index < len(blocks) else None
|
||||||
pdf.add_page()
|
details = block and block.find(
|
||||||
|
style=lambda value: has_style(value, "font-weight:normal")
|
||||||
# Header
|
)
|
||||||
pdf.set_font("helvetica", "B", 16)
|
if details:
|
||||||
pdf.cell(160, 7, pdf_safe("Terminübersicht: " + str(month) + " " + str(date.year)), 0, 1)
|
parts = [part.strip() for part in details.stripped_strings if part.strip()]
|
||||||
|
if parts and ":" in parts[0]:
|
||||||
path = os.path.join(os.path.dirname(__file__), "nak_logo.png")
|
time_text = parts.pop(0)
|
||||||
assert os.path.isfile(path)
|
if parts:
|
||||||
pdf.image(path, 188, 9.5, 11)
|
place = parts[0].lstrip(", ") or "--"
|
||||||
|
yield time_text, title.get_text(strip=True), place
|
||||||
pdf.set_font("helvetica", "", 10)
|
|
||||||
pdf.cell(160, 5, pdf_safe("der Gemeinde Berlin-Reinickendorf"), 0, 1)
|
|
||||||
pdf.cell(0, 2, " ", 0, 1)
|
def next_month(month):
|
||||||
|
if month.month == 12:
|
||||||
# Tablehead
|
return dt.date(month.year + 1, 1, 1)
|
||||||
pdf.set_font("helvetica", "B", 10)
|
return dt.date(month.year, month.month + 1, 1)
|
||||||
pdf.cell(20, 5, "Datum", 1, 0, "C")
|
|
||||||
pdf.cell(15, 5, "Tag", 1, 0, "C")
|
|
||||||
pdf.cell(20, 5, "Uhrzeit", 1, 0, "C")
|
def target_months(today):
|
||||||
pdf.cell(75, 5, "Veranstaltung", 1, 0)
|
current = today.replace(day=1)
|
||||||
pdf.cell(60, 5, "Ort", 1, 1, "C")
|
return current, next_month(current)
|
||||||
|
|
||||||
pdf.set_font("helvetica", "", 8)
|
|
||||||
|
def month_days(month):
|
||||||
# Startposition (heutiges Datum) suchen
|
count = (next_month(month) - month).days
|
||||||
iStart = None
|
return (month + dt.timedelta(days=offset) for offset in range(count))
|
||||||
jStart = None
|
|
||||||
|
|
||||||
for i in range(len(tablerows)):
|
def month_end(month):
|
||||||
table_cells = tablerows[i].find_all("td")
|
return next_month(month) - dt.timedelta(days=1)
|
||||||
for j in range(len(table_cells)):
|
|
||||||
table_dates = table_cells[j].get("data-date", "")
|
|
||||||
date_for_table = str(table_dates).split("-")
|
def missing_days(cells, month):
|
||||||
if len(date_for_table) == 3:
|
return [day for day in month_days(month) if day not in cells]
|
||||||
if (
|
|
||||||
date_for_table[0] == str(date.strftime("%Y"))
|
|
||||||
and date_for_table[1] == str(date.strftime("%m"))
|
def require_month(cells, month):
|
||||||
and date_for_table[2] == str(date.strftime("%d"))
|
missing = missing_days(cells, month)
|
||||||
):
|
if not missing:
|
||||||
iStart = i
|
return
|
||||||
jStart = j
|
|
||||||
break
|
label = month.strftime("%m.%Y")
|
||||||
if jStart is not None:
|
if len(missing) == (next_month(month) - month).days:
|
||||||
break
|
raise RuntimeError(f"CKalender enthält keine Daten für {label}.")
|
||||||
|
|
||||||
if jStart is None:
|
preview = ", ".join(day.strftime("%d.%m.") for day in missing[:3])
|
||||||
raise RuntimeError("Heutiges Datum wurde im Kalender nicht gefunden.")
|
suffix = " …" if len(missing) > 3 else ""
|
||||||
|
raise RuntimeError(
|
||||||
j = jStart
|
f"CKalender-Daten für {label} sind unvollständig "
|
||||||
|
f"({len(missing)} Tage fehlen: {preview}{suffix})."
|
||||||
for i in range(len(tablerows)):
|
)
|
||||||
table_cells = tablerows[i].find_all("td")
|
|
||||||
table_dates = table_cells[j].get("data-date", "")
|
|
||||||
date_for_table = str(table_dates).split("-")
|
def calendar_url_for_period(url, start, end, kind):
|
||||||
|
parts = urlsplit(url)
|
||||||
if len(date_for_table) != 3:
|
overridden = {"calendar_kind", "from_date", "until_date"}
|
||||||
break
|
query = [
|
||||||
|
item for item in parse_qsl(parts.query, keep_blank_values=True)
|
||||||
if is_invalid_date(date_for_table[0] + "-" + date_for_table[1] + "-" + date_for_table[2]):
|
if item[0] not in overridden
|
||||||
break
|
]
|
||||||
|
query += [
|
||||||
# Trennlinie
|
("calendar_kind", kind),
|
||||||
pdf.set_fill_color(130)
|
("from_date", start.isoformat()),
|
||||||
pdf.cell(0, 0.3, " ", 0, 1, fill=True)
|
("until_date", end.isoformat()),
|
||||||
|
]
|
||||||
# Wochentag korrigieren
|
return parts._replace(query=urlencode(query)).geturl()
|
||||||
if weekday == 7:
|
|
||||||
weekday = 0
|
|
||||||
|
def fetch_calendar_cells(url, start, end, kind):
|
||||||
# Zeilenfarbe
|
response = requests.get(calendar_url_for_period(url, start, end, kind), timeout=30)
|
||||||
if counter % 2 == 0:
|
response.raise_for_status()
|
||||||
pdf.set_fill_color(255, 255, 255)
|
return calendar_cells(response.text)
|
||||||
else:
|
|
||||||
pdf.set_fill_color(220)
|
|
||||||
|
def load_calendar_cells(url, today):
|
||||||
# Samstag/Sonntag hervorheben
|
months = target_months(today)
|
||||||
pdf.set_font("helvetica", "", 8)
|
cells = fetch_calendar_cells(url, months[0], month_end(months[1]), "year")
|
||||||
if weekday == 2 or weekday == 6:
|
|
||||||
pdf.set_font("helvetica", "B", 8)
|
for month in months:
|
||||||
if weekday == 6:
|
if not missing_days(cells, month):
|
||||||
pdf.set_fill_color(170, 210, 255)
|
continue
|
||||||
|
end = month_end(month)
|
||||||
# Heutigen Tag gelb markieren
|
fetched = fetch_calendar_cells(url, month, end, "month")
|
||||||
if (
|
cells.update({
|
||||||
date_for_table[0] == str(date.strftime("%Y"))
|
day: cell for day, cell in fetched.items()
|
||||||
and date_for_table[1] == str(date.strftime("%m"))
|
if month <= day <= end
|
||||||
and date_for_table[2] == str(date.strftime("%d"))
|
})
|
||||||
):
|
return cells
|
||||||
pdf.set_fill_color(255, 255, 0)
|
|
||||||
|
|
||||||
pdf.cell(20, 4, pdf_safe(date_for_table[2] + "." + date_for_table[1] + "." + date_for_table[0]), 0, 0, "C", fill=True)
|
def add_table_header(pdf):
|
||||||
|
pdf.set_font("helvetica", "B", 10)
|
||||||
event_full_block = table_cells[j].find_all("div", class_="calEventBox")
|
for index, (width, label, align) in enumerate(TABLE_COLUMNS):
|
||||||
events = table_cells[j].find_all(
|
pdf.cell(width, 5, label, 1, int(index == len(TABLE_COLUMNS) - 1), align)
|
||||||
style=lambda style: style
|
|
||||||
and "margin-right:.2em" in style.replace(" ", "")
|
|
||||||
)
|
def add_footer(pdf, now):
|
||||||
|
pdf.set_font("helvetica", "", 8)
|
||||||
pdf.cell(15, 4, pdf_safe(weekdays_list[weekday]), 0, 0, "C", fill=True)
|
pdf.cell(0, 1, " ", 0, 1)
|
||||||
|
pdf.cell(0, 0.1, " ", 1, 1)
|
||||||
if len(events) == 0:
|
pdf.cell(0, 1, " ", 0, 1)
|
||||||
pdf.cell(0, 4, " ", 0, 1, fill=True)
|
pdf.cell(153, 3, "gemeindeleitung@nak-reinickendorf.de", 0, 0)
|
||||||
|
pdf.cell(30, 3, pdf_safe("Klixstraße 17, 13403 Berlin"), 0, 1)
|
||||||
for k in range(len(events)):
|
pdf.set_font("helvetica", "I", 8)
|
||||||
if k > 0:
|
pdf.cell(153, 3, f"Berlin, {now:%d.%m.%Y %H:%M}", 0, 0)
|
||||||
pdf.cell(35, 4, " ", 0, 0, fill=True)
|
pdf.set_font("helvetica", "", 8)
|
||||||
|
pdf.cell(30, 3, "nak-reinickendorf.de", 0, 1)
|
||||||
event_place = "--"
|
|
||||||
pdf.cell(20, 4, " ", 0, 0, "C", fill=True)
|
|
||||||
|
def add_month(pdf, cells, month, today=None):
|
||||||
if k < len(event_full_block):
|
for day in month_days(month):
|
||||||
event_time = event_full_block[k].find_all(style="font-weight:normal")
|
pdf.set_fill_color(130)
|
||||||
else:
|
pdf.cell(0, 0.3, " ", 0, 1, fill=True)
|
||||||
event_time = []
|
pdf.set_fill_color(255 if day.day % 2 else 220)
|
||||||
|
pdf.set_font("helvetica", "B" if day.weekday() >= 5 else "", 8)
|
||||||
if len(event_time) > 0:
|
if day.weekday() == 6:
|
||||||
try:
|
pdf.set_fill_color(170, 210, 255)
|
||||||
elements = str(event_time[0]).split("<")
|
if day == today:
|
||||||
except Exception:
|
pdf.set_fill_color(255, 255, 0)
|
||||||
print("error1")
|
|
||||||
else:
|
pdf.cell(20, 4, day.strftime("%d.%m.%Y"), 0, 0, "C", fill=True)
|
||||||
if len(elements) > 1 and ">" in elements[1]:
|
pdf.cell(15, 4, WEEKDAYS[day.weekday()], 0, 0, "C", fill=True)
|
||||||
time_text = elements[1].split(">")[1]
|
|
||||||
if ":" in str(time_text):
|
events = list(event_rows(cells[day])) if day in cells else []
|
||||||
pdf.set_x(pdf.get_x() - 20)
|
if not events:
|
||||||
pdf.cell(20, 4, pdf_safe(time_text), 0, 0, "C", fill=True)
|
pdf.cell(0, 4, " ", 0, 1, fill=True)
|
||||||
|
continue
|
||||||
if len(elements) > 2 and ">" in elements[2]:
|
|
||||||
place_text = elements[2].split(">")[1]
|
for index, (time_text, title, place) in enumerate(events):
|
||||||
if place_text == "":
|
if index:
|
||||||
event_place = "--"
|
pdf.cell(35, 4, " ", 0, 0, fill=True)
|
||||||
else:
|
pdf.cell(20, 4, pdf_safe(time_text), 0, 0, "C", fill=True)
|
||||||
if str(place_text).startswith(","):
|
pdf.cell(75, 4, pdf_safe(title), 0, 0, fill=True)
|
||||||
event_place = str(place_text)[2:]
|
pdf.cell(60, 4, pdf_safe(place), 0, 1, "C", fill=True)
|
||||||
else:
|
|
||||||
event_place = str(place_text)
|
|
||||||
|
def add_month_section(pdf, cells, month, now, today=None, planning_note=False):
|
||||||
event_name = events[k].get_text(strip=True) if len(events) > 0 else ""
|
name = MONTHS[month.month - 1]
|
||||||
pdf.cell(75, 4, pdf_safe(event_name), 0, 0, fill=True)
|
pdf.set_font("helvetica", "B", 16)
|
||||||
pdf.cell(60, 4, pdf_safe(event_place), 0, 1, "C", fill=True)
|
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
|
||||||
|
pdf.set_font("helvetica", "", 10)
|
||||||
counter += 1
|
subtitle = (
|
||||||
weekday += 1
|
f"Die Termine für {name} befinden sich noch in Planung und können sich ändern."
|
||||||
|
if planning_note else "der Gemeinde Berlin-Reinickendorf"
|
||||||
# Fußzeile
|
)
|
||||||
pdf.set_font("helvetica", "", 8)
|
pdf.cell(160, 5, pdf_safe(subtitle), 0, 1)
|
||||||
pdf.cell(0, 1, " ", 0, 1)
|
pdf.cell(0, 2, " ", 0, 1)
|
||||||
pdf.cell(0, 0.1, " ", 1, 1)
|
add_table_header(pdf)
|
||||||
pdf.cell(0, 1, " ", 0, 1)
|
add_month(pdf, cells, month, today)
|
||||||
pdf.cell(153, 3, pdf_safe("gemeindeleitung@nak-reinickendorf.de"), 0, 0)
|
add_footer(pdf, now)
|
||||||
pdf.cell(30, 3, pdf_safe("Klixstraße 17, 13403 Berlin"), 0, 1)
|
|
||||||
pdf.set_font("helvetica", "I", 8)
|
|
||||||
pdf.cell(153, 3, pdf_safe("Berlin, " + str(date.strftime("%d.%m.%Y %H:%M"))), 0, 0)
|
def create_pdf(cells, output_path, now=None):
|
||||||
pdf.set_font("helvetica", "", 8)
|
now = now or dt.datetime.now()
|
||||||
pdf.cell(30, 3, pdf_safe("nak-reinickendorf.de"), 0, 1)
|
today = now.date()
|
||||||
|
months = target_months(today)
|
||||||
# Zweite Monats-Spalte / Folgemonat
|
for month in months:
|
||||||
if jStart + 1 < 12:
|
require_month(cells, month)
|
||||||
pdf.cell(0, 4, " ", 0, 1)
|
|
||||||
pdf.cell(0, 2, " ", 0, 1)
|
pdf = FPDF("P", "mm", "A4")
|
||||||
|
pdf.set_auto_page_break(auto=True, margin=15)
|
||||||
pdf.set_font("helvetica", "B", 16)
|
pdf.add_page()
|
||||||
pdf.cell(160, 7, pdf_safe("Terminübersicht: " + str(months_numeric[date.month] + " " + str(date.year))), 0, 1)
|
|
||||||
|
logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
|
||||||
pdf.set_font("helvetica", "", 10)
|
if not os.path.isfile(logo):
|
||||||
pdf.cell(
|
raise RuntimeError(f"Logo fehlt: {logo}")
|
||||||
160,
|
pdf.image(logo, 188, 9.5, 11)
|
||||||
5,
|
|
||||||
pdf_safe("Die Termine für " + str(months_numeric_lower[date.month]) + " befinden sich noch in Planung und können sich ändern."),
|
for index, month in enumerate(months):
|
||||||
0,
|
if index:
|
||||||
1,
|
pdf.cell(0, 6, " ", 0, 1)
|
||||||
)
|
add_month_section(
|
||||||
pdf.cell(0, 2, " ", 0, 1)
|
pdf, cells, month, now,
|
||||||
|
today=today if index == 0 else None,
|
||||||
# Tablehead
|
planning_note=index == 1,
|
||||||
pdf.set_font("helvetica", "B", 10)
|
)
|
||||||
pdf.cell(20, 5, "Datum", 1, 0, "C")
|
|
||||||
pdf.cell(15, 5, "Tag", 1, 0, "C")
|
output_dir = os.path.dirname(output_path)
|
||||||
pdf.cell(20, 5, "Uhrzeit", 1, 0, "C")
|
if output_dir:
|
||||||
pdf.cell(75, 5, "Veranstaltung", 1, 0)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
pdf.cell(60, 5, "Ort", 1, 1, "C")
|
pdf.output(output_path)
|
||||||
|
|
||||||
j = jStart + 1
|
|
||||||
|
def main():
|
||||||
for i in range(len(tablerows)):
|
url = os.environ.get("CKAL_CALENDAR_URL")
|
||||||
table_cells = tablerows[i].find_all("td")
|
if not url:
|
||||||
table_dates = table_cells[j].get("data-date", "")
|
raise RuntimeError("CKAL_CALENDAR_URL ist nicht gesetzt.")
|
||||||
date_for_table = str(table_dates).split("-")
|
|
||||||
|
now = dt.datetime.now()
|
||||||
if len(date_for_table) != 3:
|
cells = load_calendar_cells(url, now.date())
|
||||||
break
|
create_pdf(cells, os.environ.get("PDF_OUTPUT", "/data/pdf/terminuebersicht.pdf"), now)
|
||||||
|
|
||||||
if is_invalid_date(date_for_table[0] + "-" + date_for_table[1] + "-" + date_for_table[2]):
|
|
||||||
break
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
pdf.set_fill_color(130)
|
|
||||||
pdf.cell(0, 0.3, " ", 0, 1, fill=True)
|
|
||||||
|
|
||||||
if weekday == 7:
|
|
||||||
weekday = 0
|
|
||||||
|
|
||||||
if counter % 2 == 0:
|
|
||||||
pdf.set_fill_color(255, 255, 255)
|
|
||||||
else:
|
|
||||||
pdf.set_fill_color(220)
|
|
||||||
|
|
||||||
pdf.set_font("helvetica", "", 8)
|
|
||||||
if weekday == 2 or weekday == 6:
|
|
||||||
pdf.set_font("helvetica", "B", 8)
|
|
||||||
if weekday == 6:
|
|
||||||
pdf.set_fill_color(170, 210, 255)
|
|
||||||
|
|
||||||
pdf.cell(20, 4, pdf_safe(date_for_table[2] + "." + date_for_table[1] + "." + date_for_table[0]), 0, 0, "C", fill=True)
|
|
||||||
|
|
||||||
event_full_block = table_cells[j].find_all("div", class_="calEventBox")
|
|
||||||
events = table_cells[j].find_all(
|
|
||||||
style=lambda style: style
|
|
||||||
and "margin-right:.2em" in style.replace(" ", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
pdf.cell(15, 4, pdf_safe(weekdays_list[weekday]), 0, 0, "C", fill=True)
|
|
||||||
|
|
||||||
if len(events) == 0:
|
|
||||||
pdf.cell(0, 4, " ", 0, 1, fill=True)
|
|
||||||
|
|
||||||
for k in range(len(events)):
|
|
||||||
if k > 0:
|
|
||||||
pdf.cell(35, 4, " ", 0, 0, fill=True)
|
|
||||||
|
|
||||||
event_place = "--"
|
|
||||||
pdf.cell(20, 4, " ", 0, 0, "C", fill=True)
|
|
||||||
|
|
||||||
if k < len(event_full_block):
|
|
||||||
event_time = event_full_block[k].find_all(style="font-weight:normal")
|
|
||||||
else:
|
|
||||||
event_time = []
|
|
||||||
|
|
||||||
if len(event_time) > 0:
|
|
||||||
try:
|
|
||||||
elements = str(event_time[0]).split("<")
|
|
||||||
except Exception:
|
|
||||||
print("error3")
|
|
||||||
else:
|
|
||||||
if len(elements) > 1 and ">" in elements[1]:
|
|
||||||
time_text = elements[1].split(">")[1]
|
|
||||||
if ":" in str(time_text):
|
|
||||||
pdf.set_x(pdf.get_x() - 20)
|
|
||||||
pdf.cell(20, 4, pdf_safe(time_text), 0, 0, "C", fill=True)
|
|
||||||
|
|
||||||
if len(elements) > 2 and ">" in elements[2]:
|
|
||||||
place_text = elements[2].split(">")[1]
|
|
||||||
if place_text == "":
|
|
||||||
event_place = "--"
|
|
||||||
else:
|
|
||||||
if str(place_text).startswith(","):
|
|
||||||
event_place = str(place_text)[2:]
|
|
||||||
else:
|
|
||||||
event_place = str(place_text)
|
|
||||||
|
|
||||||
event_name = events[k].get_text(strip=True) if len(events) > 0 else ""
|
|
||||||
pdf.cell(75, 4, pdf_safe(event_name), 0, 0, fill=True)
|
|
||||||
pdf.cell(60, 4, pdf_safe(event_place), 0, 1, "C", fill=True)
|
|
||||||
|
|
||||||
counter += 1
|
|
||||||
weekday += 1
|
|
||||||
|
|
||||||
# Fußzeile
|
|
||||||
pdf.set_font("helvetica", "", 8)
|
|
||||||
pdf.cell(0, 1, " ", 0, 1)
|
|
||||||
pdf.cell(0, 0.1, " ", 1, 1)
|
|
||||||
pdf.cell(0, 1, " ", 0, 1)
|
|
||||||
pdf.cell(153, 3, pdf_safe("gemeindeleitung@nak-reinickendorf.de"), 0, 0)
|
|
||||||
pdf.cell(30, 3, pdf_safe("Klixstraße 17, 13403 Berlin"), 0, 1)
|
|
||||||
pdf.set_font("helvetica", "I", 8)
|
|
||||||
pdf.cell(153, 3, pdf_safe("Berlin, " + str(date.strftime("%d.%m.%Y %H:%M"))), 0, 0)
|
|
||||||
pdf.set_font("helvetica", "", 8)
|
|
||||||
pdf.cell(30, 3, pdf_safe("nak-reinickendorf.de"), 0, 1)
|
|
||||||
|
|
||||||
#### PDF ERZEUGEN ####
|
|
||||||
output_path = os.environ.get("PDF_OUTPUT", "/data/pdf/terminuebersicht.pdf")
|
|
||||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
||||||
pdf.output(output_path)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user