From 851a868463819db4c2ea9f7134fffe0fd9281be1 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 11 Dec 2025 21:57:08 +0100 Subject: [PATCH 1/5] refactor and add elements.py Signed-off-by: David Wallace --- src/terms/config.py | 3 ++ src/terms/elements.py | 90 +++++++++++++++++++++++++++++++++++++++++++ src/terms/main.py | 23 +++++------ src/terms/utils.py | 66 +++++-------------------------- 4 files changed, 114 insertions(+), 68 deletions(-) create mode 100644 src/terms/elements.py 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..57deac8 --- /dev/null +++ b/src/terms/elements.py @@ -0,0 +1,90 @@ +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 + + +@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 + + def to_serializable(self) -> dict[str, Any]: + base = {'uri': self.uri, 'type': self.type, 'module': self.module, 'url': self.url} + 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 = {} + for file_path in files: + tree = et.parse(file_path) + root_node = tree.getroot() + + 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]) + + 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) diff --git a/src/terms/main.py b/src/terms/main.py index d2f365a..4e82d15 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 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,9 @@ def elements(): @app.command() def element(): - for element in gather_elements(catalog_path): - template = get_template('element.html') + xml_files = gather_files(catalog_path) + for element in gather_elements(xml_files): + 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/utils.py b/src/terms/utils.py index 9f031a0..41b9483 100644 --- a/src/terms/utils.py +++ b/src/terms/utils.py @@ -1,81 +1,33 @@ import asyncio import os import shutil -import xml.etree.ElementTree as et -from importlib.resources import files +from importlib.resources import files as importlib_files from pathlib import Path import httpx from jinja2 import Environment, FileSystemLoader, PackageLoader, select_autoescape -from .config import assets, module_map, ns_dc +from .config import assets -def gather_elements(catalog_path: Path) -> list: - elements = [] - urls = {} - for file_path in catalog_path.rglob("*"): - if file_path.suffix == '.xml': - tree = et.parse(file_path) - root_node = tree.getroot() +def gather_files(catalog_path: Path) -> list[Path]: + return sorted( + [file_path for file_path in catalog_path.rglob("*.xml") if file_path.is_file()] + ) - for element_node in root_node: - uri = element_node.attrib.get(f"{ns_dc}uri") - element = { - 'uri': uri, - 'type': element_node.tag, - 'module': module_map[element_node.tag], - } - for child_node in element_node: - if child_node.tag.startswith(ns_dc): - key = child_node.tag[len(ns_dc):] - elif 'lang' in child_node.attrib: - key = f"{child_node.tag}_{child_node.attrib['lang']}" - else: - key = child_node.tag - - if len(child_node) > 0: - element[key] = [ - { - '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[key] = { - 'uri': child_node.attrib.get(f"{ns_dc}uri") - } - else: - element[key] = child_node.text - - urls[uri] = element['url'] = f"{element['module']}/{element.get('uri_path', element.get('path', ''))}" - elements.append(element) - - # loop over subvalues again and add urls - for element in elements: - for key in element.keys(): - if isinstance(element[key], list): - for item in element[key]: - item['url'] = urls[item['uri']] - if isinstance(element[key], dict): - element[key]['url'] = urls[item['uri']] - - return sorted(elements, key=lambda x: x["uri"]) - - -def get_template(template_name: str): +def get_template_env(): templates_path = os.getenv('TEMPLATE_PATH') if templates_path is None: env = Environment(loader=PackageLoader("terms", "templates"), autoescape=select_autoescape()) else: env = Environment(loader=FileSystemLoader(templates_path)) - return env.get_template(template_name) + return env def copy_static(static_path: Path): - shutil.copytree(files('terms') / 'static', static_path, dirs_exist_ok=True) + shutil.copytree(str(importlib_files('terms') / 'static'), static_path, dirs_exist_ok=True) async def download_assets(assets_path: Path) -> None: From a729f4a5547bbe4ef6379a8366c7b222d82c7747 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 11 Dec 2025 22:01:44 +0100 Subject: [PATCH 2/5] Add nested view for catalogs Signed-off-by: David Wallace --- src/terms/static/js/app.js | 145 +++++++++++++++++++++++++++++++ src/terms/templates/base.html | 2 +- src/terms/templates/element.html | 42 ++++++++- 3 files changed, 187 insertions(+), 2 deletions(-) diff --git a/src/terms/static/js/app.js b/src/terms/static/js/app.js index f61176c..af97a95 100644 --- a/src/terms/static/js/app.js +++ b/src/terms/static/js/app.js @@ -133,3 +133,148 @@ document.addEventListener("DOMContentLoaded", () => { filterInput.addEventListener("input", debouncedInput) }) + + +document.addEventListener("DOMContentLoaded", () => { + const flatView = document.getElementById("catalog-flat-view"); + const treeView = document.getElementById("catalog-tree-view"); + const btnFlat = document.getElementById("btn-view-flat"); + const btnTree = document.getElementById("btn-view-tree"); + + // Only run on catalog element pages + if (!flatView || !treeView || !btnFlat || !btnTree) return; + + const baseUrl = document.body.dataset.baseUrl || "/"; + const rootUri = btnTree.dataset.rootUri; + let treeLoaded = false; + let elementByUri = null; + + function setActiveButton(activeBtn) { + [btnFlat, btnTree].forEach((btn) => btn.classList.remove("active")); + activeBtn.classList.add("active"); + } + + function showFlat() { + flatView.classList.remove("d-none"); + treeView.classList.add("d-none"); + setActiveButton(btnFlat); + } + + function showTree() { + flatView.classList.add("d-none"); + treeView.classList.remove("d-none"); + setActiveButton(btnTree); + if (!treeLoaded) { + loadTree(); + } + } + + // Keys on the element JSON that represent structural children + const CHILD_KEYS = ["sections", "pages", "questionsets", "questions"]; + + async function loadTree() { + try { + const indexUrl = (baseUrl || "/") + "index.json"; + const res = await fetch(indexUrl); + if (!res.ok) { + throw new Error("Failed to load index.json"); + } + + const allElements = await res.json(); + elementByUri = new Map(allElements.map((el) => [el.uri, el])); + + const treeRoot = buildTreeForUri(rootUri, elementByUri); + treeView.innerHTML = ""; + treeView.appendChild(renderTreeNode(treeRoot, baseUrl)); + treeLoaded = true; + } catch (err) { + console.error(err); + treeView.innerHTML = + '
Could not load catalog tree.
'; + } + } + + function buildTreeForUri(uri, byUri, seen = new Set()) { + const element = byUri.get(uri); + if (!element || seen.has(uri)) return null; + + seen.add(uri); + + const node = { + uri: element.uri, + type: element.type || element.model || "element", + url: element.url, + children: [], + }; + + CHILD_KEYS.forEach((key) => { + const refs = element[key]; + if (Array.isArray(refs)) { + refs.forEach((ref) => { + if (ref && ref.uri) { + const childNode = buildTreeForUri(ref.uri, byUri, seen); + if (childNode) { + node.children.push(childNode); + } + } + }); + } + }); + + return node; + } + + function renderTreeNode(node, baseUrl) { + if (!node) { + return document.createTextNode(""); + } + + const wrapper = document.createElement("div"); + wrapper.classList.add("mb-1"); + + const header = document.createElement("div"); + header.classList.add("small"); + + const label = document.createElement("strong"); + label.textContent = (node.type || "element") + ": "; + header.appendChild(label); + + if (node.url) { + const link = document.createElement("a"); + link.href = baseUrl + node.url.replace(/^\//, ""); + link.textContent = node.uri; + header.appendChild(link); + } else { + const span = document.createElement("span"); + span.textContent = node.uri; + header.appendChild(span); + } + + wrapper.appendChild(header); + + if (node.children && node.children.length > 0) { + const ul = document.createElement("ul"); + ul.classList.add("list-unstyled", "ms-3"); + + node.children.forEach((child) => { + const li = document.createElement("li"); + li.appendChild(renderTreeNode(child, baseUrl)); + ul.appendChild(li); + }); + + wrapper.appendChild(ul); + } + + return wrapper; + } + + btnFlat.addEventListener("click", (event) => { + event.preventDefault(); + showFlat(); + }); + + btnTree.addEventListener("click", (event) => { + event.preventDefault(); + showTree(); + }); +}); diff --git a/src/terms/templates/base.html b/src/terms/templates/base.html index 9351c4c..ad446a7 100644 --- a/src/terms/templates/base.html +++ b/src/terms/templates/base.html @@ -15,7 +15,7 @@ - + {% include "_nav.html" %}
diff --git a/src/terms/templates/element.html b/src/terms/templates/element.html index 70c9b48..0bae289 100644 --- a/src/terms/templates/element.html +++ b/src/terms/templates/element.html @@ -2,6 +2,46 @@ {% block content %} -{% include "_element.html" %} +{# Optional header + toggle, only for catalogs #} +{% if element.type == "catalog" %} +
+

+ Catalog + {{ element.uri }} +

+ +
+ + +
+
+{% endif %} + +{# Flat view = your existing card rendering #} +
+ {% include "_element.html" %} +
+ +{# Tree view placeholder, only needed for catalog pages #} +{% if element.type == "catalog" %} +
+
+ Tree view will appear here. +
+
+{% endif %} {% endblock %} From 1e780abcf41d37796358ea1397277b0d436fc5a1 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 12 Dec 2025 10:01:52 +0100 Subject: [PATCH 3/5] Make tree in python and to element Signed-off-by: David Wallace --- src/terms/elements.py | 33 +++++++ src/terms/main.py | 12 ++- src/terms/static/js/app.js | 145 ------------------------------ src/terms/templates/_element.html | 24 ++++- src/terms/templates/element.html | 45 ++-------- 5 files changed, 71 insertions(+), 188 deletions(-) diff --git a/src/terms/elements.py b/src/terms/elements.py index 57deac8..b0e989b 100644 --- a/src/terms/elements.py +++ b/src/terms/elements.py @@ -88,3 +88,36 @@ def _attach_urls(values: Iterable[Any], urls: dict[str, str]) -> None: 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 4e82d15..e7449fc 100644 --- a/src/terms/main.py +++ b/src/terms/main.py @@ -8,7 +8,7 @@ import typer from .config import base_url, catalog_path, public_path -from .elements import gather_elements +from .elements import build_element_tree, gather_elements from .utils import copy_static, download_assets, gather_files, get_template_env app = typer.Typer() @@ -56,7 +56,15 @@ def elements(): @app.command() def element(): xml_files = gather_files(catalog_path) - for element in gather_elements(xml_files): + 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) diff --git a/src/terms/static/js/app.js b/src/terms/static/js/app.js index af97a95..f61176c 100644 --- a/src/terms/static/js/app.js +++ b/src/terms/static/js/app.js @@ -133,148 +133,3 @@ document.addEventListener("DOMContentLoaded", () => { filterInput.addEventListener("input", debouncedInput) }) - - -document.addEventListener("DOMContentLoaded", () => { - const flatView = document.getElementById("catalog-flat-view"); - const treeView = document.getElementById("catalog-tree-view"); - const btnFlat = document.getElementById("btn-view-flat"); - const btnTree = document.getElementById("btn-view-tree"); - - // Only run on catalog element pages - if (!flatView || !treeView || !btnFlat || !btnTree) return; - - const baseUrl = document.body.dataset.baseUrl || "/"; - const rootUri = btnTree.dataset.rootUri; - let treeLoaded = false; - let elementByUri = null; - - function setActiveButton(activeBtn) { - [btnFlat, btnTree].forEach((btn) => btn.classList.remove("active")); - activeBtn.classList.add("active"); - } - - function showFlat() { - flatView.classList.remove("d-none"); - treeView.classList.add("d-none"); - setActiveButton(btnFlat); - } - - function showTree() { - flatView.classList.add("d-none"); - treeView.classList.remove("d-none"); - setActiveButton(btnTree); - if (!treeLoaded) { - loadTree(); - } - } - - // Keys on the element JSON that represent structural children - const CHILD_KEYS = ["sections", "pages", "questionsets", "questions"]; - - async function loadTree() { - try { - const indexUrl = (baseUrl || "/") + "index.json"; - const res = await fetch(indexUrl); - if (!res.ok) { - throw new Error("Failed to load index.json"); - } - - const allElements = await res.json(); - elementByUri = new Map(allElements.map((el) => [el.uri, el])); - - const treeRoot = buildTreeForUri(rootUri, elementByUri); - treeView.innerHTML = ""; - treeView.appendChild(renderTreeNode(treeRoot, baseUrl)); - treeLoaded = true; - } catch (err) { - console.error(err); - treeView.innerHTML = - '
Could not load catalog tree.
'; - } - } - - function buildTreeForUri(uri, byUri, seen = new Set()) { - const element = byUri.get(uri); - if (!element || seen.has(uri)) return null; - - seen.add(uri); - - const node = { - uri: element.uri, - type: element.type || element.model || "element", - url: element.url, - children: [], - }; - - CHILD_KEYS.forEach((key) => { - const refs = element[key]; - if (Array.isArray(refs)) { - refs.forEach((ref) => { - if (ref && ref.uri) { - const childNode = buildTreeForUri(ref.uri, byUri, seen); - if (childNode) { - node.children.push(childNode); - } - } - }); - } - }); - - return node; - } - - function renderTreeNode(node, baseUrl) { - if (!node) { - return document.createTextNode(""); - } - - const wrapper = document.createElement("div"); - wrapper.classList.add("mb-1"); - - const header = document.createElement("div"); - header.classList.add("small"); - - const label = document.createElement("strong"); - label.textContent = (node.type || "element") + ": "; - header.appendChild(label); - - if (node.url) { - const link = document.createElement("a"); - link.href = baseUrl + node.url.replace(/^\//, ""); - link.textContent = node.uri; - header.appendChild(link); - } else { - const span = document.createElement("span"); - span.textContent = node.uri; - header.appendChild(span); - } - - wrapper.appendChild(header); - - if (node.children && node.children.length > 0) { - const ul = document.createElement("ul"); - ul.classList.add("list-unstyled", "ms-3"); - - node.children.forEach((child) => { - const li = document.createElement("li"); - li.appendChild(renderTreeNode(child, baseUrl)); - ul.appendChild(li); - }); - - wrapper.appendChild(ul); - } - - return wrapper; - } - - btnFlat.addEventListener("click", (event) => { - event.preventDefault(); - showFlat(); - }); - - btnTree.addEventListener("click", (event) => { - event.preventDefault(); - showTree(); - }); -}); diff --git a/src/terms/templates/_element.html b/src/terms/templates/_element.html index a5ec06c..3f4f9f2 100644 --- a/src/terms/templates/_element.html +++ b/src/terms/templates/_element.html @@ -1,3 +1,21 @@ +{% macro render_tree(node) %} +
+ {{ node.type | capitalize }}: + {% if node.url %} + {{ node.uri }} + {% else %} + {{ node.uri }} + {% endif %} +
+{% if node.children %} +
    + {% for child in node.children %} +
  • {{ render_tree(child) }}
  • + {% endfor %} +
+{% endif %} +{% endmacro %} +
@@ -18,7 +36,11 @@ {{ key }}
- {% if value is string %} + {% if key == "tree" %} +
+ {{ render_tree(value) }} +
+ {% elif value is string %} {{ value | striptags }} {% elif value is mapping %} {{ value.uri }} diff --git a/src/terms/templates/element.html b/src/terms/templates/element.html index 0bae289..b767fe9 100644 --- a/src/terms/templates/element.html +++ b/src/terms/templates/element.html @@ -2,46 +2,11 @@ {% block content %} -{# Optional header + toggle, only for catalogs #} -{% if element.type == "catalog" %} -
-

- Catalog - {{ element.uri }} -

+

+ {{ element.type.capitalize() }} + {{ element.uri }} +

-
- - -
-
-{% endif %} - -{# Flat view = your existing card rendering #} -
- {% include "_element.html" %} -
- -{# Tree view placeholder, only needed for catalog pages #} -{% if element.type == "catalog" %} -
-
- Tree view will appear here. -
-
-{% endif %} +{% include "_element.html" %} {% endblock %} From 1a23ed83fbee903897c0a780b012e4df2d57942d Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 12 Dec 2025 10:38:42 +0100 Subject: [PATCH 4/5] Add git info and language toggle Signed-off-by: David Wallace --- src/terms/elements.py | 17 ++++++- src/terms/static/css/style.css | 11 ++++- src/terms/static/js/app.js | 61 ++++++++++++++++++++++++- src/terms/templates/_element.html | 40 +++++++++++++++-- src/terms/templates/_nav.html | 6 +++ src/terms/templates/base.html | 14 ++++++ src/terms/utils.py | 75 +++++++++++++++++++++++++++++++ 7 files changed, 216 insertions(+), 8 deletions(-) diff --git a/src/terms/elements.py b/src/terms/elements.py index b0e989b..d23ff81 100644 --- a/src/terms/elements.py +++ b/src/terms/elements.py @@ -5,6 +5,7 @@ from xml.etree import ElementTree as et from terms.config import module_map, ns_dc +from terms.utils import get_git_info @dataclass @@ -23,9 +24,12 @@ class Element: 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 @@ -40,13 +44,24 @@ def _serialize_value(self, value: Any) -> Any: 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]) + 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) 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 3f4f9f2..aed1b20 100644 --- a/src/terms/templates/_element.html +++ b/src/terms/templates/_element.html @@ -30,16 +30,48 @@
    {% for key, value in element.items() %} {% if key not in ["type", "module", "uri", "uri_prefix", "uri_path", "template", "url"] and value %} -
  • + {% set lang = None %} + {% set base_key = key %} + {% if "_" in key %} + {% set maybe_key, maybe_lang = key.rsplit("_", 1) %} + {% if maybe_lang|length <= 5 and maybe_lang.isalpha() %} + {% set lang = maybe_lang %} + {% set base_key = maybe_key %} + {% endif %} + {% endif %} +
  • -
    - {{ key }} +
    + {{ base_key }} + {% if lang %}{{ lang }}{% endif %}
    -
    +
    {% if key == "tree" %}
    {{ render_tree(value) }}
    + {% elif key == "git" and value is mapping %} +
    +
    Commit
    +
    + {% if value.commit_url %} + {{ value.commit }} + {% else %} + {{ value.commit }} + {% endif %} + {% if value.message %}
    {{ value.message }}
    {% endif %} +
    +
    Author
    +
    {{ value.author }}{% if value.date %}
    {{ value.date }}
    {% endif %}
    +
    Source
    +
    + {% if value.file_url %} + {{ value.path }} + {% else %} + {{ value.path }} + {% endif %} +
    +
    {% elif value is string %} {{ value | striptags }} {% elif value is mapping %} diff --git a/src/terms/templates/_nav.html b/src/terms/templates/_nav.html index 6e703ec..5f460af 100644 --- a/src/terms/templates/_nav.html +++ b/src/terms/templates/_nav.html @@ -31,6 +31,12 @@
    +
    + + +
    diff --git a/src/terms/templates/base.html b/src/terms/templates/base.html index ad446a7..ce538e3 100644 --- a/src/terms/templates/base.html +++ b/src/terms/templates/base.html @@ -24,5 +24,19 @@ {% endblock %}
+ + + diff --git a/src/terms/utils.py b/src/terms/utils.py index 41b9483..d51fedd 100644 --- a/src/terms/utils.py +++ b/src/terms/utils.py @@ -1,6 +1,7 @@ import asyncio import os import shutil +import subprocess from importlib.resources import files as importlib_files from pathlib import Path @@ -47,3 +48,77 @@ async def download_asset(client: httpx.AsyncClient, asset_name: str, asset_url: asset_path = assets_path / asset_name asset_path.parent.mkdir(exist_ok=True, parents=True) asset_path.write_bytes(response.content) + +def get_git_info(file_path: Path) -> dict[str, str] | None: + """Return metadata for the last commit touching ``file_path``. + + The information is collected directly from Git (if available) so it works + for local paths as well as checked out submodules. + """ + + repo_root = _run_git_command(["rev-parse", "--show-toplevel"], file_path.parent) + if not repo_root: + return None + + relative_path = file_path.resolve().relative_to(Path(repo_root)) + + commit_raw = _run_git_command( + ["log", "-1", "--date=iso", "--format=%H%n%an%n%ad%n%s", "--", str(relative_path)], + repo_root, + ) + if not commit_raw: + return None + + try: + commit_hash, author, date, message = commit_raw.splitlines() + except ValueError: + return None + + remote_url = _normalize_remote_url(_run_git_command(["config", "--get", "remote.origin.url"], repo_root)) + branch = _run_git_command(["rev-parse", "--abbrev-ref", "HEAD"], repo_root) or "main" + + file_url = commit_url = None + if remote_url: + file_url = f"{remote_url}/blob/{branch}/{relative_path.as_posix()}" + commit_url = f"{remote_url}/commit/{commit_hash}" + + return { + "path": str(relative_path), + "commit": commit_hash, + "author": author, + "date": date, + "message": message, + "file_url": file_url, + "commit_url": commit_url, + "repository": remote_url, + "branch": branch, + } + + +def _run_git_command(args: list[str], cwd: Path | str) -> str | None: + try: + result = subprocess.run( + ["git", "-C", str(cwd), *args], + check=True, + capture_output=True, + text=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return None + + return result.stdout.strip() + + +def _normalize_remote_url(remote_url: str | None) -> str | None: + if not remote_url: + return None + + remote = remote_url + if remote.startswith("git@"): + remote = remote.replace(":", "/", 1) + remote = remote.replace("git@", "https://", 1) + + if remote.endswith(".git"): + remote = remote[:-4] + + return remote From 40d9c71da4bf9ce47e30316811f10d4dba3b874b Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 12 Dec 2025 10:53:27 +0100 Subject: [PATCH 5/5] Fix lang fields in element Signed-off-by: David Wallace --- src/terms/templates/_element.html | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/terms/templates/_element.html b/src/terms/templates/_element.html index aed1b20..2db8071 100644 --- a/src/terms/templates/_element.html +++ b/src/terms/templates/_element.html @@ -29,14 +29,18 @@
    {% for key, value in element.items() %} -{% if key not in ["type", "module", "uri", "uri_prefix", "uri_path", "template", "url"] and value %} + {% if key not in ["type", "module", "uri", "uri_prefix", "uri_path", "template", "url"] and value %} {% set lang = None %} {% set base_key = key %} {% if "_" in key %} - {% set maybe_key, maybe_lang = key.rsplit("_", 1) %} - {% if maybe_lang|length <= 5 and maybe_lang.isalpha() %} - {% set lang = maybe_lang %} - {% set base_key = maybe_key %} + {% set parts = key.rsplit("_", 1) %} + {% set is_language = parts|length == 2 + and parts[1]|length == 2 + and parts[0] in ["title", "short_title", "text", "help", "label", "verb"] %} + + {% if is_language %} + {% set base_key = parts[0] %} + {% set lang = parts[1] %} {% endif %} {% endif %}