Merge branch 'main' of gitea.mintcalc.com:typst4ei/TUM-typst4ei

# Conflicts:
#	requirements.txt
This commit is contained in:
alexander
2026-01-30 12:11:42 +01:00
18 changed files with 658 additions and 150 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.venv .venv
out out
prod prod
cache
node_modules node_modules
__pycache__/ __pycache__/
@@ -8,3 +9,5 @@ package-lock.json
package.json package.json
cheatsheet_inventory.json cheatsheet_inventory.json
*.log
config.yaml

11
.vscode/tasks.json vendored
View File

@@ -39,6 +39,17 @@
"kind": "build", "kind": "build",
"isDefault": true "isDefault": true
} }
},
{
"label": "trigger local build all",
"type": "shell",
"command": "curl",
"args": ["http://localhost:8000/trigger/all"],
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": []
} }
] ]

View File

@@ -11,8 +11,16 @@ idna==3.11
Jinja2==3.1.6 Jinja2==3.1.6
MarkupSafe==3.0.3 MarkupSafe==3.0.3
pydantic==2.12.5 pydantic==2.12.5
pydantic-settings==2.12.0
pydantic_core==2.41.5 pydantic_core==2.41.5
<<<<<<< HEAD
starlette==0.50.0 starlette==0.50.0
=======
python-dotenv==1.2.1
PyYAML==6.0.3
starlette==0.38.6
tornado==6.5.4
>>>>>>> 0f1f9e84cbed6b0e9027627cc14355db81c4fa83
typing-inspection==0.4.2 typing-inspection==0.4.2
typing_extensions==4.15.0 typing_extensions==4.15.0
uvicorn==0.40.0 uvicorn==0.40.0

View File

@@ -1,58 +1,64 @@
import asyncio
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
import shutil import shutil
import datetime import time
import os import os
import datetime
from sources import CSItem from sources import CSItem
from inventory import load_cheatsheet_inventory, prepare_cheatsheets from inventory import load_cheatsheet_inventory, prepare_cheatsheets
from config import get_settings
from logger import get_worker_thread_logger
INVENTORY_FILE = "cheatsheet_inventory.json" def build(trigger_list: list[str] | None = None):
STATIC_DIR = "static" start_time = time.time()
TEMPLATES_DIR = "templates" settings = get_settings()
OUTPUT_DIR = "out"
PROD_DIR = "prod"
async def build(trigger_list: list[str] | None = None): inv_raw = load_cheatsheet_inventory(settings.paths.inventory_file)
inv_raw = load_cheatsheet_inventory(INVENTORY_FILE)
# Clear output directory # Clear output directory
shutil.rmtree(OUTPUT_DIR, ignore_errors=True) shutil.rmtree(settings.paths.output, ignore_errors=True)
shutil.copytree(STATIC_DIR, OUTPUT_DIR) shutil.copytree(settings.paths.static, settings.paths.output)
inv, cats, no_cat = prepare_cheatsheets(inv_raw)
inv: list[CSItem] = await prepare_cheatsheets(inv_raw, OUTPUT_DIR) if not os.path.exists(settings.paths.prod):
os.mkdir(settings.paths.prod)
if not os.path.exists(PROD_DIR):
os.mkdir(PROD_DIR)
env = Environment( env = Environment(
loader=FileSystemLoader(TEMPLATES_DIR), loader=FileSystemLoader(settings.paths.templates),
autoescape=select_autoescape() autoescape=select_autoescape()
) )
index = env.get_template("index.html.j2") index = env.get_template("index.html.j2")
print(f"{len(inv)} Cheatsheets") logger = get_worker_thread_logger()
logger.info("Generated cheatsheets:")
for i in inv: for i in inv:
print("-", i) logger.info(f"- {i}")
thisYear = datetime.datetime.now().year thisYear = datetime.datetime.now().year
with open(f"{OUTPUT_DIR}/index.html", "w", encoding="utf-8") as f: print(cats)
f.write(index.render(items=inv, thisYear=thisYear))
with open(f"{OUTPUT_DIR}/impressum.html", "w", encoding="utf-8") as f: with open(f"{settings.paths.output}/index.html", "w", encoding="utf-8") as f:
f.write(index.render(cats=list(
sorted(
map(lambda c: (c[0], list(c[1].items())), cats.items()),
key=lambda x: x[0]
)
), no_cat=no_cat, thisYear=thisYear))
with open(f"{settings.paths.output}/impressum.html", "w", encoding="utf-8") as f:
f.write(env.get_template("impressum.html.j2").render(thisYear=thisYear)) f.write(env.get_template("impressum.html.j2").render(thisYear=thisYear))
with open(f"{OUTPUT_DIR}/license.html", "w", encoding="utf-8") as f: with open(f"{settings.paths.output}/license.html", "w", encoding="utf-8") as f:
f.write(env.get_template("license.html.j2").render(thisYear=thisYear)) f.write(env.get_template("license.html.j2").render(thisYear=thisYear))
# Copy to prod # Copy to prod
print("Copying to prod directory...") logger.info("Copying output to production directory")
shutil.copytree(OUTPUT_DIR, PROD_DIR, dirs_exist_ok=True) shutil.copytree(settings.paths.output, settings.paths.prod, dirs_exist_ok=True)
print("Done.") logger.info("Done after {:.2f}s".format(time.time() - start_time))
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(build()) build()

52
src/config.py Normal file
View File

@@ -0,0 +1,52 @@
from pydantic import Field
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, YamlConfigSettingsSource
class PathsConfig(BaseSettings):
"""Path configuration settings"""
inventory_file: str = Field(default="cheatsheet_inventory.json", description="Cheatsheet inventory file")
templates: str = Field(default="templates", description="Templates directory")
static: str = Field(default="static", description="Static files directory")
output: str = Field(default="out", description="Output directory")
prod: str = Field(default="prod", description="Production directory")
cache: str = Field(default="cache", description="Cache directory")
class Settings(BaseSettings):
"""Main application settings loaded from YAML and environment variables"""
paths: PathsConfig = Field(default_factory=PathsConfig)
request_timeout: float = Field(default=2.0, description="Request timeout in seconds")
log_level: str = Field(default="DEBUG", description="Logging level")
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
return (
init_settings,
env_settings,
dotenv_settings,
YamlConfigSettingsSource(cls, yaml_file="config.yaml"),
)
# Global settings instance
global_settings: Settings = None
def get_settings() -> Settings:
"""Get the global settings instance, loading it if necessary"""
global global_settings
if global_settings is None:
global_settings = Settings()
return global_settings
def load_settings() -> Settings:
global global_settings
global_settings = Settings()

View File

@@ -1,12 +1,16 @@
import os import os
import traceback import traceback
import re
from sources import CSInventoryConfig, CSItem, CheatsheetSourceType from sources import CSInventoryConfig, CSItem, CheatsheetSourceType
from sources.plain import process_plain_url from sources.plain import process_plain_url
from sources.gitea import process_gitea from sources.gitea import process_gitea
from sources.codeberg import process_codeberg
from logger import get_worker_thread_logger
def load_cheatsheet_inventory(file: str) -> CSInventoryConfig: def load_cheatsheet_inventory(file: str) -> CSInventoryConfig:
logger = get_worker_thread_logger()
logger.info(f"Loading cheatsheet inventory from {file}")
if not os.path.exists(file): if not os.path.exists(file):
res = CSInventoryConfig(items=[]) res = CSInventoryConfig(items=[])
else: else:
@@ -20,33 +24,60 @@ def load_cheatsheet_inventory(file: str) -> CSInventoryConfig:
return res return res
async def prepare_cheatsheets(config: CSInventoryConfig, outdir: str) -> list[CSItem]: def prepare_cheatsheets(config: CSInventoryConfig) -> list[CSItem]:
res: list[CSItem] = [] res: list[CSItem] = []
logger = get_worker_thread_logger()
for item in config.items: for item in config.items:
new_items = [] new_items = []
try: try:
match item.source.type: match item.source.type:
case CheatsheetSourceType.GITEA_SOURCE: case CheatsheetSourceType.GITEA_SOURCE:
new_items += await process_gitea(item, outdir) new_items += process_gitea(item)
case CheatsheetSourceType.PLAIN_URL: case CheatsheetSourceType.PLAIN_URL:
new_items.append(await process_plain_url(item, outdir)) new_items.append(process_plain_url(item))
case CheatsheetSourceType.CODEBERG_SOURCE:
new_items += process_codeberg(item)
case _: case _:
print("Unknow Source Type:", item.source.type) logger.warning("Unknown Source Type: %s", item.source.type)
except: except:
traceback.print_exc() logger.error("Error processing item: %s", item)
print("Error processing item:", item) logger.error(traceback.format_exc())
new_item = None new_item = None
if new_items: if new_items:
for new_item in new_items: for new_item in new_items:
print("->", new_item) new_item: CSItem = new_item
logger.debug(f"-> {new_item.title} ({new_item.url})")
res.append(new_item) res.append(new_item)
no_cat: list[CSItem] = []
cats: dict[int, dict[str, list[CSItem]]] = {}
return res for item in res:
m = re.match(r"[sS][eE][mM]([eE][sS][tT][eE][rR])?(\d+)-(.*)", item.title.strip())
if m:
semester = int(m.group(2))
module = str(m.group(3)).lower().strip() \
.replace("_", " ").replace("-", " ").title()
if semester not in cats:
cats[semester] = {}
if module not in cats[semester]:
cats[semester][module] = []
item.semester = semester
item.module = module
cats[semester][module].append(item)
else:
no_cat.append(item)
return res, cats, no_cat

60
src/logger.py Normal file
View File

@@ -0,0 +1,60 @@
# Source - https://stackoverflow.com/a
# Posted by Chris, modified by community. See post 'Timeline' for change history
# Retrieved 2026-01-24, License - CC BY-SA 4.0
import logging
LOGGING_CONFIG = {
'version': 1,
'disable_existing_loggers': True,
'formatters': {
'standard': {
'format': '%(asctime)s [%(levelname)-7s][%(name)-24s]: %(message)s'
},
},
'handlers': {
'default': {
'formatter': 'standard',
'class': 'logging.StreamHandler',
'stream': 'ext://sys.stdout', # Default is stderr
},
'stream_handler': {
'formatter': 'standard',
'class': 'logging.StreamHandler',
'stream': 'ext://sys.stdout', # Default is stderr
},
'file_handler': {
'formatter': 'standard',
'class': 'logging.handlers.RotatingFileHandler',
'filename': 'app.log',
'maxBytes': 1024 * 1024 * 1, # = 1MB
'backupCount': 3,
},
},
'loggers': {
'uvicorn': {
'handlers': ['default', 'file_handler'],
'level': 'TRACE',
'propagate': False
},
'uvicorn.access': {
'handlers': ['stream_handler', 'file_handler'],
'level': 'TRACE',
'propagate': False
},
'uvicorn.error': {
'handlers': ['stream_handler', 'file_handler'],
'level': 'TRACE',
'propagate': False
},
'uvicorn.asgi': {
'handlers': ['stream_handler', 'file_handler'],
'level': 'TRACE',
'propagate': False
},
},
}
def get_worker_thread_logger() -> logging.Logger:
logger = logging.getLogger("uvicorn").getChild("build_thread")
return logger

View File

@@ -1,10 +1,13 @@
from fastapi import FastAPI, HTTPException, Depends from fastapi import FastAPI
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pydantic import BaseModel from pydantic import BaseModel
import queue import janus
import asyncio import threading
import traceback
from logging import getLogger
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import os
from logger import LOGGING_CONFIG, get_worker_thread_logger
from config import load_settings
from build import build as run_build from build import build as run_build
@@ -13,48 +16,57 @@ class TriggerRequest(BaseModel):
items: list[str] items: list[str]
build_queue: asyncio.Queue = None build_queue: janus.Queue
build_queue_sync: janus.SyncQueue
build_queue_async: janus.AsyncQueue
def worker():
logger = get_worker_thread_logger()
logger.info("Build queue thread started")
error_counter = 0
while error_counter < 100:
try:
while True:
selected = build_queue_sync.get()
logger.info(f"Processing build request for: {selected}")
run_build(trigger_list=selected)
except:
traceback.print_exc()
error_counter += 1
async def worker():
print("Build queue thread started")
while True:
selected = await build_queue.get()
print("Processing build request for:", selected)
await run_build(trigger_list=selected)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
global build_queue global build_queue, build_queue_sync, build_queue_async
build_queue = asyncio.Queue() settings = load_settings()
task = asyncio.create_task(worker()) logger = getLogger("uvicorn").getChild("lifespan")
try: build_queue = janus.Queue()
yield build_queue_sync = build_queue.sync_q
finally: build_queue_async = build_queue.async_q
task.cancel()
try: t = threading.Thread(target=worker)
await task t.daemon = True
except asyncio.CancelledError: t.start()
pass
yield
app = FastAPI(title="FSSquared Trigger API", lifespan=lifespan) app = FastAPI(title="FSSquared Trigger API", lifespan=lifespan)
@app.post("/trigger") @app.post("/trigger")
async def trigger(payload: TriggerRequest): async def trigger(payload: TriggerRequest):
build_queue.put(payload.items) await build_queue_async.put(payload.items)
return {"status": "ok", "requested": payload.items} return {"status": "ok", "requested": payload.items}
@app.post("/trigger/all") @app.post("/trigger/all")
async def trigger_all(): async def trigger_all():
await build_queue.put(None) await build_queue_async.put(None)
return {"status": "ok", "requested": "all"} return {"status": "ok", "requested": "all"}
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="debug") uvicorn.run(app, host="0.0.0.0", port=8000, log_config=LOGGING_CONFIG)

View File

@@ -7,14 +7,26 @@ from uuid import uuid4
# Configuration # Configuration
class CheatsheetSourceType(str, Enum): class CheatsheetSourceType(str, Enum):
GITEA_SOURCE = "gitea" GITEA_SOURCE = "gitea"
CODEBERG_SOURCE = "codeberg"
PLAIN_URL = "url" PLAIN_URL = "url"
class CSSourceBase(BaseModel): class CSSourceBase(BaseModel):
type: CheatsheetSourceType type: CheatsheetSourceType
class CSSourceGitea(CSSourceBase): class CSSourceGitea(CSSourceBase):
type: Literal[CheatsheetSourceType.GITEA_SOURCE] type: Literal[CheatsheetSourceType.GITEA_SOURCE]
base_url: str base_url: str
fetch_url: str | None = Field(default=None)
repo: str
owner: str
tag: str
hide_repo: bool = False
class CSSourceCodeberg(CSSourceBase):
type: Literal[CheatsheetSourceType.CODEBERG_SOURCE]
base_url: str
fetch_url: str | None = Field(default=None)
repo: str repo: str
owner: str owner: str
tag: str tag: str
@@ -25,7 +37,7 @@ class CSSourcePlainURL(CSSourceBase):
url: str url: str
repo_url: str repo_url: str
CSSourceType = CSSourcePlainURL | CSSourceGitea CSSourceType = CSSourcePlainURL | CSSourceGitea | CSSourceCodeberg
class CSInventoryItem(BaseModel): class CSInventoryItem(BaseModel):
source: CSSourceType source: CSSourceType
@@ -47,4 +59,6 @@ class CSItem(BaseModel):
id: str id: str
git_repo: str git_repo: str
git_repo_type: str git_repo_type: str
semester: int | None = 0
module: str | None = ""

146
src/sources/codeberg.py Normal file
View File

@@ -0,0 +1,146 @@
from datetime import datetime
from sources import CSSourceGitea, CSItem, CSInventoryItem
from sources.util import cache_cheatsheet, get_datestring
import httpx
from pathlib import Path
from logger import get_worker_thread_logger
def process_codeberg(item: CSInventoryItem) -> list[CSItem] | None:
logger = get_worker_thread_logger()
logger.info(f"Processing Gitea cheatsheet: {item.source.owner}/{item.source.repo}@{item.source.tag}")
source: CSSourceGitea = item.source
commit_hash, tag_id = get_codeberg_release_commit_sha(source.fetch_url or source.base_url, source.owner, source.repo, source.tag)
assets = list_codeberg_release_assets(source.fetch_url or source.base_url, source.owner, source.repo, tag_id)
assets = list(filter(lambda a: a[1].endswith(".pdf"), assets))
print(assets)
assets_urls = list(map(lambda a: (
f"{source.fetch_url or source.base_url}/{source.owner}/{source.repo}/releases/download/{source.tag}/{a[1]}",
f"{source.base_url}/{source.owner}/{source.repo}/releases/download/{source.tag}/{a[1]}"
), assets),
)
logger.info(f"Found {len(assets_urls)} PDF assets in Gitea release {source.owner}/{source.repo}@{source.tag}")
res = []
for fetch_url, real_url in assets_urls:
if item.cache:
cache_url = cache_cheatsheet(fetch_url)
if cache_url:
real_url = cache_url
else:
continue
name = Path(real_url).stem
res.append(CSItem(
url = real_url,
date=get_datestring(),
commit=commit_hash[:10] if commit_hash else "",
author=item.author if item.author else source.owner,
title=f"{name}",
id=item.id,
git_repo=f"{source.base_url}/{source.owner}/{source.repo}" if not source.hide_repo else "",
git_repo_type="Codeberg"
))
return res
def get_codeberg_release_commit_sha(base_url, owner, repo, tag_name, token=None):
"""
Resolve the commit SHA for a Codeberg release tag.
:param base_url: e.g. "https://codeberg.org"
:param owner: repo owner
:param repo: repository name
:param tag_name: release tag (e.g. "v1.2.3")
:param token: optional API token
:return: commit SHA (str)
"""
logger = get_worker_thread_logger()
with httpx.Client() as client:
headers = {}
if token:
headers["Authorization"] = f"token {token}"
# 1) List tags and find the matching tag
tags_url = f"{base_url}/api/v1/repos/{owner}/{repo}/tags"
resp = client.get(tags_url, headers=headers)
resp.raise_for_status()
tags = resp.json()
sorted_tags = list(sorted(tags, key=lambda t: datetime.fromisoformat(t["commit"]["created"]), reverse=True) )
tag = sorted_tags[0] if sorted_tags else None
if not tag:
logger.warning(f"Tag '{tag_name}' not found")
return None, ""
# Lightweight tags usually already contain the commit SHA
commit_sha = tag.get("commit", {}).get("sha")
tag_obj_sha = tag.get("id")
# If commit.sha looks valid, return it
if commit_sha:
return commit_sha, tag.get("name")
# 2) Annotated tag: dereference via /git/tags/{sha}
if not tag_obj_sha:
raise RuntimeError("Tag object SHA missing; cannot dereference annotated tag")
git_tag_url = f"{base_url}/api/v1/repos/{owner}/{repo}/git/tags/{tag_obj_sha}"
resp = client.get(git_tag_url, headers=headers)
resp.raise_for_status()
annotated = resp.json()
# The object pointed to by the tag (usually a commit)
target = annotated.get("object", {})
if target.get("type") != "commit":
raise RuntimeError(f"Tag points to a {target.get('type')} instead of a commit")
return target.get("sha"), tag.get("name")
def list_codeberg_release_assets(base_url, owner, repo, tag, token=None):
"""
Return a list of (download_url, filename) for all assets of a Codeberg release.
:param base_url: Codeberg host URL, e.g. "https://codeberg.org"
:param owner: repository owner
:param repo: repository name
:param tag: release tag name
:param token: optional API token
:returns: list of (download_url, filename) tuples
"""
with httpx.Client() as client:
headers = {}
if token:
headers["Authorization"] = f"token {token}"
# 1) Get release by tag
rel_url = f"{base_url}/api/v1/repos/{owner}/{repo}/releases/tags/{tag}"
rel_resp = client.get(rel_url, headers=headers)
rel_resp.raise_for_status()
release: dict = rel_resp.json()
assets = release.get("assets", [])
result = []
for asset in assets:
# Codeberg asset info usually contains:
# - "browser_download_url" → direct URL
# - "name" → filename
download_url = asset.get("browser_download_url")
filename = asset.get("name")
if download_url and filename:
result.append((download_url, filename))
return result

View File

@@ -3,34 +3,41 @@ from sources import CSSourceGitea, CSItem, CSInventoryItem
from sources.util import cache_cheatsheet, get_datestring from sources.util import cache_cheatsheet, get_datestring
import httpx import httpx
from pathlib import Path from pathlib import Path
from logger import get_worker_thread_logger
async def process_gitea(item: CSInventoryItem, outdir: str) -> list[CSItem] | None: def process_gitea(item: CSInventoryItem) -> list[CSItem] | None:
logger = get_worker_thread_logger()
logger.info(f"Processing Gitea cheatsheet: {item.source.owner}/{item.source.repo}@{item.source.tag}")
source: CSSourceGitea = item.source source: CSSourceGitea = item.source
commit_hash = await get_release_commit_sha(source.base_url, source.owner, source.repo, source.tag) commit_hash = get_gitea_release_commit_sha(source.fetch_url or source.base_url, source.owner, source.repo, source.tag)
asserts = await list_release_assets(source.base_url, source.owner, source.repo, source.tag) assets = list_gitea_release_assets(source.fetch_url or source.base_url, source.owner, source.repo, source.tag)
asserts = list(filter(lambda a: a[1].endswith(".pdf"), asserts)) assets = list(filter(lambda a: a[1].endswith(".pdf"), assets))
asserts = list(map(lambda a: (a[0], f"{source.base_url}/repos/{source.owner}/{source.repo}/releases/download/{source.tag}/{a[0]}"), asserts)) print(assets)
assets_urls = list(map(lambda a: (
f"{source.fetch_url or source.base_url}/{source.owner}/{source.repo}/releases/download/{source.tag}/{a[1]}",
f"{source.base_url}/{source.owner}/{source.repo}/releases/download/{source.tag}/{a[1]}"
), assets),
)
print(f"Found {len(asserts)} PDF assets in Gitea release {source.owner}/{source.repo}@{source.tag}") logger.info(f"Found {len(assets_urls)} PDF assets in Gitea release {source.owner}/{source.repo}@{source.tag}")
res = [] res = []
for a in asserts: for fetch_url, real_url in assets_urls:
res_url = a[0]
if item.cache: if item.cache:
cache_url = await cache_cheatsheet(a[0], outdir) cache_url = cache_cheatsheet(fetch_url)
if cache_url: if cache_url:
res_url = cache_url real_url = cache_url
else: else:
continue continue
name = Path(a[1]).stem name = Path(real_url).stem
res.append(CSItem( res.append(CSItem(
url = res_url, url = real_url,
date=get_datestring(), date=get_datestring(),
commit=commit_hash[:10] if commit_hash else "", commit=commit_hash[:10] if commit_hash else "",
author=item.author if item.author else source.owner, author=item.author if item.author else source.owner,
@@ -42,7 +49,7 @@ async def process_gitea(item: CSInventoryItem, outdir: str) -> list[CSItem] | No
return res return res
async def get_release_commit_sha(base_url, owner, repo, tag_name, token=None): def get_gitea_release_commit_sha(base_url, owner, repo, tag_name, token=None):
""" """
Resolve the commit SHA for a Gitea release tag. Resolve the commit SHA for a Gitea release tag.
@@ -55,14 +62,14 @@ async def get_release_commit_sha(base_url, owner, repo, tag_name, token=None):
""" """
async with httpx.AsyncClient() as client: with httpx.Client() as client:
headers = {} headers = {}
if token: if token:
headers["Authorization"] = f"token {token}" headers["Authorization"] = f"token {token}"
# 1) List tags and find the matching tag # 1) List tags and find the matching tag
tags_url = f"{base_url}/api/v1/repos/{owner}/{repo}/tags" tags_url = f"{base_url}/api/v1/repos/{owner}/{repo}/tags"
resp = await client.get(tags_url, headers=headers) resp = client.get(tags_url, headers=headers)
resp.raise_for_status() resp.raise_for_status()
tags = resp.json() tags = resp.json()
@@ -83,7 +90,7 @@ async def get_release_commit_sha(base_url, owner, repo, tag_name, token=None):
raise RuntimeError("Tag object SHA missing; cannot dereference annotated tag") raise RuntimeError("Tag object SHA missing; cannot dereference annotated tag")
git_tag_url = f"{base_url}/api/v1/repos/{owner}/{repo}/git/tags/{tag_obj_sha}" git_tag_url = f"{base_url}/api/v1/repos/{owner}/{repo}/git/tags/{tag_obj_sha}"
resp = await client.get(git_tag_url, headers=headers) resp = client.get(git_tag_url, headers=headers)
resp.raise_for_status() resp.raise_for_status()
annotated = resp.json() annotated = resp.json()
@@ -95,7 +102,7 @@ async def get_release_commit_sha(base_url, owner, repo, tag_name, token=None):
return target.get("sha") return target.get("sha")
async def list_release_assets(base_url, owner, repo, tag, token=None): def list_gitea_release_assets(base_url, owner, repo, tag, token=None):
""" """
Return a list of (download_url, filename) for all assets of a Gitea release. Return a list of (download_url, filename) for all assets of a Gitea release.
@@ -107,14 +114,14 @@ async def list_release_assets(base_url, owner, repo, tag, token=None):
:returns: list of (download_url, filename) tuples :returns: list of (download_url, filename) tuples
""" """
async with httpx.AsyncClient() as client: with httpx.Client() as client:
headers = {} headers = {}
if token: if token:
headers["Authorization"] = f"token {token}" headers["Authorization"] = f"token {token}"
# 1) Get release by tag # 1) Get release by tag
rel_url = f"{base_url}/api/v1/repos/{owner}/{repo}/releases/tags/{tag}" rel_url = f"{base_url}/api/v1/repos/{owner}/{repo}/releases/tags/{tag}"
rel_resp = await client.get(rel_url, headers=headers) rel_resp = client.get(rel_url, headers=headers)
rel_resp.raise_for_status() rel_resp.raise_for_status()
release: dict = rel_resp.json() release: dict = rel_resp.json()

View File

@@ -1,12 +1,13 @@
from sources import CSInventoryItem, CSSourcePlainURL, CSItem from sources import CSInventoryItem, CSSourcePlainURL, CSItem
from sources.util import cache_cheatsheet, get_datestring from sources.util import cache_cheatsheet, get_datestring
from config import get_settings
async def process_plain_url(item: CSInventoryItem, outdir: str) -> CSItem | None: async def process_plain_url(item: CSInventoryItem) -> CSItem | None:
source: CSSourcePlainURL = item.source source: CSSourcePlainURL = item.source
res_url = source.url res_url = source.url
if item.cache: if item.cache:
cache_url = await cache_cheatsheet(source.url, outdir) cache_url = await cache_cheatsheet(source.url)
if cache_url: if cache_url:
res_url = cache_url res_url = cache_url
else: else:

View File

@@ -1,39 +1,49 @@
import hashlib
import httpx import httpx
import datetime import datetime
import os import os
from pathlib import Path from pathlib import Path
from logger import get_worker_thread_logger
from urllib.parse import urlparse from urllib.parse import urlparse
from config import get_settings
def get_datestring() -> str: def get_datestring() -> str:
return datetime.datetime.now().strftime("%d.%m.%y") return datetime.datetime.now().strftime("%d.%m.%y (%H:%M:%S)")
def cache_cheatsheet(url) -> str | None:
async def cache_cheatsheet(url, outdir: str) -> str | None: settings = get_settings()
logger = get_worker_thread_logger()
print("Caching cheatsheet from", url) logger.info(f"Caching cheatsheet from {url}")
try: try:
async with httpx.AsyncClient() as client: with httpx.Client() as client:
r = await client.get(url, timeout=5.0) r = client.get(url, timeout=5.0)
if not r.is_success and r.headers.get("Content-Type") != "application/pdf": if not r.is_success and r.headers.get("Content-Type") != "application/pdf":
logger.error(f"Failed to fetch URL: {url} (status code: {r.status_code})")
return None return None
except httpx.TimeoutException: except httpx.TimeoutException:
print("Timeout fetching URL:", url) logger.error(f"Timeout fetching URL: {url}")
return None return None
data = r.content data = r.content
url_base_name = Path(urlparse(url).path).stem url_base_name = Path(urlparse(url).path).stem
filesname = os.path.join("cache", f"{url_base_name}.pdf") filesname = os.path.join(f"{url_base_name}.pdf")
if not os.path.exists(os.path.join(outdir, "cache")): if not os.path.exists(os.path.join(settings.paths.cache)):
os.mkdir(os.path.join(outdir, "cache")) os.mkdir(os.path.join(settings.paths.cache))
with open(os.path.join(outdir, filesname), "wb") as f:
if not os.path.exists(os.path.join(settings.paths.output, "cache")):
os.mkdir(os.path.join(settings.paths.output, "cache"))
with open(os.path.join(settings.paths.output, "cache", filesname), "wb") as f:
f.write(data) f.write(data)
print("Saved file to", filesname) with open(os.path.join(settings.paths.cache, filesname), "wb") as f:
f.write(data)
return filesname logger.info(f"Saved file to {filesname}")
return os.path.join("cache", filesname)

View File

@@ -47,8 +47,7 @@ nav.navbar a:hover {
} }
/* Main content area padding */ /* Main content area padding */
body > h1, body > h1, body > h2, body > h3, body > table, body > p {
body > table {
margin-left: 20px; margin-left: 20px;
margin-right: 20px; margin-right: 20px;
} }
@@ -61,10 +60,30 @@ h1 {
padding-bottom: 10px; padding-bottom: 10px;
} }
h2 {
color: #444;
font-size: 1.2em;
text-align: center;
font-weight: bold;
margin-top: 20px;
border-top: 1px solid #999;
padding-top: 10px;
padding-bottom: 5px;
}
h3 {
color: #333;
text-align: start;
font-weight: normal;
margin-bottom: 5px;
margin-top: 5px;
}
table { table {
width: calc(100% - 40px); width: calc(100% - 40px);
border-collapse: collapse; border-collapse: collapse;
margin-top: 20px; margin-top: 0;
margin-bottom: 20px;
background-color: #ffffff; background-color: #ffffff;
} }
@@ -77,11 +96,35 @@ th, td {
text-align: left; text-align: left;
} }
thead {
height: 15px;
}
th { th {
background-color: #c0c0c0; background-color: #c0c0c0;
font-weight: bold; font-weight: bold;
} }
tr > :nth-child(1), tr > :nth-child(1) {
width: 40%;
}
tr > :nth-child(2), tr > :nth-child(2) {
width: 15%;
}
tr > :nth-child(3), tr > :nth-child(3) {
width: 15%;
}
tr > :nth-child(4), tr > :nth-child(4) {
width: 15%;
}
tr > :nth-child(5), tr > :nth-child(5) {
width: 15%;
}
tbody tr:hover { tbody tr:hover {
background-color: #e8e8ff; background-color: #e8e8ff;
} }

View File

@@ -1 +1 @@
{"version":3,"sourceRoot":"","sources":["../../styles/main.scss"],"names":[],"mappings":"AAAA;AAAA;AAAA;AAAA;AAKA;EACI;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;EAEA;;;AAGJ;AACA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;;;AAGJ;AACA;AAAA;EAEI;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;;;AAGJ;EACI;;;AAGJ;EACI;EACA;;;AAGJ;EACI;EACA;;;AAGJ;EACI;;;AAGJ;AACA;EACI;EACA;EACA;EACA;EACA;EACA","file":"main.css"} {"version":3,"sourceRoot":"","sources":["../../styles/main.scss"],"names":[],"mappings":"AAAA;AAAA;AAAA;AAAA;AAKA;EACI;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;EAEA;;;AAGJ;AACA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;;;AAGJ;AACA;EACI;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;;;AAGJ;EACI;;;AAGJ;EACI;EACA;;;AAGJ;EACI;;;AAGJ;EACI;EACA;;;AAGJ;EACI;;;AAGJ;EACI;;;AAGJ;EACI;;;AAGJ;EACI;;;AAGJ;EACI;;;AAGJ;EACI;;;AAGJ;AACA;EACI;EACA;EACA;EACA;EACA;EACA","file":"main.css"}

View File

@@ -50,8 +50,7 @@ nav.navbar a:hover {
} }
/* Main content area padding */ /* Main content area padding */
body > h1, body > h1, body > h2, body > h3, body > table, body > p {
body > table {
margin-left: 20px; margin-left: 20px;
margin-right: 20px; margin-right: 20px;
} }
@@ -64,10 +63,30 @@ h1 {
padding-bottom: 10px; padding-bottom: 10px;
} }
h2 {
color: #444;
font-size: 1.2em;
text-align: center;
font-weight: bold;
margin-top: 20px;
border-top: 1px solid #999;
padding-top: 10px;
padding-bottom: 5px;
}
h3 {
color: #333;
text-align: start;
font-weight: normal;
margin-bottom: 5px;
margin-top: 5px;
}
table { table {
width: calc(100% - 40px); width: calc(100% - 40px);
border-collapse: collapse; border-collapse: collapse;
margin-top: 20px; margin-top: 0;
margin-bottom: 20px;
background-color: #ffffff; background-color: #ffffff;
} }
@@ -80,11 +99,35 @@ th, td {
text-align: left; text-align: left;
} }
thead {
height: 15px;
}
th { th {
background-color: #c0c0c0; background-color: #c0c0c0;
font-weight: bold; font-weight: bold;
} }
tr > :nth-child(1), tr > :nth-child(1) {
width: 40%;
}
tr > :nth-child(2), tr > :nth-child(2) {
width: 15%;
}
tr > :nth-child(3), tr > :nth-child(3) {
width: 15%;
}
tr > :nth-child(4), tr > :nth-child(4) {
width: 15%;
}
tr > :nth-child(5), tr > :nth-child(5) {
width: 15%;
}
tbody tr:hover { tbody tr:hover {
background-color: #e8e8ff; background-color: #e8e8ff;
} }

View File

@@ -4,50 +4,111 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
<title>FS²</title> <title>typst4ei</title>
</head> </head>
<body> <body>
{% include "navbar.j2" %} {% include "navbar.j2" %}
<h1>Formel(sammlung)²</h1> <h1>typst4ei</h1>
<table> <p>Eine Sammlung von Formelsammlung für/von EI Stundenten der TUM</p>
<thead>
<tr>
<th>Title</th>
<th>Repo</th>
<th>Upload Date</th>
<th>Git commit</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
<a href="{{ item.url }}">{{ item.title }}</a>
{% if item.author %} <p>
<br>by {{ item.author }} Disclaimer: Die Richtigkeit des Materials kann nicht garantiert werden.
{% endif %} Wir wissen auch nicht was wir tun. Nutzt die Formelsammlungen auf eigene Gefahr.
</td>
<td>
{% if item.git_repo %}
<a href="{{ item.git_repo }}">{{ item.git_repo_type }}</a>
{% else %}
N/A
{% endif %}
</td>
<td>
{{ item.date }}
</td>
<td>
{% if item.git_repo %}
{{ item.commit }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
Aber Feedback und Korrekturen sind immer willkommen!
</p>
{% for item in cats %}
<h2>{{ item[0] }}. Semester</h2>
<table>
<thead>
<tr>
<th>Title</th>
<th>Autor</th>
<th>Repo</th>
<th>Upload Date</th>
<th>Git commit</th>
</tr>
</thead>
<tbody>
{% for module in item[1] %}
{% for item in module[1] %}
<tr>
<td>
<a href="{{ item.url }}">{{ module[0] }}</a>
</td>
<td>
{% if item.author %}
{{ item.author }}
{% else %}
N/A
{% endif %}
</td>
<td>
{% if item.git_repo %}
<a href="{{ item.git_repo }}">{{ item.git_repo_type }}</a>
{% else %}
N/A
{% endif %}
</td>
<td>
{{ item.date }}
</td>
<td>
{% if item.git_repo %}
{{ item.commit }}
{% endif %}
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
{% endfor%}
{% if no_cat %}
<h2>Verschiedenes</h2>
<table>
<thead>
<tr>
<th>Title</th>
<th>Repo</th>
<th>Upload Date</th>
<th>Git commit</th>
</tr>
</thead>
<tbody>
{% for item in no_cat %}
<tr>
<td>
<a href="{{ item.url }}">{{ item.title }}</a>
{% if item.author %}
<br>by {{ item.author }}
{% endif %}
</td>
<td>
{% if item.git_repo %}
<a href="{{ item.git_repo }}">{{ item.git_repo_type }}</a>
{% else %}
N/A
{% endif %}
</td>
<td>
{{ item.date }}
</td>
<td>
{% if item.git_repo %}
{{ item.commit }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</body> </body>
</html> </html>

View File

@@ -4,6 +4,6 @@
<a href="index.html">Home</a> <a href="index.html">Home</a>
<a href="https://gitea.mintcalc.com/alexander/FSSquared">Gitea</a> <a href="https://gitea.mintcalc.com/alexander/FSSquared">Gitea</a>
<a href="impressum.html">Impressum</a> <!--<a href="impressum.html">Impressum</a>-->
<a href="license.html">License</a> <a href="license.html">License</a>
</nav> </nav>