Compare commits
4 commits
main
...
make-good-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7553028f2d | ||
|
|
31d7c67783 | ||
|
|
88765f396e | ||
|
|
64a45a8641 |
17 changed files with 27789 additions and 112 deletions
|
|
@ -18,9 +18,15 @@
|
||||||
sync.allExtras = true;
|
sync.allExtras = true;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
typst = {
|
||||||
|
enable = true;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
packages = [
|
packages = [
|
||||||
|
pkgs.google-fonts
|
||||||
|
pkgs.hanken-grotesk
|
||||||
|
pkgs.typstyle
|
||||||
pkgs.uv
|
pkgs.uv
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ dependencies = [
|
||||||
"cssselect==1.3.0",
|
"cssselect==1.3.0",
|
||||||
"cssutils==2.11.1",
|
"cssutils==2.11.1",
|
||||||
"discord.py==2.7.1",
|
"discord.py==2.7.1",
|
||||||
|
"fastapi[standard]>=0.136.1",
|
||||||
"html5lib==1.1",
|
"html5lib==1.1",
|
||||||
"idna==3.10",
|
"idna==3.10",
|
||||||
|
"immichpy>=4.1.3",
|
||||||
"Jinja2==3.1.6",
|
"Jinja2==3.1.6",
|
||||||
"lxml==6.0.0",
|
"lxml==6.0.0",
|
||||||
"MarkupSafe==3.0.2",
|
"MarkupSafe==3.0.2",
|
||||||
|
|
@ -27,10 +29,12 @@ dependencies = [
|
||||||
"premailer==3.10.0",
|
"premailer==3.10.0",
|
||||||
"python-dateutil==2.9.0.post0",
|
"python-dateutil==2.9.0.post0",
|
||||||
"python-dotenv==1.1.1",
|
"python-dotenv==1.1.1",
|
||||||
|
"pytz==2026.1.post1",
|
||||||
"requests==2.32.4",
|
"requests==2.32.4",
|
||||||
"six==1.17.0",
|
"six==1.17.0",
|
||||||
"soupsieve==2.7",
|
"soupsieve==2.7",
|
||||||
"typing_extensions==4.14.0",
|
"typing_extensions==4.14.0",
|
||||||
|
"typst>=0.14.8",
|
||||||
"urllib3==2.5.0",
|
"urllib3==2.5.0",
|
||||||
"webencodings==0.5.1",
|
"webencodings==0.5.1",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
0
src/kronos-ui/__init__.py
Normal file
0
src/kronos-ui/__init__.py
Normal file
74
src/kronos-ui/main.py
Normal file
74
src/kronos-ui/main.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, UploadFile, Form
|
||||||
|
from fastapi.responses import HTMLResponse, Response
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
import typst
|
||||||
|
|
||||||
|
from kronos.mobilizon import get_raw_events
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory="src/kronos-ui/static/"), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def root():
|
||||||
|
with open("src/kronos-ui/static/index.html", "rb") as f:
|
||||||
|
index = f.read()
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/calendar", responses={200: {"content": {"image/png": {}}}}, response_class=Response)
|
||||||
|
async def calendar():
|
||||||
|
get_raw_events()
|
||||||
|
|
||||||
|
png_bytes = typst.compile("src/typst/calendar.typ", format="png")
|
||||||
|
|
||||||
|
return Response(content=png_bytes, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/event_card", responses={200: {"content": {"image/png": {}}}}, response_class=Response)
|
||||||
|
async def event_card( # noqa: PLR0913
|
||||||
|
title: str = Form(""),
|
||||||
|
description: str = Form(""),
|
||||||
|
begins=Form(...),
|
||||||
|
ends=Form(...),
|
||||||
|
speaker: str = Form(""),
|
||||||
|
picture: UploadFile = File(...),
|
||||||
|
):
|
||||||
|
pic_path = os.path.join("src", "typst", "assets", "tmp", f"{datetime.now()}.jpg")
|
||||||
|
fp = open(pic_path, "wb")
|
||||||
|
if picture and picture.size > 0:
|
||||||
|
shutil.copyfileobj(picture.file, fp)
|
||||||
|
else:
|
||||||
|
with open(os.path.join("src", "typst", "assets", "photo.jpg"), "rb") as placeholder:
|
||||||
|
shutil.copyfileobj(placeholder, fp)
|
||||||
|
|
||||||
|
# TODO: pack this in a method
|
||||||
|
events = [
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"beginsOn": begins,
|
||||||
|
"endsOn": ends,
|
||||||
|
"description": description,
|
||||||
|
"physicalAddress": None,
|
||||||
|
"picture": os.path.relpath(pic_path, start="src/typst"),
|
||||||
|
"url": None,
|
||||||
|
"txt_description": description,
|
||||||
|
"txt_date": f"{begins.split('T')[0]} {begins.split('T')[1]}-{ends.split('T')[1]}",
|
||||||
|
# "txt_date": "2026-04-29 18:00-19:00",
|
||||||
|
"speaker": speaker,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
sys_inputs = {"events": json.dumps(events)}
|
||||||
|
print(sys_inputs)
|
||||||
|
# FIXME: remove hardcoded paths
|
||||||
|
png_bytes = typst.compile("src/typst/event.typ", format="png", sys_inputs=sys_inputs)
|
||||||
|
|
||||||
|
fp.close()
|
||||||
|
|
||||||
|
return Response(content=png_bytes, media_type="image/png")
|
||||||
3
src/kronos-ui/setzer.py
Normal file
3
src/kronos-ui/setzer.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import typst
|
||||||
|
|
||||||
|
typst.compile("src/typst/calendar.typ", output="calendar.pdf")
|
||||||
164
src/kronos-ui/static/index.html
Normal file
164
src/kronos-ui/static/index.html
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="description" content="Kronos Generator" />
|
||||||
|
<title>Kronos UI -- Generator Genialnych Grafik</title>
|
||||||
|
<link rel="shortcut icon" href="static/favicon.ico" />
|
||||||
|
<!-- Font Awesome CSS -->
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
|
/>
|
||||||
|
<!-- Bootstrap CSS -->
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
integrity=""
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="static/css/style.css" />
|
||||||
|
<!-- DataTables CSS -->
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
border: 1px solid grey;
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tab-info] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active[data-tab-info] {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
font-size: 20px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-weight: bold;
|
||||||
|
color: rgb(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
border-bottom: 1px solid grey;
|
||||||
|
background-color: rgb(16, 153, 9);
|
||||||
|
font-size: 25px;
|
||||||
|
color: rgb(0, 0, 0);
|
||||||
|
display: flex;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs span {
|
||||||
|
background: rgb(16, 153, 9);
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgb(255, 255, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs span:hover {
|
||||||
|
background: rgb(55, 219, 46);
|
||||||
|
cursor: pointer;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="site-body" class="container-fluid">
|
||||||
|
<div class="row flex-nonwrap">
|
||||||
|
<div class="sidenav"></div>
|
||||||
|
<div class="main">
|
||||||
|
<h1>Kronos</h1>
|
||||||
|
<div class="tabs">
|
||||||
|
<span data-tab-value="#tab_1">Kalendarz</span>
|
||||||
|
<span data-tab-value="#tab_2">Karta</span>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tabs__tab active" id="tab_1" data-tab-info>
|
||||||
|
<h2>Kalendarz</h2>
|
||||||
|
<form action="/calendar">
|
||||||
|
<label for="calendar_begins">Początek:</label>
|
||||||
|
<input type="date" id="calendar_begins" name="begins"><br>
|
||||||
|
<input type="submit" value="Make me a calendar!">
|
||||||
|
</form>
|
||||||
|
<div>
|
||||||
|
<img src="/calendar" width="80%">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tabs__tab" id="tab_2" data-tab-info>
|
||||||
|
<h2>Karta</h2>
|
||||||
|
<form action="/event_card" method="post" enctype="multipart/form-data">
|
||||||
|
<label for="title">Tytuł:</label><br>
|
||||||
|
<input type="text" id="title" name="title"><br>
|
||||||
|
<label for="description">Opis:</label><br>
|
||||||
|
<input type="text" id="description" name="description"><br>
|
||||||
|
<label for="begins">Początek:</label>
|
||||||
|
<input type="datetime-local" id="card_begins" name="begins"><br>
|
||||||
|
<label for="ends">Koniec:</label>
|
||||||
|
<input type="datetime-local" id="card_ends" name="ends"><br>
|
||||||
|
<label for="speaker">Występuje:</label><br>
|
||||||
|
<input type="text" id="speaker" name="speaker"><br>
|
||||||
|
<label for="picture">Fotka:</label><br>
|
||||||
|
<input type="file" id="picture" name="picture" accept="image/png, image/jpeg"><br>
|
||||||
|
<input type="submit" value="Make it so!">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<a href="https://forge.hs3.pl/leming/kronos"
|
||||||
|
>Źródełka</a
|
||||||
|
>
|
||||||
|
</footer>
|
||||||
|
<!-- jQuery -->
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
|
||||||
|
// function to get each tab details
|
||||||
|
const tabs = document.querySelectorAll('[data-tab-value]')
|
||||||
|
const tabInfos = document.querySelectorAll('[data-tab-info]')
|
||||||
|
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const target = document
|
||||||
|
.querySelector(tab.dataset.tabValue);
|
||||||
|
tabInfos.forEach(tabInfo => {
|
||||||
|
tabInfo.classList.remove('active')
|
||||||
|
})
|
||||||
|
target.classList.add('active');
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const dateValue = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const calendarStartDate = document.getElementById('calendar_begins');
|
||||||
|
calendarStartDate.value = dateValue;
|
||||||
|
|
||||||
|
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||||
|
|
||||||
|
const cardStartDate = document.getElementById('card_begins');
|
||||||
|
cardStartDate.value = now.toISOString().slice(0,16);
|
||||||
|
|
||||||
|
const cardEndsDate = document.getElementById('card_ends');
|
||||||
|
cardEndsDate.value = now.toISOString().slice(0,16);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -4,7 +4,6 @@ import sys
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
|
|
||||||
import requests
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
@ -12,13 +11,13 @@ from premailer import transform
|
||||||
|
|
||||||
from .discordbot import KronosBot, intents
|
from .discordbot import KronosBot, intents
|
||||||
|
|
||||||
|
from .mobilizon import get_raw_events
|
||||||
|
|
||||||
|
|
||||||
MOBILIZON_API_URL = "https://events.hs3.pl/api"
|
|
||||||
QUERY_LIMIT = 100
|
|
||||||
TIME_WINDOW_DAYS = 365
|
|
||||||
TRUNCATE_AFTER_CHARACTERS = 300
|
TRUNCATE_AFTER_CHARACTERS = 300
|
||||||
TEMPLATE_FILENAME = "templates/newsletter_template.html"
|
TEMPLATES = {
|
||||||
OUTPUT_FILENAME = "newsletter_events.html"
|
"templates/newsletter_template.html": "newsletter_events.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def sanitize_html(raw_html: str) -> str:
|
def sanitize_html(raw_html: str) -> str:
|
||||||
|
|
@ -29,69 +28,8 @@ def sanitize_html(raw_html: str) -> str:
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def get_time_window(days: int = 365):
|
|
||||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
|
||||||
ends_utc = now_utc + datetime.timedelta(days=days)
|
|
||||||
|
|
||||||
begins_iso = now_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
||||||
ends_iso = ends_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
||||||
return begins_iso, ends_iso
|
|
||||||
|
|
||||||
|
|
||||||
def build_graphql_query():
|
|
||||||
return """
|
|
||||||
query SearchEventsInWindow($beginsOn: DateTime, $endsOn: DateTime, $limit: Int) {
|
|
||||||
searchEvents(beginsOn: $beginsOn, endsOn: $endsOn, limit: $limit) {
|
|
||||||
total
|
|
||||||
elements {
|
|
||||||
__typename
|
|
||||||
... on Event {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
description
|
|
||||||
beginsOn
|
|
||||||
picture {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
url
|
|
||||||
physicalAddress {
|
|
||||||
description
|
|
||||||
locality
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_events(begins_on: str, ends_on: str, limit: int = QUERY_LIMIT):
|
|
||||||
query = build_graphql_query()
|
|
||||||
variables = {"beginsOn": begins_on, "endsOn": ends_on, "limit": limit}
|
|
||||||
payload = {"query": query, "variables": variables}
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
resp = requests.post(MOBILIZON_API_URL, json=payload, headers=headers)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
if "errors" in data:
|
|
||||||
raise RuntimeError(f"GraphQL error: {data['errors']}")
|
|
||||||
|
|
||||||
events = []
|
|
||||||
for elem in data["data"]["searchEvents"]["elements"]:
|
|
||||||
if elem.get("__typename") == "Event":
|
|
||||||
begins_iso = elem.get("beginsOn")
|
|
||||||
if begins_iso:
|
|
||||||
dt = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
|
|
||||||
dt_begins = datetime.datetime.fromisoformat(begins_on.replace("Z", "+00:00"))
|
|
||||||
dt_ends = datetime.datetime.fromisoformat(ends_on.replace("Z", "+00:00"))
|
|
||||||
if dt_begins <= dt < dt_ends:
|
|
||||||
events.append(elem)
|
|
||||||
return events
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_events_for_template(raw_events: list) -> list:
|
def prepare_events_for_template(raw_events: list) -> list:
|
||||||
jours_semaine = [
|
days_week = [
|
||||||
"poniedziałek",
|
"poniedziałek",
|
||||||
"wtorek",
|
"wtorek",
|
||||||
"środa",
|
"środa",
|
||||||
|
|
@ -100,7 +38,7 @@ def prepare_events_for_template(raw_events: list) -> list:
|
||||||
"sobota",
|
"sobota",
|
||||||
"niedziela",
|
"niedziela",
|
||||||
]
|
]
|
||||||
mois_annee = [
|
month_year = [
|
||||||
"",
|
"",
|
||||||
"stycznia",
|
"stycznia",
|
||||||
"lutego",
|
"lutego",
|
||||||
|
|
@ -132,6 +70,7 @@ def prepare_events_for_template(raw_events: list) -> list:
|
||||||
begins_iso = ev.get("beginsOn")
|
begins_iso = ev.get("beginsOn")
|
||||||
picture = ev.get("picture")
|
picture = ev.get("picture")
|
||||||
picture_url = picture.get("url") if picture else None
|
picture_url = picture.get("url") if picture else None
|
||||||
|
picture_filename = ev.get("picture_filename")
|
||||||
link = ev.get("url") or ""
|
link = ev.get("url") or ""
|
||||||
|
|
||||||
if picture_url and "?" in picture_url:
|
if picture_url and "?" in picture_url:
|
||||||
|
|
@ -146,15 +85,15 @@ def prepare_events_for_template(raw_events: list) -> list:
|
||||||
location = ""
|
location = ""
|
||||||
|
|
||||||
dt_utc = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
|
dt_utc = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
|
||||||
dt_paris = dt_utc.astimezone(ZoneInfo("Europe/Paris"))
|
dt_warsaw = dt_utc.astimezone(ZoneInfo("Europe/Warsaw"))
|
||||||
jour_nom = jours_semaine[dt_paris.weekday()]
|
day_name = days_week[dt_warsaw.weekday()]
|
||||||
jour_num = dt_paris.day
|
day_num = dt_warsaw.day
|
||||||
mois_nom = mois_annee[dt_paris.month]
|
month_name = month_year[dt_warsaw.month]
|
||||||
annee = dt_paris.year
|
year = dt_warsaw.year
|
||||||
heure = dt_paris.hour
|
hour = dt_warsaw.hour
|
||||||
minute = dt_paris.minute
|
minute = dt_warsaw.minute
|
||||||
minute_str = f"{minute:02d}"
|
minute_str = f"{minute:02d}"
|
||||||
full_date = f"{jour_nom}, {jour_num} {mois_nom} {annee} @ {heure}:{minute_str}"
|
full_date = f"{day_name}, {day_num} {month_name} {year} @ {hour}:{minute_str}"
|
||||||
|
|
||||||
prepared.append(
|
prepared.append(
|
||||||
{
|
{
|
||||||
|
|
@ -162,6 +101,7 @@ def prepare_events_for_template(raw_events: list) -> list:
|
||||||
"description": truncated,
|
"description": truncated,
|
||||||
"full_date": full_date,
|
"full_date": full_date,
|
||||||
"picture_url": picture_url,
|
"picture_url": picture_url,
|
||||||
|
"picture_filename": picture_filename,
|
||||||
"location": location,
|
"location": location,
|
||||||
"link": link,
|
"link": link,
|
||||||
"start_time": dt_utc,
|
"start_time": dt_utc,
|
||||||
|
|
@ -199,45 +139,27 @@ def _main():
|
||||||
_args = parser.parse_args()
|
_args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
begins_on, ends_on = get_time_window(days=TIME_WINDOW_DAYS)
|
raw_events = get_raw_events()
|
||||||
print(
|
|
||||||
f"Fetching events between {begins_on} and {ends_on}...",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_events = list(
|
|
||||||
filter(
|
|
||||||
lambda x: "https://events.hs3.pl/" in x.get("url", ""),
|
|
||||||
fetch_events(begins_on, ends_on),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not raw_events:
|
|
||||||
print("No events found in the requested period.", file=sys.stderr)
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
for ev in raw_events:
|
|
||||||
print(f"{ev.get('title', 'Untitled')} — {ev.get('beginsOn', 'Unknown date')}")
|
|
||||||
|
|
||||||
raw_events.sort(key=lambda ev: ev.get("beginsOn", ""))
|
|
||||||
|
|
||||||
events = prepare_events_for_template(raw_events)
|
events = prepare_events_for_template(raw_events)
|
||||||
|
|
||||||
os.makedirs("dist", exist_ok=True)
|
for template_filename, output_filename in TEMPLATES.items():
|
||||||
output_path = os.path.join(os.getcwd(), "dist", OUTPUT_FILENAME)
|
output = render_newsletter(
|
||||||
html_output = render_newsletter(
|
events=events, template_dir=os.getcwd(), template_name=template_filename
|
||||||
events=events, template_dir=os.getcwd(), template_name=TEMPLATE_FILENAME
|
)
|
||||||
)
|
|
||||||
|
|
||||||
with open(output_path, "w", encoding="utf-8") as f:
|
os.makedirs("dist", exist_ok=True)
|
||||||
f.write(html_output)
|
output_path = os.path.join(os.getcwd(), "dist", output_filename)
|
||||||
print(f"File '{output_path}' generated successfully.", file=sys.stderr)
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(output)
|
||||||
|
print(f"File '{output_path}' generated successfully.", file=sys.stderr)
|
||||||
|
|
||||||
inlined_output_path = os.path.join(os.getcwd(), "dist", "newsletter_events_inlined.html")
|
if output_filename.endswith(".html"):
|
||||||
inline_css(output_path, inlined_output_path)
|
inlined_output_path = os.path.join(os.getcwd(), "dist", f"inlined_{output_filename}")
|
||||||
print(
|
inline_css(output_path, inlined_output_path)
|
||||||
f"File '{inlined_output_path}' (inline CSS) generated.",
|
print(
|
||||||
file=sys.stderr,
|
f"File '{inlined_output_path}' (inline CSS) generated.",
|
||||||
)
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
discord_token = os.getenv("DISCORD_TOKEN")
|
discord_token = os.getenv("DISCORD_TOKEN")
|
||||||
client = KronosBot(intents=intents)
|
client = KronosBot(intents=intents)
|
||||||
|
|
|
||||||
143
src/kronos/mobilizon.py
Normal file
143
src/kronos/mobilizon.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from pytz import timezone
|
||||||
|
|
||||||
|
MOBILIZON_API_URL = "https://events.hs3.pl/api"
|
||||||
|
QUERY_LIMIT = 100
|
||||||
|
TIME_WINDOW_DAYS = 365
|
||||||
|
|
||||||
|
|
||||||
|
def get_time_window(start_utc=datetime.datetime.now(datetime.timezone.utc), days: int = 365):
|
||||||
|
now_utc = start_utc
|
||||||
|
ends_utc = now_utc + datetime.timedelta(days=days)
|
||||||
|
|
||||||
|
begins_iso = now_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
ends_iso = ends_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
return begins_iso, ends_iso
|
||||||
|
|
||||||
|
|
||||||
|
def build_graphql_query():
|
||||||
|
return """
|
||||||
|
query SearchEventsInWindow($beginsOn: DateTime, $endsOn: DateTime, $limit: Int) {
|
||||||
|
searchEvents(beginsOn: $beginsOn, endsOn: $endsOn, limit: $limit) {
|
||||||
|
total
|
||||||
|
elements {
|
||||||
|
__typename
|
||||||
|
... on Event {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
description
|
||||||
|
beginsOn
|
||||||
|
endsOn
|
||||||
|
picture {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
url
|
||||||
|
physicalAddress {
|
||||||
|
description
|
||||||
|
locality
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_events(begins_on: str, ends_on: str, limit: int = QUERY_LIMIT):
|
||||||
|
query = build_graphql_query()
|
||||||
|
variables = {"beginsOn": begins_on, "endsOn": ends_on, "limit": limit}
|
||||||
|
payload = {"query": query, "variables": variables}
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
resp = requests.post(MOBILIZON_API_URL, json=payload, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if "errors" in data:
|
||||||
|
raise RuntimeError(f"GraphQL error: {data['errors']}")
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for elem in data["data"]["searchEvents"]["elements"]:
|
||||||
|
if elem.get("__typename") == "Event":
|
||||||
|
begins_iso = elem.get("beginsOn")
|
||||||
|
if begins_iso:
|
||||||
|
dt = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
|
||||||
|
dt_begins = datetime.datetime.fromisoformat(begins_on.replace("Z", "+00:00"))
|
||||||
|
dt_ends = datetime.datetime.fromisoformat(ends_on.replace("Z", "+00:00"))
|
||||||
|
if dt_begins <= dt < dt_ends:
|
||||||
|
events.append(elem)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def save_json_content(events):
|
||||||
|
json_dirname = "src/typst/content"
|
||||||
|
os.makedirs(json_dirname, exist_ok=True)
|
||||||
|
with open(os.path.join(json_dirname, "events.json"), "w") as f:
|
||||||
|
json.dump(sorted(events, key=lambda event: event["beginsOn"]), f)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_picture(event):
|
||||||
|
picture = event.get("picture")
|
||||||
|
picture_url = picture.get("url") if picture else None
|
||||||
|
picture_filename = None
|
||||||
|
|
||||||
|
if picture_url:
|
||||||
|
if "?" in picture_url:
|
||||||
|
picture_url = picture_url.split("?", 1)[0]
|
||||||
|
|
||||||
|
picture_dirname = "src/typst/assets/tmp"
|
||||||
|
picture_filename = os.path.join(picture_dirname, picture_url.split("/")[-1])
|
||||||
|
event["picture_filename"] = picture_filename
|
||||||
|
|
||||||
|
response = requests.get(picture_url)
|
||||||
|
|
||||||
|
os.makedirs(picture_dirname, exist_ok=True)
|
||||||
|
with open(picture_filename, "wb") as picture_file:
|
||||||
|
picture_file.write(response.content)
|
||||||
|
|
||||||
|
|
||||||
|
def get_raw_events():
|
||||||
|
begins_on, ends_on = get_time_window(days=TIME_WINDOW_DAYS)
|
||||||
|
print(
|
||||||
|
f"Fetching events between {begins_on} and {ends_on}...",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_events = list(
|
||||||
|
filter(
|
||||||
|
lambda x: "https://events.hs3.pl/" in x.get("url", ""),
|
||||||
|
fetch_events(begins_on, ends_on),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not raw_events:
|
||||||
|
print("No events found in the requested period.", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
for ev in raw_events:
|
||||||
|
print(f"{ev.get('title', 'Untitled')} — {ev.get('beginsOn', 'Unknown date')}")
|
||||||
|
|
||||||
|
ev["txt_description"] = BeautifulSoup(ev["description"], "html.parser").get_text()
|
||||||
|
|
||||||
|
local_tz = timezone("Europe/Warsaw")
|
||||||
|
begins_date = datetime.datetime.fromisoformat(ev["beginsOn"]).astimezone(local_tz)
|
||||||
|
ends_date = datetime.datetime.fromisoformat(ev["endsOn"]).astimezone(local_tz)
|
||||||
|
begins = (begins_date.strftime("%Y-%m-%d"), begins_date.strftime("%H:%M"))
|
||||||
|
ends = (ends_date.strftime("%Y-%m-%d"), ends_date.strftime("%H:%M"))
|
||||||
|
txt_date = f"{begins[0]} {begins[1]}-"
|
||||||
|
if begins[0] != ends[0]:
|
||||||
|
txt_date += f"{ends[0]}"
|
||||||
|
txt_date += f"{ends[1]}"
|
||||||
|
ev["txt_date"] = txt_date
|
||||||
|
|
||||||
|
extract_picture(ev)
|
||||||
|
|
||||||
|
save_json_content(raw_events)
|
||||||
|
|
||||||
|
raw_events.sort(key=lambda ev: ev.get("beginsOn", ""))
|
||||||
|
|
||||||
|
return raw_events
|
||||||
2
src/typst/.gitignore
vendored
Normal file
2
src/typst/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
assets/tmp/*
|
||||||
|
*.pdf
|
||||||
25941
src/typst/assets/calendar.svg
Normal file
25941
src/typst/assets/calendar.svg
Normal file
File diff suppressed because it is too large
Load diff
|
After Width: | Height: | Size: 1.4 MiB |
1244
src/typst/assets/event.svg
Normal file
1244
src/typst/assets/event.svg
Normal file
File diff suppressed because it is too large
Load diff
|
After Width: | Height: | Size: 68 KiB |
BIN
src/typst/assets/hs3_logo_hires.png
Normal file
BIN
src/typst/assets/hs3_logo_hires.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
BIN
src/typst/assets/logo.png
Normal file
BIN
src/typst/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
BIN
src/typst/assets/photo.jpg
Normal file
BIN
src/typst/assets/photo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
88
src/typst/calendar.typ
Normal file
88
src/typst/calendar.typ
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
#import "@preview/cades:0.3.1": qr-code
|
||||||
|
|
||||||
|
#set page(
|
||||||
|
paper: "a4",
|
||||||
|
background: context {
|
||||||
|
place(center + horizon, {
|
||||||
|
image("assets/calendar.svg")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
margin: (top: .5cm, bottom: .5cm, x: 0cm),
|
||||||
|
)
|
||||||
|
|
||||||
|
#place(
|
||||||
|
top + left,
|
||||||
|
dx: 8mm,
|
||||||
|
dy: -14mm,
|
||||||
|
image("assets/hs3_logo_hires.png", height: 58mm, width: 58mm),
|
||||||
|
)
|
||||||
|
|
||||||
|
#place(
|
||||||
|
top + left,
|
||||||
|
dx: 13mm,
|
||||||
|
dy: 145mm,
|
||||||
|
qr-code("https://hs3.pl", width: 32mm),
|
||||||
|
)
|
||||||
|
|
||||||
|
#let add_event(number, title, description, date, address) = {
|
||||||
|
let ex = 85mm + calc.rem-euclid(number, 2) * 67mm
|
||||||
|
let ey = 46mm + calc.div-euclid(number, 2) * 76mm
|
||||||
|
|
||||||
|
set text(
|
||||||
|
fill: white,
|
||||||
|
font: "Rajdhani",
|
||||||
|
)
|
||||||
|
|
||||||
|
place(
|
||||||
|
top + left,
|
||||||
|
dx: ex,
|
||||||
|
dy: ey,
|
||||||
|
block(
|
||||||
|
width: 32mm,
|
||||||
|
height: 48mm,
|
||||||
|
|
||||||
|
[
|
||||||
|
#block(
|
||||||
|
height: 14mm,
|
||||||
|
text(
|
||||||
|
weight: "bold",
|
||||||
|
title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
#move(dy: 3mm, block(
|
||||||
|
height: 8mm,
|
||||||
|
clip: true,
|
||||||
|
text(
|
||||||
|
weight: "bold",
|
||||||
|
size: 10pt,
|
||||||
|
date,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
#move(dy: 4mm, block(
|
||||||
|
height: 12mm,
|
||||||
|
clip: true,
|
||||||
|
text(size: 10pt, address),
|
||||||
|
))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#let events = json("content/events.json")
|
||||||
|
#let index = 0
|
||||||
|
|
||||||
|
#for event in events {
|
||||||
|
add_event(
|
||||||
|
index,
|
||||||
|
event.title,
|
||||||
|
event.txt_description,
|
||||||
|
event.txt_date,
|
||||||
|
"Hackerspace Trójmiasto, al. Wojska Polskiego 41, Gdańsk",
|
||||||
|
)
|
||||||
|
|
||||||
|
index = index + 1
|
||||||
|
|
||||||
|
if (index >= 6) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
0
src/typst/content/.gitkeep
Normal file
0
src/typst/content/.gitkeep
Normal file
86
src/typst/event.typ
Normal file
86
src/typst/event.typ
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
#import "@preview/bulb:0.1.0": dither
|
||||||
|
|
||||||
|
#set page(
|
||||||
|
width: 297mm,
|
||||||
|
height: 169mm,
|
||||||
|
background: context {
|
||||||
|
place(center + horizon, {
|
||||||
|
image("assets/event.svg")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
margin: (top: 0cm, bottom: .5cm, x: .5cm),
|
||||||
|
)
|
||||||
|
|
||||||
|
#let make_event_card(title, description, date, address, picture, name) = {
|
||||||
|
move(
|
||||||
|
dx: 148mm,
|
||||||
|
dy: 23mm,
|
||||||
|
image(
|
||||||
|
dither(
|
||||||
|
read(picture, encoding: none),
|
||||||
|
mode: "bw",
|
||||||
|
method: "bayer8x8",
|
||||||
|
size: 360,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
place(
|
||||||
|
top + left,
|
||||||
|
dx: 94mm,
|
||||||
|
dy: -4mm,
|
||||||
|
image("assets/hs3_logo_hires.png", height: 38mm, width: 38mm),
|
||||||
|
)
|
||||||
|
|
||||||
|
set text(font: "Hanken Grotesk", size: 14pt)
|
||||||
|
|
||||||
|
place(
|
||||||
|
top + left,
|
||||||
|
dx: 12mm,
|
||||||
|
dy: 30mm,
|
||||||
|
block(
|
||||||
|
width: 118mm,
|
||||||
|
height: 130mm,
|
||||||
|
grid(
|
||||||
|
rows: (2fr, 6fr, 0.75fr, 0.5fr),
|
||||||
|
text(size: 28pt, weight: "bold", title),
|
||||||
|
text(size: 10pt, description),
|
||||||
|
text(
|
||||||
|
size: 24pt,
|
||||||
|
weight: "bold",
|
||||||
|
grid(
|
||||||
|
columns: (1fr, 1fr),
|
||||||
|
date.split(" ").at(0), align(right)[#date.split(" ").at(1)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
address,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
align(right + bottom)[#text(size: 14pt, weight: "bold", name)]
|
||||||
|
}
|
||||||
|
|
||||||
|
#let events = none
|
||||||
|
#if sys.inputs.len() < 1 {
|
||||||
|
events = json("content/events.json")
|
||||||
|
} else {
|
||||||
|
events = json(bytes(sys.inputs.events))
|
||||||
|
}
|
||||||
|
#let index = 0
|
||||||
|
|
||||||
|
#for event in events {
|
||||||
|
make_event_card(
|
||||||
|
event.title,
|
||||||
|
event.txt_description,
|
||||||
|
event.txt_date,
|
||||||
|
"Gdańsk, al. Wojska Polskiego 41",
|
||||||
|
event.picture,
|
||||||
|
event.speaker,
|
||||||
|
)
|
||||||
|
|
||||||
|
index = index + 1
|
||||||
|
|
||||||
|
if index < events.len() {
|
||||||
|
pagebreak()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue