diff --git a/src/terms/config.py b/src/terms/config.py index f86eb50..01b3c21 100644 --- a/src/terms/config.py +++ b/src/terms/config.py @@ -6,6 +6,9 @@ load_dotenv('.env') base_url = os.getenv('BASE_URL', '/') + +if os.getenv('CATALOG_PATH') is None: + raise RuntimeError("CATALOG_PATH environment variable must be set") catalog_path = Path(os.getenv('CATALOG_PATH')) public_path = Path(os.getenv('PUBLIC_PATH', 'public')) diff --git a/src/terms/elements.py b/src/terms/elements.py new file mode 100644 index 0000000..d23ff81 --- /dev/null +++ b/src/terms/elements.py @@ -0,0 +1,138 @@ +from collections.abc import Iterable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from xml.etree import ElementTree as et + +from terms.config import module_map, ns_dc +from terms.utils import get_git_info + + +@dataclass +class Reference: + uri: str + url: str | None = None + + def to_serializable(self) -> dict[str, str]: + return {'uri': self.uri, 'url': self.url} + + +@dataclass +class Element: + uri: str + type: str + module: str + attributes: dict[str, Any] = field(default_factory=dict) + url: str | None = None + git: dict[str, Any] | None = None + + def to_serializable(self) -> dict[str, Any]: + base = {'uri': self.uri, 'type': self.type, 'module': self.module, 'url': self.url} + if self.git: + base['git'] = self.git + base.update({key: self._serialize_value(value) for key, value in self.attributes.items()}) + return base + + def _serialize_value(self, value: Any) -> Any: + if isinstance(value, Reference): + return value.to_serializable() + if isinstance(value, list): + return [self._serialize_value(item) for item in value] + return value + + +def gather_elements(files: list[Path]) -> list: + elements = [] + urls = {} + git_infos: dict[Path, dict[str, Any] | None] = {} + for file_path in files: + tree = et.parse(file_path) + root_node = tree.getroot() + + git_info = git_infos.get(file_path) + if file_path not in git_infos: + git_info = get_git_info(file_path) + git_infos[file_path] = git_info + + for element_node in root_node: + uri = element_node.attrib.get(f"{ns_dc}uri") + element = Element( + uri=uri, + type=element_node.tag, + module=module_map[element_node.tag], + git=git_info, + ) + + for child_node in element_node: + key = _extract_key(child_node) + + if len(child_node) > 0: + element.attributes[key] = [ + Reference(uri=grand_child_node.attrib.get(f"{ns_dc}uri")) + for grand_child_node in child_node + ] + elif child_node.attrib.get(f"{ns_dc}uri"): + element.attributes[key] = Reference(uri=child_node.attrib.get(f"{ns_dc}uri")) + else: + element.attributes[key] = child_node.text + + element.url = f"{element.module}/{element.attributes.get('uri_path', element.attributes.get('path', ''))}" + urls[element.uri] = element.url + elements.append(element) + + # loop over subvalues again and add urls + for element in elements: + _attach_urls(element.attributes.values(), urls) + + return sorted([element.to_serializable() for element in elements], key=lambda x: x["uri"]) + + +def _extract_key(child_node: et.Element) -> str: + if child_node.tag.startswith(ns_dc): + return child_node.tag[len(ns_dc):] + if 'lang' in child_node.attrib: + return f"{child_node.tag}_{child_node.attrib['lang']}" + return child_node.tag + + +def _attach_urls(values: Iterable[Any], urls: dict[str, str]) -> None: + for value in values: + if isinstance(value, list): + for item in value: + if isinstance(item, Reference): + item.url = urls.get(item.uri) + elif isinstance(value, Reference): + value.url = urls.get(value.uri) + + +def build_element_tree( + root_uri: str, elements_by_uri: dict[str, dict[str, Any]], seen: set[str] | None = None +) -> dict[str, Any] | None: + """Build a nested tree representation for a catalog starting at ``root_uri``.""" + + seen = set() if seen is None else seen + + element = elements_by_uri.get(root_uri) + if not element or root_uri in seen: + return None + + seen.add(root_uri) + + tree = { + "uri": element["uri"], + "type": element.get("type") or element.get("module") or "element", + "url": element.get("url"), + "children": [], + } + + for key in ["sections", "pages", "questionsets", "questions"]: + for child_ref in element.get(key, []) or []: + child_uri = child_ref.get("uri") + if not child_uri: + continue + + child_tree = build_element_tree(child_uri, elements_by_uri, seen) + if child_tree: + tree["children"].append(child_tree) + + return tree diff --git a/src/terms/main.py b/src/terms/main.py index d2f365a..e7449fc 100644 --- a/src/terms/main.py +++ b/src/terms/main.py @@ -6,14 +6,13 @@ from http.server import HTTPServer, SimpleHTTPRequestHandler import typer -from dotenv import load_dotenv from .config import base_url, catalog_path, public_path -from .utils import copy_static, download_assets, gather_elements, get_template - -load_dotenv('.env') +from .elements import build_element_tree, gather_elements +from .utils import copy_static, download_assets, gather_files, get_template_env app = typer.Typer() +template_env = get_template_env() @app.command() def build(): @@ -26,9 +25,9 @@ def build(): @app.command() def index(): - template = get_template('index.html') - - elements = gather_elements(catalog_path) + template = template_env.get_template('index.html') + xml_files = gather_files(catalog_path) + elements = gather_elements(xml_files) html = template.render(base_url=base_url, elements=elements) html_path = public_path / 'index.html' @@ -40,11 +39,12 @@ def index(): @app.command() def elements(): module_elements = defaultdict(list) - for element in gather_elements(catalog_path): + xml_files = gather_files(catalog_path) + for element in gather_elements(xml_files): module_elements[element['module']].append(element) for module, elements in module_elements.items(): - template = get_template('elements.html') + template = template_env.get_template('elements.html') html = template.render(base_url=base_url, module=module, elements=elements) html_path = public_path / module @@ -55,8 +55,17 @@ def elements(): @app.command() def element(): - for element in gather_elements(catalog_path): - template = get_template('element.html') + xml_files = gather_files(catalog_path) + elements = gather_elements(xml_files) + elements_by_uri = {element["uri"]: element for element in elements} + + for element in elements: + if element.get("type") == "catalog": + tree = build_element_tree(element["uri"], elements_by_uri) + if tree: + element["tree"] = tree + + template = template_env.get_template('element.html') html = template.render(base_url=base_url, element=element) html_path = public_path / element['module'] / element.get('uri_path', element.get('path', '')) diff --git a/src/terms/static/css/style.css b/src/terms/static/css/style.css index 2869837..bfbe3f8 100644 --- a/src/terms/static/css/style.css +++ b/src/terms/static/css/style.css @@ -8,8 +8,7 @@ --rdmo-color-link-hover: #2a5d90; --rdmo-color-light-gray: #f8f9fa; --rdmo-color-dark-gray: #999; - --rdmo-color-header: #fff; - --rdmo-color-header-bg: #000; + --rdmo-color-header: #fff; --rdmo-color-header-bg: #000; --rdmo-color-header-height: 300px; --rdmo-color-footer: #999; --rdmo-color-footer-bg: #001; @@ -45,6 +44,10 @@ nav.navbar .filter-form { } } +nav.navbar #language-toggle { + min-width: 8rem; +} + .navbar-toggler-icon { --bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); } @@ -53,3 +56,7 @@ main { margin-top: 6rem; margin-bottom: 6rem; } + +.footer { + margin-top: auto; +} diff --git a/src/terms/static/js/app.js b/src/terms/static/js/app.js index f61176c..c7d22c3 100644 --- a/src/terms/static/js/app.js +++ b/src/terms/static/js/app.js @@ -2,6 +2,55 @@ document.addEventListener("DOMContentLoaded", () => { const filterInput = document.getElementById("filter") if (!filterInput) return + const languageToggle = document.getElementById("language-toggle") + const languageItems = Array.prototype.slice.call( + document.querySelectorAll("[data-lang]") + ) + + const availableLanguages = Array.from( + new Set( + languageItems + .map((item) => item.getAttribute("data-lang")) + .filter((lang) => lang) + ) + ).sort() + + const addLanguageOptions = () => { + if (!languageToggle || availableLanguages.length === 0) return + + const current = languageToggle.value + availableLanguages.forEach((lang) => { + if (!languageToggle.querySelector(`option[value="${lang}"]`)) { + const option = document.createElement("option") + option.value = lang + option.textContent = lang.toUpperCase() + languageToggle.appendChild(option) + } + }) + + const preferred = localStorage.getItem("rdmo-terms-language") || (availableLanguages.includes("en") ? "en" : current) + if (preferred && languageToggle.querySelector(`option[value="${preferred}"]`)) { + languageToggle.value = preferred + } + } + + const applyLanguage = (lang) => { + if (!languageToggle) return + + localStorage.setItem("rdmo-terms-language", lang) + + languageItems.forEach((item) => { + const itemLang = item.getAttribute("data-lang") + if (!itemLang) return + + if (lang === "all" || itemLang === lang) { + item.classList.remove("d-none") + } else { + item.classList.add("d-none") + } + }) + } + // Collect all result cards on this page const elements = Array.prototype.slice.call( document.querySelectorAll(".element[data-uri]") @@ -103,7 +152,7 @@ document.addEventListener("DOMContentLoaded", () => { return } - const options = { + let options = { prefix: true, fuzzy: 0.2 } @@ -132,4 +181,14 @@ document.addEventListener("DOMContentLoaded", () => { const debouncedInput = _.debounce(handleInput, 300) filterInput.addEventListener("input", debouncedInput) + + addLanguageOptions() + applyLanguage(languageToggle ? languageToggle.value : "all") + + if (languageToggle) { + languageToggle.addEventListener("change", (event) => { + applyLanguage(event.target.value) + }) + } + }) diff --git a/src/terms/templates/_element.html b/src/terms/templates/_element.html index a5ec06c..2db8071 100644 --- a/src/terms/templates/_element.html +++ b/src/terms/templates/_element.html @@ -1,3 +1,21 @@ +{% macro render_tree(node) %} +