diff --git a/.gitignore b/.gitignore index a04cb52..3921f9b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,13 @@ .venv out prod +cache node_modules __pycache__/ package-lock.json package.json -cheatsheet_inventory.json \ No newline at end of file +cheatsheet_inventory.json +*.log +config.yaml \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ca8a0f3..6172d05 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -39,6 +39,17 @@ "kind": "build", "isDefault": true } + }, + { + "label": "trigger local build all", + "type": "shell", + "command": "curl", + "args": ["http://localhost:8000/trigger/all"], + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": [] } ] diff --git a/requirements.txt b/requirements.txt index daa9af7..37b10b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,8 +11,16 @@ idna==3.11 Jinja2==3.1.6 MarkupSafe==3.0.3 pydantic==2.12.5 +pydantic-settings==2.12.0 pydantic_core==2.41.5 +<<<<<<< HEAD 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_extensions==4.15.0 uvicorn==0.40.0 diff --git a/src/build.py b/src/build.py index 76392d5..cb596cc 100644 --- a/src/build.py +++ b/src/build.py @@ -1,58 +1,64 @@ -import asyncio from jinja2 import Environment, FileSystemLoader, select_autoescape import shutil -import datetime +import time import os +import datetime from sources import CSItem 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" -STATIC_DIR = "static" -TEMPLATES_DIR = "templates" -OUTPUT_DIR = "out" -PROD_DIR = "prod" +def build(trigger_list: list[str] | None = None): + start_time = time.time() + settings = get_settings() -async def build(trigger_list: list[str] | None = None): - inv_raw = load_cheatsheet_inventory(INVENTORY_FILE) + inv_raw = load_cheatsheet_inventory(settings.paths.inventory_file) # Clear output directory - shutil.rmtree(OUTPUT_DIR, ignore_errors=True) - shutil.copytree(STATIC_DIR, OUTPUT_DIR) + shutil.rmtree(settings.paths.output, ignore_errors=True) + 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(PROD_DIR): - os.mkdir(PROD_DIR) + if not os.path.exists(settings.paths.prod): + os.mkdir(settings.paths.prod) env = Environment( - loader=FileSystemLoader(TEMPLATES_DIR), + loader=FileSystemLoader(settings.paths.templates), autoescape=select_autoescape() ) 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: - print("-", i) + logger.info(f"- {i}") thisYear = datetime.datetime.now().year - with open(f"{OUTPUT_DIR}/index.html", "w", encoding="utf-8") as f: - f.write(index.render(items=inv, thisYear=thisYear)) + print(cats) - 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)) - 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)) # Copy to prod - print("Copying to prod directory...") - shutil.copytree(OUTPUT_DIR, PROD_DIR, dirs_exist_ok=True) - print("Done.") + logger.info("Copying output to production directory") + shutil.copytree(settings.paths.output, settings.paths.prod, dirs_exist_ok=True) + logger.info("Done after {:.2f}s".format(time.time() - start_time)) if __name__ == "__main__": - asyncio.run(build()) \ No newline at end of file + build() \ No newline at end of file diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..11df98b --- /dev/null +++ b/src/config.py @@ -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() \ No newline at end of file diff --git a/src/inventory.py b/src/inventory.py index 7419572..b529728 100644 --- a/src/inventory.py +++ b/src/inventory.py @@ -1,12 +1,16 @@ import os import traceback +import re from sources import CSInventoryConfig, CSItem, CheatsheetSourceType from sources.plain import process_plain_url 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: + logger = get_worker_thread_logger() + logger.info(f"Loading cheatsheet inventory from {file}") if not os.path.exists(file): res = CSInventoryConfig(items=[]) else: @@ -20,33 +24,60 @@ def load_cheatsheet_inventory(file: str) -> CSInventoryConfig: return res -async def prepare_cheatsheets(config: CSInventoryConfig, outdir: str) -> list[CSItem]: +def prepare_cheatsheets(config: CSInventoryConfig) -> list[CSItem]: res: list[CSItem] = [] + logger = get_worker_thread_logger() + for item in config.items: new_items = [] try: match item.source.type: case CheatsheetSourceType.GITEA_SOURCE: - new_items += await process_gitea(item, outdir) + new_items += process_gitea(item) 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 _: - print("Unknow Source Type:", item.source.type) + logger.warning("Unknown Source Type: %s", item.source.type) except: - traceback.print_exc() - print("Error processing item:", item) + logger.error("Error processing item: %s", item) + logger.error(traceback.format_exc()) new_item = None if 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) - - - return res + + no_cat: list[CSItem] = [] + cats: dict[int, dict[str, list[CSItem]]] = {} + + 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 diff --git a/src/logger.py b/src/logger.py new file mode 100644 index 0000000..720137d --- /dev/null +++ b/src/logger.py @@ -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 \ No newline at end of file diff --git a/src/main.py b/src/main.py index 0e8ee83..95674aa 100644 --- a/src/main.py +++ b/src/main.py @@ -1,10 +1,13 @@ -from fastapi import FastAPI, HTTPException, Depends -from fastapi.security import HTTPBasic, HTTPBasicCredentials +from fastapi import FastAPI from pydantic import BaseModel -import queue -import asyncio +import janus +import threading +import traceback +from logging import getLogger 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 @@ -13,48 +16,57 @@ class TriggerRequest(BaseModel): 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 async def lifespan(app: FastAPI): - global build_queue - build_queue = asyncio.Queue() - task = asyncio.create_task(worker()) + global build_queue, build_queue_sync, build_queue_async + settings = load_settings() + logger = getLogger("uvicorn").getChild("lifespan") - try: - yield - finally: - task.cancel() + build_queue = janus.Queue() + build_queue_sync = build_queue.sync_q + build_queue_async = build_queue.async_q - try: - await task - except asyncio.CancelledError: - pass + t = threading.Thread(target=worker) + t.daemon = True + t.start() + + yield app = FastAPI(title="FSSquared Trigger API", lifespan=lifespan) - @app.post("/trigger") async def trigger(payload: TriggerRequest): - build_queue.put(payload.items) + await build_queue_async.put(payload.items) return {"status": "ok", "requested": payload.items} @app.post("/trigger/all") async def trigger_all(): - await build_queue.put(None) + await build_queue_async.put(None) return {"status": "ok", "requested": "all"} if __name__ == "__main__": 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) \ No newline at end of file diff --git a/src/sources/__init__.py b/src/sources/__init__.py index 85506a7..3b10bd9 100644 --- a/src/sources/__init__.py +++ b/src/sources/__init__.py @@ -7,14 +7,26 @@ from uuid import uuid4 # Configuration class CheatsheetSourceType(str, Enum): GITEA_SOURCE = "gitea" + CODEBERG_SOURCE = "codeberg" PLAIN_URL = "url" + class CSSourceBase(BaseModel): type: CheatsheetSourceType class CSSourceGitea(CSSourceBase): type: Literal[CheatsheetSourceType.GITEA_SOURCE] 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 owner: str tag: str @@ -25,7 +37,7 @@ class CSSourcePlainURL(CSSourceBase): url: str repo_url: str -CSSourceType = CSSourcePlainURL | CSSourceGitea +CSSourceType = CSSourcePlainURL | CSSourceGitea | CSSourceCodeberg class CSInventoryItem(BaseModel): source: CSSourceType @@ -47,4 +59,6 @@ class CSItem(BaseModel): id: str git_repo: str git_repo_type: str + semester: int | None = 0 + module: str | None = "" diff --git a/src/sources/codeberg.py b/src/sources/codeberg.py new file mode 100644 index 0000000..dde82b1 --- /dev/null +++ b/src/sources/codeberg.py @@ -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 \ No newline at end of file diff --git a/src/sources/gitea.py b/src/sources/gitea.py index 8a8f164..449866a 100644 --- a/src/sources/gitea.py +++ b/src/sources/gitea.py @@ -3,34 +3,41 @@ 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 -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 - commit_hash = await get_release_commit_sha(source.base_url, source.owner, source.repo, source.tag) - asserts = await list_release_assets(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) + 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)) - asserts = list(map(lambda a: (a[0], f"{source.base_url}/repos/{source.owner}/{source.repo}/releases/download/{source.tag}/{a[0]}"), asserts)) + 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), + ) - 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 = [] - for a in asserts: - res_url = a[0] + for fetch_url, real_url in assets_urls: if item.cache: - cache_url = await cache_cheatsheet(a[0], outdir) + cache_url = cache_cheatsheet(fetch_url) if cache_url: - res_url = cache_url + real_url = cache_url else: continue - name = Path(a[1]).stem + name = Path(real_url).stem res.append(CSItem( - url = res_url, + url = real_url, date=get_datestring(), commit=commit_hash[:10] if commit_hash else "", 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 -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. @@ -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 = {} 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 = await client.get(tags_url, headers=headers) + resp = client.get(tags_url, headers=headers) resp.raise_for_status() 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") 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() 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") -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. @@ -107,14 +114,14 @@ async def list_release_assets(base_url, owner, repo, tag, token=None): :returns: list of (download_url, filename) tuples """ - async with httpx.AsyncClient() as client: + 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 = await client.get(rel_url, headers=headers) + rel_resp = client.get(rel_url, headers=headers) rel_resp.raise_for_status() release: dict = rel_resp.json() diff --git a/src/sources/plain.py b/src/sources/plain.py index 83e3a57..1d1de9d 100644 --- a/src/sources/plain.py +++ b/src/sources/plain.py @@ -1,12 +1,13 @@ from sources import CSInventoryItem, CSSourcePlainURL, CSItem 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 res_url = source.url if item.cache: - cache_url = await cache_cheatsheet(source.url, outdir) + cache_url = await cache_cheatsheet(source.url) if cache_url: res_url = cache_url else: diff --git a/src/sources/util.py b/src/sources/util.py index bc34ad8..872e549 100644 --- a/src/sources/util.py +++ b/src/sources/util.py @@ -1,39 +1,49 @@ -import hashlib import httpx import datetime import os from pathlib import Path +from logger import get_worker_thread_logger from urllib.parse import urlparse +from config import get_settings def get_datestring() -> str: - return datetime.datetime.now().strftime("%d.%m.%y") + return datetime.datetime.now().strftime("%d.%m.%y (%H:%M:%S)") - -async def cache_cheatsheet(url, outdir: str) -> str | None: - - print("Caching cheatsheet from", url) +def cache_cheatsheet(url) -> str | None: + settings = get_settings() + logger = get_worker_thread_logger() + logger.info(f"Caching cheatsheet from {url}") try: - async with httpx.AsyncClient() as client: - r = await client.get(url, timeout=5.0) + with httpx.Client() as client: + r = client.get(url, timeout=5.0) 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 except httpx.TimeoutException: - print("Timeout fetching URL:", url) + logger.error(f"Timeout fetching URL: {url}") return None data = r.content 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")): - os.mkdir(os.path.join(outdir, "cache")) + if not os.path.exists(os.path.join(settings.paths.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) - print("Saved file to", filesname) + with open(os.path.join(settings.paths.cache, filesname), "wb") as f: + f.write(data) + + logger.info(f"Saved file to {filesname}") - return filesname + return os.path.join("cache", filesname) diff --git a/static/css/main.css b/static/css/main.css index fb69368..6dcdcf4 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -47,8 +47,7 @@ nav.navbar a:hover { } /* Main content area padding */ -body > h1, -body > table { +body > h1, body > h2, body > h3, body > table, body > p { margin-left: 20px; margin-right: 20px; } @@ -61,10 +60,30 @@ h1 { 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 { width: calc(100% - 40px); border-collapse: collapse; - margin-top: 20px; + margin-top: 0; + margin-bottom: 20px; background-color: #ffffff; } @@ -77,11 +96,35 @@ th, td { text-align: left; } +thead { + height: 15px; +} + th { background-color: #c0c0c0; 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 { background-color: #e8e8ff; } diff --git a/static/css/main.css.map b/static/css/main.css.map index 1ec0b49..e311719 100644 --- a/static/css/main.css.map +++ b/static/css/main.css.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/styles/main.scss b/styles/main.scss index e222248..0849718 100644 --- a/styles/main.scss +++ b/styles/main.scss @@ -50,8 +50,7 @@ nav.navbar a:hover { } /* Main content area padding */ -body > h1, -body > table { +body > h1, body > h2, body > h3, body > table, body > p { margin-left: 20px; margin-right: 20px; } @@ -64,10 +63,30 @@ h1 { 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 { width: calc(100% - 40px); border-collapse: collapse; - margin-top: 20px; + margin-top: 0; + margin-bottom: 20px; background-color: #ffffff; } @@ -80,11 +99,35 @@ th, td { text-align: left; } +thead { + height: 15px; +} + th { background-color: #c0c0c0; 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 { background-color: #e8e8ff; } diff --git a/templates/index.html.j2 b/templates/index.html.j2 index 10d4ba2..55b313a 100644 --- a/templates/index.html.j2 +++ b/templates/index.html.j2 @@ -4,50 +4,111 @@ -
| Title | -Repo | -Upload Date | -Git commit | -
|---|---|---|---|
|
- {{ item.title }}
-
- {% if item.author %}
- by {{ item.author }} - {% endif %} - |
- - {% if item.git_repo %} - {{ item.git_repo_type }} - {% else %} - N/A - {% endif %} - | -- {{ item.date }} - | -- {% if item.git_repo %} - {{ item.commit }} - {% endif %} - | -
Eine Sammlung von Formelsammlung für/von EI Stundenten der TUM
+ ++ Disclaimer: Die Richtigkeit des Materials kann nicht garantiert werden. + Wir wissen auch nicht was wir tun. Nutzt die Formelsammlungen auf eigene Gefahr. + Aber Feedback und Korrekturen sind immer willkommen! +
+ + + {% for item in cats %} +| Title | +Autor | +Repo | +Upload Date | +Git commit | +
|---|---|---|---|---|
| + {{ module[0] }} + | ++ {% if item.author %} + {{ item.author }} + {% else %} + N/A + {% endif %} + | ++ {% if item.git_repo %} + {{ item.git_repo_type }} + {% else %} + N/A + {% endif %} + | ++ {{ item.date }} + | ++ {% if item.git_repo %} + {{ item.commit }} + {% endif %} + | +
| Title | +Repo | +Upload Date | +Git commit | +
|---|---|---|---|
|
+ {{ item.title }}
+
+ {% if item.author %}
+ by {{ item.author }} + {% endif %} + |
+ + {% if item.git_repo %} + {{ item.git_repo_type }} + {% else %} + N/A + {% endif %} + | ++ {{ item.date }} + | ++ {% if item.git_repo %} + {{ item.commit }} + {% endif %} + | +