diff --git a/pyproject.toml b/pyproject.toml
index 185fa2f..7530a28 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,8 +18,10 @@ dependencies = [
"cssselect==1.3.0",
"cssutils==2.11.1",
"discord.py==2.7.1",
+ "fastapi[standard]>=0.136.1",
"html5lib==1.1",
"idna==3.10",
+ "immichpy>=4.1.3",
"Jinja2==3.1.6",
"lxml==6.0.0",
"MarkupSafe==3.0.2",
@@ -31,6 +33,7 @@ dependencies = [
"six==1.17.0",
"soupsieve==2.7",
"typing_extensions==4.14.0",
+ "typst>=0.14.8",
"urllib3==2.5.0",
"webencodings==0.5.1",
]
diff --git a/src/kronos-ui/__init__.py b/src/kronos-ui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/kronos-ui/main.py b/src/kronos-ui/main.py
new file mode 100644
index 0000000..259ae48
--- /dev/null
+++ b/src/kronos-ui/main.py
@@ -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")
diff --git a/src/kronos-ui/setzer.py b/src/kronos-ui/setzer.py
new file mode 100644
index 0000000..6f38ff7
--- /dev/null
+++ b/src/kronos-ui/setzer.py
@@ -0,0 +1,3 @@
+import typst
+
+typst.compile("src/typst/calendar.typ", output="calendar.pdf")
diff --git a/src/kronos-ui/static/index.html b/src/kronos-ui/static/index.html
new file mode 100644
index 0000000..dcbe7af
--- /dev/null
+++ b/src/kronos-ui/static/index.html
@@ -0,0 +1,164 @@
+
+
+
+
+
+ Kronos UI -- Generator Genialnych Grafik
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Kronos
+
+ Kalendarz
+ Karta
+
+
+
+
Kalendarz
+
+
+

+
+
+
+
Karta
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/kronos/main.py b/src/kronos/main.py
index 0909e09..8db068b 100755
--- a/src/kronos/main.py
+++ b/src/kronos/main.py
@@ -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():
diff --git a/src/kronos/mobilizon.py b/src/kronos/mobilizon.py
new file mode 100644
index 0000000..25c74c1
--- /dev/null
+++ b/src/kronos/mobilizon.py
@@ -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
diff --git a/src/typst/assets/hs3_logo_hires.png b/src/typst/assets/hs3_logo_hires.png
new file mode 100644
index 0000000..a78beac
Binary files /dev/null and b/src/typst/assets/hs3_logo_hires.png differ
diff --git a/src/typst/event.typ b/src/typst/event.typ
index ae81c34..dedee18 100644
--- a/src/typst/event.typ
+++ b/src/typst/event.typ
@@ -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