feat: Add Web UI for calendar and card generation

This commit is contained in:
Piotr Gaczkowski 2026-05-02 12:50:22 +02:00
parent 64a45a8641
commit 88765f396e
9 changed files with 394 additions and 136 deletions

View file

74
src/kronos-ui/main.py Normal file
View 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
View file

@ -0,0 +1,3 @@
import typst
typst.compile("src/typst/calendar.typ", output="calendar.pdf")

View 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>

View file

@ -1,11 +1,9 @@
import datetime
import json
import os
import sys
from zoneinfo import ZoneInfo
from argparse import ArgumentParser
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from jinja2 import Environment, FileSystemLoader, select_autoescape
@ -13,10 +11,9 @@ from premailer import transform
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
TEMPLATES = {
"templates/newsletter_template.html": "newsletter_events.html",
@ -31,68 +28,6 @@ def sanitize_html(raw_html: str) -> str:
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
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 prepare_events_for_template(raw_events: list) -> list:
days_week = [
"poniedziałek",
@ -196,37 +131,6 @@ def inline_css(input_html_path, output_html_path):
f.write(inlined_html)
def download_picture():
pass
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 _main():
load_dotenv()
@ -235,41 +139,7 @@ def _main():
_args = parser.parse_args()
try:
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()
begins = ev["beginsOn"].split("T")
ends = ev["endsOn"].split("T")
txt_date = f"{begins[0]} {begins[1][:-4]}-"
if begins[0] != ends[0]:
txt_date += f"{ends[0]}"
txt_date += f"{ends[1][:-4]}"
ev["txt_date"] = txt_date
extract_picture(ev)
save_json_content(raw_events)
raw_events.sort(key=lambda ev: ev.get("beginsOn", ""))
raw_events = get_raw_events()
events = prepare_events_for_template(raw_events)
for template_filename, output_filename in TEMPLATES.items():

139
src/kronos/mobilizon.py Normal file
View file

@ -0,0 +1,139 @@
import datetime
import json
import os
import sys
import requests
from bs4 import BeautifulSoup
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()
begins = ev["beginsOn"].split("T")
ends = ev["endsOn"].split("T")
txt_date = f"{begins[0]} {begins[1][:-4]}-"
if begins[0] != ends[0]:
txt_date += f"{ends[0]}"
txt_date += f"{ends[1][:-4]}"
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

View file

@ -17,7 +17,7 @@
dy: 23mm,
image(
dither(
read("assets/photo.jpg", encoding: none),
read(picture, encoding: none),
mode: "bw",
method: "bayer8x8",
size: 360,
@ -61,11 +61,16 @@
align(right+bottom)[#text(size: 14pt, weight: "bold", name)]
}
#let events = json("content/events.json")
#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", "", "Joanna Roscoe")
make_event_card(event.title, event.txt_description, event.txt_date, "Gdańsk, al. Wojska Polskiego 41", event.picture, event.speaker)
index = index + 1