Compare commits

..

No commits in common. "make-good-art" and "main" have entirely different histories.

17 changed files with 112 additions and 27789 deletions

View file

@ -18,15 +18,9 @@
sync.allExtras = true;
};
};
typst = {
enable = true;
};
};
packages = [
pkgs.google-fonts
pkgs.hanken-grotesk
pkgs.typstyle
pkgs.uv
];

View file

@ -18,10 +18,8 @@ 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",
@ -29,12 +27,10 @@ dependencies = [
"premailer==3.10.0",
"python-dateutil==2.9.0.post0",
"python-dotenv==1.1.1",
"pytz==2026.1.post1",
"requests==2.32.4",
"six==1.17.0",
"soupsieve==2.7",
"typing_extensions==4.14.0",
"typst>=0.14.8",
"urllib3==2.5.0",
"webencodings==0.5.1",
]

View file

@ -1,74 +0,0 @@
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")

View file

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

View file

@ -1,164 +0,0 @@
<!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

@ -4,6 +4,7 @@ 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
@ -11,13 +12,13 @@ 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",
}
TEMPLATE_FILENAME = "templates/newsletter_template.html"
OUTPUT_FILENAME = "newsletter_events.html"
def sanitize_html(raw_html: str) -> str:
@ -28,8 +29,69 @@ 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
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 = [
jours_semaine = [
"poniedziałek",
"wtorek",
"środa",
@ -38,7 +100,7 @@ def prepare_events_for_template(raw_events: list) -> list:
"sobota",
"niedziela",
]
month_year = [
mois_annee = [
"",
"stycznia",
"lutego",
@ -70,7 +132,6 @@ def prepare_events_for_template(raw_events: list) -> list:
begins_iso = ev.get("beginsOn")
picture = ev.get("picture")
picture_url = picture.get("url") if picture else None
picture_filename = ev.get("picture_filename")
link = ev.get("url") or ""
if picture_url and "?" in picture_url:
@ -85,15 +146,15 @@ def prepare_events_for_template(raw_events: list) -> list:
location = ""
dt_utc = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
dt_warsaw = dt_utc.astimezone(ZoneInfo("Europe/Warsaw"))
day_name = days_week[dt_warsaw.weekday()]
day_num = dt_warsaw.day
month_name = month_year[dt_warsaw.month]
year = dt_warsaw.year
hour = dt_warsaw.hour
minute = dt_warsaw.minute
dt_paris = dt_utc.astimezone(ZoneInfo("Europe/Paris"))
jour_nom = jours_semaine[dt_paris.weekday()]
jour_num = dt_paris.day
mois_nom = mois_annee[dt_paris.month]
annee = dt_paris.year
heure = dt_paris.hour
minute = dt_paris.minute
minute_str = f"{minute:02d}"
full_date = f"{day_name}, {day_num} {month_name} {year} @ {hour}:{minute_str}"
full_date = f"{jour_nom}, {jour_num} {mois_nom} {annee} @ {heure}:{minute_str}"
prepared.append(
{
@ -101,7 +162,6 @@ def prepare_events_for_template(raw_events: list) -> list:
"description": truncated,
"full_date": full_date,
"picture_url": picture_url,
"picture_filename": picture_filename,
"location": location,
"link": link,
"start_time": dt_utc,
@ -139,22 +199,40 @@ def _main():
_args = parser.parse_args()
try:
raw_events = get_raw_events()
events = prepare_events_for_template(raw_events)
for template_filename, output_filename in TEMPLATES.items():
output = render_newsletter(
events=events, template_dir=os.getcwd(), template_name=template_filename
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')}")
raw_events.sort(key=lambda ev: ev.get("beginsOn", ""))
events = prepare_events_for_template(raw_events)
os.makedirs("dist", exist_ok=True)
output_path = os.path.join(os.getcwd(), "dist", output_filename)
output_path = os.path.join(os.getcwd(), "dist", OUTPUT_FILENAME)
html_output = render_newsletter(
events=events, template_dir=os.getcwd(), template_name=TEMPLATE_FILENAME
)
with open(output_path, "w", encoding="utf-8") as f:
f.write(output)
f.write(html_output)
print(f"File '{output_path}' generated successfully.", file=sys.stderr)
if output_filename.endswith(".html"):
inlined_output_path = os.path.join(os.getcwd(), "dist", f"inlined_{output_filename}")
inlined_output_path = os.path.join(os.getcwd(), "dist", "newsletter_events_inlined.html")
inline_css(output_path, inlined_output_path)
print(
f"File '{inlined_output_path}' (inline CSS) generated.",

View file

@ -1,143 +0,0 @@
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

View file

@ -1,2 +0,0 @@
assets/tmp/*
*.pdf

File diff suppressed because it is too large Load diff

Before

Width:  |  Height:  |  Size: 1.4 MiB

File diff suppressed because it is too large Load diff

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View file

@ -1,88 +0,0 @@
#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
}
}

View file

@ -1,86 +0,0 @@
#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()
}
}