Merge branch 'main' into fix-svg-include

This commit is contained in:
Matthias Jung
2026-02-14 01:42:43 +01:00
committed by GitHub
30 changed files with 248 additions and 33262 deletions
+26
View File
@@ -0,0 +1,26 @@
name: Pytest Report
on:
workflow_run:
workflows: [Pytest]
types: [completed]
permissions:
actions: read
checks: write
pull-requests: write
jobs:
checks:
runs-on: ubuntu-latest
steps:
- name: Download Test Report
uses: actions/download-artifact@v6
with:
name: pytest-report
github-token: ${{ github.token }}
run-id: ${{ github.event.workflow_run.id }}
- name: Publish Test Report
uses: mikepenz/action-junit-report@v6
with:
commit: ${{github.event.workflow_run.head_sha}}
report_paths: pytest.xml
comment: true
pr_id: ${{ github.event.workflow_run.pull_requests[0].number }}
+6 -10
View File
@@ -2,8 +2,6 @@ name: Pytest
on: pull_request
permissions:
contents: read
checks: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@@ -21,12 +19,10 @@ jobs:
- name: Install failure annotation plugin
run: pip install pytest-github-actions-annotate-failures
- name: Test with pytest
run: |
set -o pipefail
pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov-report=html --cov | tee pytest-coverage.txt
- name: Comment pytest code coverage
uses: MishaKav/pytest-coverage-comment@main
if: ${{ always() }}
run: pytest --junitxml=pytest.xml
- name: Upload Test Report
uses: actions/upload-artifact@v6
with:
pytest-coverage-path: ./pytest-coverage.txt
junitxml-path: ./pytest.xml
name: pytest-report
path: pytest.xml
retention-days: 1
-4
View File
@@ -167,7 +167,3 @@ test/acceptanceTestSlides.html
.DS_Store
.envrc
config/config.json
data/*
!data/fragenkatalog3b.json
!data/README.txt
+7 -10
View File
@@ -2,28 +2,25 @@
50ohm.de ist die Ausbildungsplattform des DARC e.V.
Dieses Repository enthält den Generator für die Seite [50ohm.de](https://50ohm.de), der mit einer Directus-Datenbank als Quelle arbeitet.
Dieses Repository enthält den Generator für die Seite [50ohm.de](https://50ohm.de), der mit einem andern [Repository](https://github.com/DARC-e-V/50ohm-contents-dl/) als Quelle für die Inhalte arbeitet.
## Überblick
Die Ausbildungsmaterialien von 50ohm.de werden in einem erweiterten Marcdownformat geschrieben, intern als Darcdown bezeichnet. Kern des Generators ist ein Parser, der auf [mistletoe](https://github.com/miyuchina/mistletoe) basiert und um zusätzliche Syntax und Ausgabeformate erweitert wurde.
Die Ausbildungsmaterialien von 50ohm.de werden in einem erweiterten Markdownformat geschrieben, intern als DARCdown bezeichnet. Kern des Generators ist ein Parser, der auf [mistletoe](https://github.com/miyuchina/mistletoe) basiert und um zusätzliche Syntax und Ausgabeformate erweitert wurde.
Die möglichen Ausgabeformate sind:
- HTML: [`fifty_ohm_html_renderer.py`](renderer/fifty_ohm_html_renderer.py)
- LaTeX: [`fifty_ohm_latex_renderer`](renderer/fifty_ohm_latex_renderer.py)
- HTML für Slides mit reveal.js: [`fifty_ohm_html_slide_renderer.py`](renderer/fifty_ohm_html_slide_renderer.py)
Prinzipiell lassen sich diese Generatoren auch losgelöst vom Directus einsetzen. Der zweite Teil des Generators liegt in `src`, unterteilt in die Prozessschritte Herunderladen ([`download.py`](src/download.py)) und Seite Bauen ([`build.py`](src/build.py)).
Prinzipiell lassen sich diese Generatoren auch für andere Quell-Repositories verwenden. Dies ist insbesondere für andere IARU-Verbände interessant, welche auch 50ohm als Ausbildungsseite implementieren wollen. Der eigentliche Teil des Generators liegt in `src`, insbesondere das Skript ([`build.py`](src/build.py)). In dieser Datei finden sich außerdem weitere Begleitfunktionen zum Renderprozess, z.B. das Übersetzen von Fragennummern in Fragen oder das Kopieren von Assets.
Hier werden alle Inhalte aus der Datenbank heruntergeladen und strukturiert in verschiedenen JSON-Dateien abgelegt. Diese dienen im Build-Prozess als Quelle für Abschnitte, Kapitelstruktur und weitere Inhalte.
In der `build.py` finden sich außerdem weitere Begleitfunktionen zum Renderprozess, z.B. das Übersetzen von Fragennummern in Fragen oder das Kopieren von Assets.
## Mitmachen
## Mitmachen und Kontakt
Aktuell wird 50ohm durch ein kleines ehrenamtliches Entwicklerteam beim DARC e.V. betreut und weiterentwickelt. Falls du einen Fehler oder einen Funktionswunsch hast, schreibe gerne ein Issue. Auch über einen PR freuen wir uns!
Wenn du inhaltliche Beiträge hast, kannst du diese über dieses [Repository](https://github.com/DARC-e-V/50ohm-contents-dl) einreichen.
Wenn du inhaltliche Beiträge hast, wende dich bitte an das 50ohm-Autorenteam.
Bei Anfragen bitte die Email-Adresse [[email protected]](mailto:[email protected]) nutzen. Weiterhin findet man viele der Beitragenden im Matrix-Kanal [50ohm Contribution](https://matrix.to/#/#50ohm-contribution:darc.de).
### Python einrichten und Abhängigkeiten installieren
@@ -44,7 +41,7 @@ $ uv pip sync requirements.txt
### Ausführen
Um 50ohm ausführen zu können, wird in den meisten Fällen ein Zugang zu passenden Directus-Instanzen benötigt. Diese können über eine `config/config.json` konfiguriert werden.
Um 50ohm ausführen zu können, muss der Pfad zu einem lokal ausgechecktem Content-Repository in die `config/config.json` eingetragen werden. Ein Beispiel-Content-Repository für deutsche Inhalte findet sich hier: [50ohm-contents-dl](https://github.com/DARC-e-V/50ohm-contents-dl).
Die 50ohm.de-Website wird mit folgendem Befehl vollständig gebaut:
-56
View File
@@ -1,56 +0,0 @@
import json
import requests
class DirectusAPI:
def __init__(self, base_url, access_token):
self.base_url = base_url
self.access_token = access_token
def __get_raw(self, endpoint, params=None):
if params is None:
params = {}
for k in params.keys():
if type(params[k]) is dict:
params[k] = json.dumps(params[k])
url = self.base_url + endpoint
result = requests.get(
url, params=params, headers={"Authorization": f"Bearer {self.access_token}", "Cache-Control": "no-store"}
)
return result
def get(self, endpoint, params=None):
if params is None:
params = {}
result = self.__get_raw(endpoint, params)
try:
result_json = result.json()
if "errors" in result_json.keys():
print(result_json["errors"])
raise Exception("Error communicating with API")
data = result_json["data"]
except Exception as e:
print(f"Error: {e}")
return None
return data
def get_one(self, endpoint, params=None):
if params is None:
params = {}
results = self.get(endpoint, params)
if len(results) == 1:
return results[0]
elif len(results) == 0:
return None
else:
raise Exception("One result expected, but got more than one result")
def get_file(self, endpoint, params=None):
if params is None:
params = {}
result = self.__get_raw(endpoint, params)
data = result.content
if data is None:
raise Exception("No data from API")
return data
+69
View File
@@ -0,0 +1,69 @@
document.addEventListener("DOMContentLoaded", function() {
var macros = {
",": (context) => context.future()?.text === " " ? "{\\char`,}" : "\\char`,",
"\\qty": "{#1\\,\\mathrm{#2}}",
"\\qtyrange": "{#1\\text{--}#2\\,\\mathrm{#3}}",
"\\unit": "{\\mathrm{#1}}",
"\\num": "{#1}",
"\\squared": "{^{2}}",
"\\cubed": "{^{3}}",
"\\per": "/",
"\\percent": "\\%",
"\\tera": "\\text{T}",
"\\giga": "\\text{G}",
"\\mega": "\\text{M}",
"\\kilo": "\\text{k}",
"\\dezi": "\\text{d}",
"\\centi": "\\text{c}",
"\\milli": "\\text{m}",
"\\micro": "\\text{μ}",
"\\nano": "\\text{n}",
"\\pico": "\\text{p}",
"\\kilogram": "\\text{kg}",
"\\gram": "\\text{g}",
"\\meter": "\\text{m}",
"\\second": "\\text{s}",
"\\hour": "\\text{h}",
"\\ampere": "\\text{A}",
"\\kelvin": "\\text{K}",
"\\mol": "\\text{mol}",
"\\bel": "\\text{B}",
"\\dezibel": "\\text{dB}",
"\\dB": "\\text{dB}",
"\\dBm": "\\text{dBm}",
"\\dBu": "\\text{dBu}",
"\\dBW": "\\text{dBW}",
"\\dBi": "\\text{dBi}",
"\\dBd": "\\text{dBd}",
"\\candela": "\\text{cd}",
"\\newton": "\\text{N}",
"\\hertz": "\\text{Hz}",
"\\pascal": "\\text{Pa}",
"\\volt": "\\text{V}",
"\\watt": "\\text{W}",
"\\joule": "\\text{J}",
"\\henry": "\\text{H}",
"\\farad": "\\text{F}",
"\\coulomb": "\\text{C}",
"\\ohm": "\\Omega",
"\\Ohm": "\\Omega",
"\\weber": "\\text{Wb}",
"\\tesla": "\\text{T}",
"\\bit": "\\text{b}",
"\\baud": "\\text{Bd}",
"\\degree": "^\\circ",
"\\degreeCelsius": "^\\circ\\text{C}",
};
renderMathInElement(document.body, {
delimiters: [
{left: '$$', right: '$$', display: true}, // Note: $$ has to come before $
{left: '$', right: '$', display: false},
{left: '\\[', right: '\\]', display: true},
{left: "\\(", right: "\\)", display: false},
],
throwOnError : false,
macros: macros
});
});
-64
View File
@@ -1,65 +1,3 @@
document.addEventListener("DOMContentLoaded", function() {
var macros = {
",": (context) => context.future()?.text === " " ? "{\\char`,}" : "\\char`,",
"\\qty": "{#1\\,\\mathrm{#2}}",
"\\qtyrange": "{#1\\text{--}#2\\,\\mathrm{#3}}",
"\\unit": "{\\mathrm{#1}}",
"\\squared": "{^{2}}",
"\\cubed": "{^{3}}",
"\\per": "/",
"\\percent": "\\%",
"\\tera": "\\text{T}",
"\\giga": "\\text{G}",
"\\mega": "\\text{M}",
"\\kilo": "\\text{k}",
"\\dezi": "\\text{d}",
"\\centi": "\\text{c}",
"\\milli": "\\text{m}",
"\\micro": "\\text{μ}",
"\\nano": "\\text{n}",
"\\pico": "\\text{p}",
"\\kilogram": "\\text{kg}",
"\\meter": "\\text{m}",
"\\second": "\\text{s}",
"\\ampere": "\\text{A}",
"\\kelvin": "\\text{K}",
"\\mol": "\\text{mol}",
"\\bel": "\\text{B}",
"\\dezibel": "\\text{dB}",
"\\dB": "\\text{dB}",
"\\dBm": "\\text{dBm}",
"\\dBu": "\\text{dBu}",
"\\dBW": "\\text{dBW}",
"\\dBi": "\\text{dBi}",
"\\dBd": "\\text{dBd}",
"\\candela": "\\text{cd}",
"\\newton": "\\text{N}",
"\\hertz": "\\text{Hz}",
"\\pascal": "\\text{Pa}",
"\\volt": "\\text{V}",
"\\watt": "\\text{W}",
"\\joule": "\\text{J}",
"\\henry": "\\text{H}",
"\\farad": "\\text{F}",
"\\coulomb": "\\text{C}",
"\\ohm": "\\Omega",
"\\weber": "\\text{Wb}",
"\\tesla": "\\text{T}",
};
renderMathInElement(document.body, {
delimiters: [
{left: '$$', right: '$$', display: true}, // Note: $$ has to come before $
{left: '$', right: '$', display: false},
{left: '\\[', right: '\\]', display: true},
{left: "\\(", right: "\\)", display: false},
],
throwOnError : false,
macros: macros
});
});
function shuffleChildren(el) {
for (var i = el.children.length; i >= 0; i--) {
el.appendChild(el.children[Math.random() * i | 0]);
@@ -87,6 +25,4 @@ for (i = 0; i < els.length; i++) {
let el = els[i];
katex.render(el.innerHTML, el, { throwOnError: false });
}
*/
+1 -22
View File
@@ -1,31 +1,10 @@
import api.directus as directus
import src.build as build
import src.config as config
import src.download as download
conf = config.Config()
api = directus.DirectusAPI(conf.question_base_url, conf.question_access_token)
content_api = directus.DirectusAPI(conf.content_base_url, conf.content_access_token)
dl = download.Download(api, content_api, conf)
bd = build.Build(conf)
# Download the text files:
dl.download_edition("N")
dl.download_edition("E")
dl.download_edition("A")
dl.download_edition("NE")
dl.download_edition("EA")
dl.download_edition("NEA")
# Download additional files:
# dl.download_question_metadata() # (now constant file metadata3b.json)
dl.download_photos()
dl.download_pictures()
dl.download_includes()
dl.download_snippets()
dl.download_content()
# Build Everything:
bd = build.Build(conf)
bd.build_website()
bd.build_edition("N")
bd.build_edition("E")
+1 -5
View File
@@ -1,7 +1,3 @@
{
"directus_question_base_url" : "https://fragenkatalog.darc.de/",
"directus_question_api_key" : "",
"directus_content_base_url" : "https://redaktion.50ohm.de/",
"directus_content_api_key" : "",
"no_latex": true
"content_path" : "/home/your_path_to/50ohm-contents-dl"
}
-57
View File
@@ -1,57 +0,0 @@
Prüfungsfragen zum Erwerb von Amateurfunkprüfungsbescheinigungen
3. Auflage, März 2024
Hinweis:
========
Die Prüfungsfragen werden in maschinenlesbarer Form von der
Bundesnetzagentur für Elektrizität, Gas, Telekommunikation, Post und Eisenbahnen,
Tulpenfeld 4, 53113 Bonn
unter der Datenlizenz DL-DE->BY-2.0 bereitgestellt.
Die bereitgestellten Daten und Metadaten dürfen für die kommerzielle und nicht kommerzielle Nutzung insbesondere
vervielfältigt, ausgedruckt, präsentiert, verändert, bearbeitet sowie an Dritte übermittelt werden;
mit eigenen Daten und Daten Anderer zusammengeführt und zu selbständigen neuen Datensätzen verbunden werden;
in interne und externe Geschäftsprozesse, Produkte und Anwendungen in öffentlichen und nicht öffentlichen
elektronischen Netzwerken eingebunden werden.
Bei der Nutzung ist sicherzustellen, dass folgende Angaben als Quellenvermerk enthalten sind:
"Prüfungsfragen zum Erwerb von Amateurfunkprüfungsbescheinigungen, Bundesnetzagentur, 3. Auflage, März 2024,
(www.bundesnetzagentur.de/amateurfunk), Datenlizenz Deutschland – Namensnennung – Version 2.0
(www.govdata.de/dl-de/by-2-0)"
Veränderungen, Bearbeitungen, neue Gestaltungen oder sonstige Abwandlungen sind im Quellenvermerk mit dem Hinweis
zu versehen, dass die Daten geändert wurden.
Anleitung:
==========
Die maschinenlesbare Version der Prüfungsfragen teilt sich in zwei Teile auf:
1. Die Struktur des Katalogs und die Prüfungsfragen als JSON-Datei
2. Alle Bilder zu den Prüfungsfragen als SVG-Dateien
Das JSON-Schema besteht aus Kapiteln "sections", die jeweils einen Titel
"title" beinhalten. Eine Section kann entweder weitere Unterkapitel "sections"
beinhalten, oder eine Liste von Prüfungsfragen "questions". Eine Prüfungsfrage
beinhaltet folgende Attribute:
- "number" : Die Prüfungsnummer im Fragenkatalog
- "class" : Die Klasse (1:N, 2:E, 3:A)
- "question" : Der Fragenkopf
- "answer_a" : Die richtige Antwort A
- "answer_b" : Ein Distraktor B (falsche Antwort)
- "answer_c" : Ein Distraktor C (falsche Antwort)
- "answer_d" : Ein Distraktor D (falsche Antwort)
- "picture_question" : Das Fragenbild [Optional]
- "picture_a" : Das Antwortbild A [Optional]
- "picture_b" : Das Antwortbild B [Optional]
- "picture_c" : Das Antwortbild C [Optional]
- "picture_d" : Das Antwortbild D [Optional]
Die Prüfungsfragen enthalten LaTeX-Code für die Darstellung der Formeln. Diese
können durch verschiedene Bibliotheken z. B. durch KaTeX (https://katex.org)
angezeigt werden.
File diff suppressed because it is too large Load Diff
-15752
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -10,6 +10,7 @@ dependencies = [
"mistletoe==1.4.0",
"requests==2.32.4",
"tqdm==4.67.1",
"gitpython==3.1.46",
]
[tool.ruff]
+1 -1
View File
@@ -277,7 +277,7 @@ class FiftyOhmHtmlRenderer(HtmlRenderer):
def render_photo_helper(id, ref, text, number, alt_text):
return f"""
<figure class="photo" id="ref_{ref}" name="{ref}">
<img src="photos/{id}.jpg" alt="{alt_text}">
<img src="photos/{id}.png" alt="{alt_text}">
<figcaption>Abbildung {number}: {text}</figcaption>
</figure>
"""
+2 -2
View File
@@ -8,12 +8,12 @@ class Question(BlockToken):
def start(line):
# Search for:
# [question:123]
return re.match(r"^\s*\[question:\d+\]", line)
return re.match(r"^\s*\[question:[\w\d]+\]", line)
@classmethod
def read(cls, lines):
first_line = next(lines)
question_number = re.match(r"^\s*\[question:(\d+)\]", first_line).group(1)
question_number = re.match(r"^\s*\[question:([\w\d]+)\]", first_line).group(1)
return question_number
@classmethod
+1
View File
@@ -17,3 +17,4 @@ ruff==0.12.2
tqdm==4.67.1
ty==0.0.1a13
urllib3==2.5.0
gitpython==3.1.46
+70 -46
View File
@@ -2,9 +2,9 @@ import json
import random
import re
import shutil
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from joblib import Memory
from mistletoe import Document
from tqdm import tqdm
@@ -18,20 +18,12 @@ class Build:
def __init__(self, config: Config):
self.config = config
memory = Memory("./cache", verbose=0)
self.env = Environment(loader=FileSystemLoader("templates/"))
self.env.filters["shuffle_answers"] = self.__filter_shuffle_answers
self.questions = self.__parse_katalog()
# Decorate the method with memory.cache
self.__build_question = memory.cache(self.__build_question)
self.__build_question_slide = memory.cache(self.__build_question_slide)
self.__build_chapter = memory.cache(self.__build_chapter)
self.__build_section = memory.cache(self.__build_section)
self.__build_chapter_slidedeck = memory.cache(self.__build_chapter_slidedeck)
def __parse_katalog(self):
with self.config.p_fragenkatalog.open() as file:
with self.config.p_data_fragenkatalog.open() as file:
fragenkatalog = json.load(file)
questions = {}
@@ -48,27 +40,28 @@ class Build:
return questions
# cached
def __build_question(self, input, template_file="html/question.html"):
def __build_question(self, number, template_file="html/question.html"):
"""Combines the original question dataset from BNetzA with our internal metadata"""
question_template = self.env.get_template(template_file)
with (self.config.p_data / "metadata3b.json").open() as file:
metadata = json.load(file)
with (self.config.p_data_metadata).open() as file:
metadata_json = json.load(file)
question = None
number = None
if f"{input}" in metadata:
metadata = metadata[f"{input}"]
number = metadata["number"] # Fragennummer z.B. AB123
if number in self.questions:
question = self.questions[number]
metadata = None
if question is None:
if number in self.questions:
question = self.questions[number]
if number in metadata_json:
metadata = metadata_json[number]
if question is None or metadata is None:
tqdm.write(
f"\033[31mQuestion #{input} is missing"
+ (f" (but found number: {number})" if number is not None else "")
f"\033[31mQuestion #{number} is missing"
+ (" (Question not in question pool)" if question is None else "")
+ (" (Question not in metadata)" if metadata is None else "")
+ "\033[0m"
)
metadata = {"layout": "not-found", "picture_a": ""}
@@ -125,7 +118,6 @@ class Build:
def __build_question_slide(self, input):
return self.__build_question(input, template_file="slide/question.html")
# cached
def __build_page(self, content, course_wrapper=False, sidebar=None):
page_template = self.env.get_template("html/page.html")
return page_template.render(content=content, course_wrapper=course_wrapper, sidebar=sidebar)
@@ -147,7 +139,7 @@ class Build:
def __photo_handler(self, id):
self.config.p_build_photos.mkdir(parents=True, exist_ok=True)
file = f"{id}.jpg"
file = f"{id}.png"
try:
shutil.copyfile(self.config.p_data_photos / file, self.config.p_build_photos / file)
if (self.config.p_data_photos / f"{id}.txt").exists():
@@ -157,7 +149,6 @@ class Build:
except FileNotFoundError:
tqdm.write(f"\033[31mPhoto #{id} not found\033[0m")
# cached
def __build_chapter(self, edition, edition_name, number, chapter, next_chapter=None):
chapter_template = self.env.get_template("html/chapter.html")
next_chapter_template = self.env.get_template("html/next_chapter.html")
@@ -179,15 +170,13 @@ class Build:
file.write(result)
def __include_handler(self, include):
with (self.config.p_data / "includes.json").open() as file:
includes = json.load(file)
code = includes.get(include)
with (self.config.p_data_html / f"{include}.html").open() as file:
code = file.read()
svg_list = re.findall(r"(\d+)\.svg", code or "")
for id in svg_list:
self.__copy_picture(id)
return code
# cached
def __build_section(
self,
edition,
@@ -345,9 +334,22 @@ class Build:
edition = edition.upper()
with (self.config.p_data / f"book_{edition}.json").open() as file:
with (self.config.p_data_toc / f"{edition}.json").open() as file:
book = json.load(file)
chapters = book["chapters"]
for chapter in chapters:
for section in chapter["sections"]:
ident = section["ident"]
section["content"] = None
section["slide"] = None
with (self.config.p_data_sections / f"{ident}.md").open() as sfile:
section_content = sfile.read()
section["content"] = section_content
with (self.config.p_data_slides / f"{ident}.md").open() as sfile:
section_content = sfile.read()
section["slide"] = section_content
edition_name = book["title"]
self.__build_book_index(book)
self.__build_slide_index(book)
@@ -379,25 +381,47 @@ class Build:
)
def __parse_snippets(self):
with (self.config.p_data / "snippets.json").open() as file:
snippets = json.load(file)
snippets = {}
for md_file in Path(self.config.p_data_snippets).glob("*.md"):
with md_file.open() as file:
snippets[md_file.stem] = file.read()
with FiftyOhmHtmlRenderer(
question_renderer=self.__build_question,
picture_handler=self.__picture_handler,
photo_handler=self.__photo_handler,
include_handler=self.__include_handler,
) as renderer:
for key, value in snippets.items():
snippets[key] = renderer.render_inner(Document(value))
# Remove leading <p> and trailing </p>:
snippets[key] = snippets[key][3:-4]
with FiftyOhmHtmlRenderer(
question_renderer=self.__build_question,
picture_handler=self.__picture_handler,
photo_handler=self.__photo_handler,
include_handler=self.__include_handler,
) as renderer:
for key, value in snippets.items():
snippets[key] = renderer.render_inner(Document(value))
# Remove leading <p> and trailing </p>:
snippets[key] = snippets[key][3:-4]
return snippets
def __parse_contents(self):
with (self.config.p_data / "content.json").open() as file:
contents = json.load(file)
return contents
static = []
for static_file in Path(self.config.p_data_static).glob("*.html"):
if "sidebar" not in static_file.stem:
with static_file.open() as file:
content = file.read()
sidebar_file = self.config.p_data_static / f"{static_file.stem}_sidebar.html"
if not sidebar_file.exists():
sidebar = ""
else:
with sidebar_file.open() as file:
sidebar = file.read()
static.append(
{
"url_part": static_file.stem,
"content": content,
"sidebar": sidebar if sidebar != "" else None,
}
)
return static
def __build_index(self, snippets):
template = self.env.get_template("html/index.html")
+14 -9
View File
@@ -13,17 +13,22 @@ class Config:
else:
self.config = {}
self.question_access_token = self.get_config_value("directus_question_api_key")
self.question_base_url = self.get_config_value("directus_question_base_url", "https://fragenkatalog.darc.de/")
self.content_access_token = self.get_config_value("directus_content_api_key")
self.content_base_url = self.get_config_value("directus_content_base_url", "https://redaktion.50ohm.de/")
self.no_latex = self.get_config_value("no_latex", False)
self.p_data = Path(self.get_config_value("content_path", "/var/www/content"))
self.p_fragenkatalog = Path(self.get_config_value("path_fragenkatalog", "./data/fragenkatalog3b.json"))
self.p_data_toc = self.p_data / "toc"
self.p_data = Path("./data")
self.p_data_photos = self.p_data / "photos"
self.p_data_pictures = self.p_data / "pictures"
self.p_data_contents = self.p_data / "contents"
self.p_data_questions = self.p_data_contents / "questions"
self.p_data_html = self.p_data_contents / "html"
self.p_data_photos = self.p_data_contents / "photos"
self.p_data_pictures = self.p_data_contents / "drawings"
self.p_data_sections = self.p_data_contents / "sections"
self.p_data_slides = self.p_data_contents / "slides"
self.p_data_snippets = self.p_data_contents / "snippets"
self.p_data_static = self.p_data_contents / "static"
self.p_data_fragenkatalog = self.p_data_questions / "fragenkatalog3b.json"
self.p_data_metadata = self.p_data_questions / "metadata3b.json"
self.p_build = Path("./build")
self.p_build_photos = self.p_build / "photos"
-162
View File
@@ -1,162 +0,0 @@
import json
from tqdm import tqdm
from api.directus import DirectusAPI
from .config import Config
class Download:
def __init__(self, api: DirectusAPI, content_api: DirectusAPI, config: Config):
self.api = api
self.config = config
self.content_api = content_api
def download_edition(self, edition):
in_edition = self.api.get_one("items/edition", {"filter": {"ident": {"_eq": edition}}})
in_chapters = self.api.get(
"items/chapter",
{"filter": {"edition": {"edition_id": {"_eq": in_edition["id"]}}}},
)
out_chapters = []
for in_chapter in tqdm(in_chapters, desc="Downloading edition"):
in_sections = self.api.get(
"items/section",
{"filter": {"chapter": {"chapter_id": {"_eq": in_chapter["id"]}}}},
)
out_chapters.append(
{
"title": in_chapter["title"],
"abstract": in_chapter["abstract"],
"ident": in_chapter["ident"],
"video_url": in_chapter["video_url"],
"sections": [],
}
)
for in_section in in_sections:
out_chapters[-1]["sections"].append(
{
"title": in_section["title"],
"ident": in_section["ident"],
"status": in_section["status"],
"class": in_section["class"],
"content": in_section["content"],
"slide": in_section["slide"],
"video_url": in_section["video_url"],
"questions": [],
}
)
out_edition = {
"title": in_edition["title"],
"abstract": in_edition["abstract"],
"chapters": out_chapters,
"edition": edition,
}
with (self.config.p_data / f"book_{edition}.json").open("w", encoding="utf-8") as file:
json.dump(out_edition, file, ensure_ascii=False, indent=4)
def download_question_metadata(self):
questions = self.api.get(
"items/questions", params={"filter": {"class_3": {"_in": [1, 2, 3]}}, "sort": "position", "limit": -1}
)
result = {}
for question in tqdm(questions, desc="Downloading metadata"):
result[question["id"]] = {
"number": question["number"],
"picture_question": question["picture_question"] if question["picture_question"] is not None else "",
"picture_a": question["picture_a"] if question["picture_a"] is not None else "",
"picture_b": question["picture_b"] if question["picture_b"] is not None else "",
"picture_c": question["picture_c"] if question["picture_c"] is not None else "",
"picture_d": question["picture_d"] if question["picture_d"] is not None else "",
"layout": question["layout"] if question["layout"] is not None else "",
}
# Serializing json:
with (self.config.p_data / "metadata.json").open("w", encoding="utf-8") as f:
json.dump(result, f, indent=4)
f.close()
def download_photos(self):
self.config.p_data_photos.mkdir(parents=True, exist_ok=True)
photos = self.api.get("items/Fotos", params={"limit": -1})
for photo in tqdm(photos, desc="Downloading photos"):
# Save Photo to file
data = self.api.get_file("assets/" + photo["photo"])
with (self.config.p_data_photos / f"{photo['id']}.jpg").open("wb") as file:
file.write(data)
# Save Alt-Text to file
prefix = ""
if photo["alt_text"] is not None:
if photo["alt_text_reviewed"] is None:
prefix = "Der folgende Alt-Text wurde noch nicht geprüft: "
with (self.config.p_data_photos / f"{photo['id']}.txt").open("w") as file:
file.write(prefix + photo["alt_text"])
def download_pictures(self):
self.config.p_data_pictures.mkdir(parents=True, exist_ok=True)
pictures = self.api.get("items/pictures", {"limit": -1})
for picture in tqdm(pictures, desc="Downloading pictures"):
# Save LaTeX sources to file
with (self.config.p_data_pictures / f"{picture['id']}include.tex").open("w") as file:
file.write(picture["latex"])
# Save Alt-Text to file
prefix = ""
if picture["alt_text"] is not None:
if picture["alt_text_reviewed"] is None:
prefix = "Der folgende Alt-Text wurde noch nicht geprüft: "
with (self.config.p_data_pictures / f"{picture['id']}.txt").open("w") as file:
file.write(prefix + picture["alt_text"])
# Download SVG
data = self.api.get_file(f"assets/{picture['picture']}")
with (self.config.p_data_pictures / f"{picture['id']}.svg").open("wb") as file:
file.write(data)
def download_includes(self):
includes = self.api.get("items/include", params={"limit": -1})
result = {}
for include in tqdm(includes, desc="Downloading includes"):
result[include["ident"]] = include["content"]
with (self.config.p_data / "includes.json").open("w", encoding="utf-8") as file:
json.dump(result, file, indent=4)
def download_snippets(self):
snippets = {}
for snippet in tqdm(self.content_api.get("items/snippet"), desc="Downloading snippets"):
snippets[snippet["ident"]] = snippet["content"]
with (self.config.p_data / "snippets.json").open("w", encoding="utf-8") as file:
json.dump(snippets, file, ensure_ascii=False, indent=4)
def download_content(self):
contents = []
for content in self.content_api.get(
"items/content"
): # tqdm(self.content_api.get("items/content"), desc="Downloading content"):
contents.append(
{
"url_part": content["url_part"],
"content": content["content"],
"sidebar": content["sidebar"],
}
)
with (self.config.p_data / "content.json").open("w", encoding="utf-8") as file:
json.dump(contents, file, ensure_ascii=False, indent=4)
+3 -3
View File
@@ -1,9 +1,9 @@
<div style="float: right;">
<div class="btn-group" role="group">
{% if chapter["video_url"] %}
<a class="btn btn-secondary" href="{{ chapter["video_url"] }}" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"></path><path d="M21 3H3c-1.11 0-2 .89-2 2v12c0 1.1.89 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.11-.9-2-2-2zm0 14H3V5h18v12zm-5-6l-7 4V7z"></path></svg></a>
<a class="btn btn-secondary" href="{{ chapter["video_url"] }}" title="Video öffnen" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"></path><path d="M21 3H3c-1.11 0-2 .89-2 2v12c0 1.1.89 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.11-.9-2-2-2zm0 14H3V5h18v12zm-5-6l-7 4V7z"></path></svg></a>
{% endif %}
<a class="btn btn-secondary" href="{{ edition }}_slide_{{ chapter["ident"] }}.html"><svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M840-120v-640H120v320H40v-320q0-33 23.5-56.5T120-840h720q33 0 56.5 23.5T920-760v560q0 33-23.5 56.5T840-120ZM360-400q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47Zm0-80q33 0 56.5-23.5T440-560q0-33-23.5-56.5T360-640q-33 0-56.5 23.5T280-560q0 33 23.5 56.5T360-480ZM40-80v-112q0-34 17.5-62.5T104-298q62-31 126-46.5T360-360q66 0 130 15.5T616-298q29 15 46.5 43.5T680-192v112H40Zm80-80h480v-32q0-11-5.5-20T580-226q-54-27-109-40.5T360-280q-56 0-111 13.5T140-226q-9 5-14.5 14t-5.5 20v32Zm240-400Zm0 400Z"/></svg></a>
<a class="btn btn-secondary" href="{{ edition }}_slide_{{ chapter["ident"] }}.html" title="Präsentationsfolien öffnen"><svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M840-120v-640H120v320H40v-320q0-33 23.5-56.5T120-840h720q33 0 56.5 23.5T920-760v560q0 33-23.5 56.5T840-120ZM360-400q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47Zm0-80q33 0 56.5-23.5T440-560q0-33-23.5-56.5T360-640q-33 0-56.5 23.5T280-560q0 33 23.5 56.5T360-480ZM40-80v-112q0-34 17.5-62.5T104-298q62-31 126-46.5T360-360q66 0 130 15.5T616-298q29 15 46.5 43.5T680-192v112H40Zm80-80h480v-32q0-11-5.5-20T580-226q-54-27-109-40.5T360-280q-56 0-111 13.5T140-226q-9 5-14.5 14t-5.5 20v32Zm240-400Zm0 400Z"/></svg></a>
</div>
</div>
@@ -23,4 +23,4 @@
<span class="btn-label">{{ number }}.{{loop.index}}</span>{{ section["title"]}}
</a>
{% endfor %}
</div>
</div>
+3 -2
View File
@@ -118,10 +118,11 @@
<div class="feature-icon bg-primary bg-gradient">
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48" fill="#5f6368"><path d="m380-300 280-180-280-180v360ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm0-80h640v-480H160v480Zm0 0v-480 480Z"/></svg>
</div>
<h2>Videokurs Klasse N</h2>
<h2>Videokurse</h2>
<p>In Zusammenarbeit mit DL2YMR ist ein Videokurs für die Klasse N entstanden, der auf seinem YouTube-Kanal abrufbar ist.</p>
<div class="d-grid gap-2 d-md-block mb-5">
<a href="https://www.youtube.com/playlist?list=PLDpWnjHk5ERbcgpLPUaU0iTsD-wrmfENk" class="btn btn-primary">Zum Videokurs</a>
<a href="https://www.youtube.com/playlist?list=PLDpWnjHk5ERbcgpLPUaU0iTsD-wrmfENk" class="btn btn-primary">Zum Videokurs Klasse N</a>
<a href="https://www.youtube.com/playlist?list=PL_JZo16Mzs5XGeN5N81MwQ70dp7478VkM" class="btn btn-primary">Vorlesung Klasse N+E</a>
</div>
</div>
<div class="feature col">
+1 -2
View File
@@ -8,7 +8,6 @@
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="index.html">Home</a></li>
<li class="breadcrumb-item"><a href="lernen.html">Lernen</a></li>
<li class="breadcrumb-item active" aria-current="page">Kurse vor Ort</li>
</ol>
</nav>
@@ -50,4 +49,4 @@
</div>
</div>
</div>
</div>
+2 -1
View File
@@ -14,6 +14,7 @@
<link rel="stylesheet" href="assets/katex/katex.min.css">
<script defer src="assets/katex/katex.min.js"></script>
<script defer src="assets/katex/auto-render.min.js"></script>
<script defer src="assets/latex.js"></script>
<script defer src="assets/script.js"></script>
<style>
</style>
@@ -128,7 +129,7 @@
<div class="d-flex gap-1 align-items-center mb-1">
Mit
<svg class="text-primary" xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" viewBox="0 0 512 512"><path d="M47.6 300.4L228.3 469.1c7.5 7 17.4 10.9 27.7 10.9s20.2-3.9 27.7-10.9L464.4 300.4c30.4-28.3 47.6-68 47.6-109.5v-5.8c0-69.9-50.5-129.5-119.4-141C347 36.5 300.6 51.4 268 84L256 96 244 84c-32.6-32.6-79-47.5-124.6-39.9C50.5 55.6 0 115.2 0 185.1v5.8c0 41.5 17.2 81.2 47.6 109.5z"/></svg></span>
vom <a class=link-secondary href="/infos#contributors">50ohm-Team</a> entwickelt
vom <a class=link-secondary href="/infos.html#contributors">50ohm-Team</a> entwickelt
</div>
<div class="mb-1">Ein Angebot des Deutschen Amateur-Radio-Clubs e.&nbsp;V.</div>
<div class="d-flex gap-2">
+11 -10
View File
@@ -6,7 +6,8 @@
<section style="font-size: smaller;">
<h4>Navigation</h4>
<div id="left">
<p>Zwischen den Folien und Abschnitten kann man mittels der Pfeiltasten hin- und herspringen, dazu kann man auch
<p>Zwischen den Folien und Abschnitten lässt sich mittels der Pfeiltasten hin- und herspringen, dazu lassen sich
auch
die Pfeiltasten am Computer nutzen.</p>
<img src="assets/images/slides/navigation.png" alt="Navigationspfeile für die Präsentation" />
</div>
@@ -44,8 +45,8 @@
<div id="left">
<p>Die Präsentation ist zweidimensional aufgebaut. Dadurch sind in Spalten die einzelnen Abschnitte eines
Kapitels und in den Reihen die Folien zu den Abschnitten.</p>
<p>Tippt man ein „o“ ein, bekommt man eine Übersicht über alle Folien des jeweiligen Kapitels. Das hilft
sich zunächst einen Überblick zu verschaffen oder sich zu orientieren, wenn man das Gefühlt hat sich
<p>Tippt man ein „o“ ein, bekommt man eine Übersicht über alle Folien des Foliensatzes. Das hilft,
sich zunächst einen Überblick zu verschaffen oder sich zu orientieren, wenn man das Gefüht hat, sich
„verlaufen“ zu haben. Die Navigation erfolgt über die Pfeiltasten.</p>
</div>
<div id="right">
@@ -77,7 +78,7 @@
<li>Wenn man mit einem Projektor arbeitet, stellt man im Betriebssystem die Nutzung von 2 Monitoren ein: Die
Referentenansicht wird dann zum Beispiel auf dem Laptop angezeigt, während die Teilnehmer die
Präsentation angezeigt bekommen.</li>
<li>Bei einer Online-Präsentation, wie beispielsweise auf <a href="https://treff.darc.de/">TREFF.darc.de</a>
<li>Bei einer Online-Präsentation, wie beispielsweise auf <a href="https://treff.darc.de/">TREFF.darc.de</a>,
präsentiert man den Browser-Tab und navigiert im „Speaker View“ Fenster.</li>
<li>Die Referentenansicht bezieht sich immer auf ein Kapitel. Am Ende des Kapitels muss sie geschlossen
werden, um im neuen Kapitel eine neue Referentenansicht zu öffnen.</li>
@@ -87,26 +88,26 @@
</section>
<section style="font-size: smaller;">
<h4>Vollbild</h4>
<p>Tippt man ein „f“ ein, wird die aktuelle Folie im Vollbild angezeigt. Mit „Esc“ kann man diesen wieder
<p>Tippt man ein „f“ ein, wird die aktuelle Folie im Vollbild angezeigt. Mit „Esc“ kann man das Vollbild wieder
verlassen.</p>
<p>Das ist insbesondere für den Bildschirm mit der Präsentation für das Publikum praktisch.</li>
</section>
<section style="font-size: smaller;">
<h4>Ausblenden</h4>
<p>Tippt man ein „b“ ein, wird die Präsentation ausgeblendet.</p>
<p>Sie kann wie folgte wieder eingeblendet werden:</p>
<p>Sie kann wie folgt wieder eingeblendet werden:</p>
<ul class="list">
<li>Durch klicken in das Fenster.</li>
<li>Durch Klicken in das Fenster.</li>
<li>Durch nochmaliges Drücken von „b“.</li>
<li>Durch klicken der Schaltfläche „Resume presentation:</li>
<li>Durch Klicken der Schaltfläche „Resume presentation“.</li>
</ul>
<img src="assets/images/slides/resume.png" alt="Schaltfläche für Resume Presentation" />
</section>
<section style="font-size: smaller;">
<h4>Zoom</h4>
<p>Bei gedrückter Alt-Taste und einem Mausklick in der Präsentation wird in diesen Teil hineingezoomt. Das ist
praktisch, um Details von Schaltungen hervorzuheben. Durh einen nochmaligen Mausklick zusammen mit Alt wird
praktisch, um Details von Schaltungen hervorzuheben. Durch einen nochmaligen Mausklick zusammen mit Alt wird
wieder herausgezoomt.</p>
<p>Das Zoomen funktioniert nur im ausgewählten Fenster. Die Referentenansicht ist hier nicht mit dem
<p>Das Zoomen funktioniert nur im ausgewählten Fenster. Die Referentenansicht ist hier nicht mit der
Präsenationsansicht gesynct.</p>
</section>
+6 -1
View File
@@ -17,6 +17,11 @@
<!-- Chart plugin -->
<script src="assets/reveal.js-plugins/chart/plugin.js"></script>
<script src="assets/chartjs/4.4.1/chart.min.js"></script>
<!-- KaTeX for formulas -->
<link rel="stylesheet" href="assets/katex/katex.min.css">
<script defer src="assets/katex/katex.min.js"></script>
<script defer src="assets/katex/auto-render.min.js"></script>
<script defer src="assets/latex.js"></script>
</head>
<body>
@@ -49,7 +54,7 @@
pdfSeparateFragments: false,
// Learn about plugins: https://revealjs.com/plugins/
plugins: [ RevealZoom, RevealNotes, RevealSearch, RevealMarkdown, RevealHighlight, RevealMath.KaTeX, RevealChart ]
plugins: [ RevealZoom, RevealNotes, RevealSearch, RevealMarkdown, RevealHighlight, RevealChart ]
});
</script>
</body>
+3 -3
View File
@@ -46,7 +46,7 @@ Wenn man allerdings nur den Widerstand und die Spannung kennt und den entspreche
$ I = \dfrac{U}{R} $
[question:64]
[question:NB504]
Würde man die 10 V-Batterie in dem Beispiel durch eine 20 V-Batterie ersetzen, dann würde sich auch der Strom von 1 mA auf 2 mA erhöhen. Wenn man also die Spannung verdoppelt, dann verdoppelt sich auch der Strom. Entsprechend würde sich auch der Strom auf 0,5 mA halbieren, wenn man die Spannung auf 5 V halbieren würde.
@@ -73,8 +73,8 @@ CQ CQ hier ist DL1PZ mit einem allgemeinen Anruf, hier ist DL1PZ und hört.
---
[question:397]
[question:515]
[question:BB102]
[question:BE105]
<indepth>
*QSO* ist eine Abkürzung, die ursprünglich aus der Morsetelegrafie stammt und "ich habe direkten Kontakt zu Station X" bedeutet. Daran angelehnt, sprechen Funkamateure oftmals von einem QSO, wenn sie eine erfolgreiche Funkverbindung meinen.
+1 -1
View File
@@ -75,7 +75,7 @@ CQ CQ hier ist DL1PZ mit einem allgemeinen Anruf, hier ist DL1PZ und hört.
[photo:123:n_rst_s-meter:Display eines IC9700-Transceivers, hervorgehoben ist das S-Meter, das den aktuellen Empfangspegel anzeigt]
---
[question:2482]
[question:NB201]
---
## Slide Tabelle
+1
View File
@@ -10,6 +10,7 @@ from test.util import paragraph
def test_dash_html():
assertions = {
"Foo - Bar": "Foo &ndash; Bar",
"Foo-Bar": "Foo-Bar",
}
for assertion in assertions:
+10 -10
View File
@@ -4,11 +4,14 @@ import pytest
from jinja2 import Environment, FileSystemLoader
from mistletoe import Document
import src.config as config
from renderer.fifty_ohm_html_renderer import FiftyOhmHtmlRenderer
conf = config.Config()
def parse_katalog():
with open("data/fragenkatalog3b.json") as fragenkatalog_file:
with open(conf.p_data_fragenkatalog) as fragenkatalog_file:
fragenkatalog = json.load(fragenkatalog_file)
questions = {}
@@ -28,17 +31,15 @@ def parse_katalog():
env = Environment(loader=FileSystemLoader("templates/html"))
question_template = env.get_template("question.html")
questions = parse_katalog()
def question_stub(input):
def question_stub(number):
questions = parse_katalog()
"""Combines the original question dataset from BNetzA with our internal metadata"""
with open("data/metadata3b.json") as metadata_file:
with open(conf.p_data_metadata) as metadata_file:
metadata = json.load(metadata_file)
number = metadata[f"{input}"]["number"] # Fragennummer z.B. AB123
question = questions[number]
metadata = metadata[f"{input}"]
metadata = metadata[f"{number}"]
if "answer_a" in question:
answers = [question["answer_a"], question["answer_b"], question["answer_c"], question["answer_d"]]
@@ -71,9 +72,8 @@ def question_stub(input):
def include_stub(include):
with open("data/includes.json") as file:
includes = json.load(file)
return includes.get(include)
with open(conf.p_data_html / f"{include}.html") as file:
return file.read()
@pytest.mark.skip("Requires special files")
+8 -7
View File
@@ -5,11 +5,14 @@ import pytest
from jinja2 import Environment, FileSystemLoader
from mistletoe import Document
import src.config as config
from renderer.fifty_ohm_html_slide_renderer import FiftyOhmHtmlSlideRenderer
conf = config.Config()
def parse_katalog():
with open("data/fragenkatalog3b.json") as fragenkatalog_file:
with open(conf.p_data_fragenkatalog) as fragenkatalog_file:
fragenkatalog = json.load(fragenkatalog_file)
questions = {}
@@ -43,14 +46,13 @@ def filter_shuffle_answers(seq):
return seq
def question_stub(input):
def question_stub(number):
questions = parse_katalog()
"""Combines the original question dataset from BNetzA with our internal metadata"""
with open("data/metadata3b.json") as metadata_file:
with open(conf.p_data_metadata) as metadata_file:
metadata = json.load(metadata_file)
number = metadata[f"{input}"]["number"] # Fragennummer z.B. AB123
question = questions[number]
metadata = metadata[f"{input}"]
metadata = metadata[f"{number}"]
if "answer_a" in question:
answers = [question["answer_a"], question["answer_b"], question["answer_c"], question["answer_d"]]
@@ -86,7 +88,6 @@ env = Environment(loader=FileSystemLoader("templates/"))
env.filters["shuffle_answers"] = filter_shuffle_answers
question_template = env.get_template("slide/question.html")
slide_template = env.get_template("slide/slide.html")
questions = parse_katalog()
@pytest.mark.skip("Requires special files")