Merge branch 'main' of gitea.mintcalc.com:typst4ei/TUM-typst4ei
# Conflicts: # requirements.txt
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
11
.vscode/tasks.json
vendored
@@ -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": []
|
||||||
}
|
}
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
58
src/build.py
58
src/build.py
@@ -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
52
src/config.py
Normal 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()
|
||||||
@@ -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
60
src/logger.py
Normal 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
|
||||||
64
src/main.py
64
src/main.py
@@ -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
|
||||||
|
|
||||||
async def worker():
|
def worker():
|
||||||
print("Build queue thread started")
|
logger = get_worker_thread_logger()
|
||||||
|
logger.info("Build queue thread started")
|
||||||
|
error_counter = 0
|
||||||
|
while error_counter < 100:
|
||||||
|
try:
|
||||||
while True:
|
while True:
|
||||||
selected = await build_queue.get()
|
selected = build_queue_sync.get()
|
||||||
print("Processing build request for:", selected)
|
logger.info(f"Processing build request for: {selected}")
|
||||||
await run_build(trigger_list=selected)
|
run_build(trigger_list=selected)
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
|
error_counter += 1
|
||||||
|
|
||||||
|
|
||||||
@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")
|
||||||
|
|
||||||
|
build_queue = janus.Queue()
|
||||||
|
build_queue_sync = build_queue.sync_q
|
||||||
|
build_queue_async = build_queue.async_q
|
||||||
|
|
||||||
|
t = threading.Thread(target=worker)
|
||||||
|
t.daemon = True
|
||||||
|
t.start()
|
||||||
|
|
||||||
try:
|
|
||||||
yield
|
yield
|
||||||
finally:
|
|
||||||
task.cancel()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
@@ -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
146
src/sources/codeberg.py
Normal 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
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,73 @@
|
|||||||
<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>
|
||||||
|
<p>Eine Sammlung von Formelsammlung für/von EI Stundenten der TUM</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
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!
|
||||||
|
</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>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -20,7 +81,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in items %}
|
{% for item in no_cat %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ item.url }}">{{ item.title }}</a>
|
<a href="{{ item.url }}">{{ item.title }}</a>
|
||||||
@@ -48,6 +109,6 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user