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