Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbe434de02 | ||
|
|
1ae23b4e9d | ||
|
|
9055f3437f | ||
|
|
92dc91885b | ||
|
|
12fe709a34 | ||
|
|
d6560b343a |
@@ -82,7 +82,8 @@ module, or every module checkout in a workspace, with:
|
||||
govoplan-docs-export-public \
|
||||
--workspace-root /mnt/DATA/git \
|
||||
--output public/docs/v1/catalog.json \
|
||||
--coverage-output docs/DOCUMENTATION_COVERAGE.md
|
||||
--coverage-output docs/DOCUMENTATION_COVERAGE.md \
|
||||
--coverage-baseline docs/DOCUMENTATION_COVERAGE_BASELINE.json
|
||||
```
|
||||
|
||||
Use `--check` in publication CI to reject a stale checked-in catalog. Dynamic
|
||||
@@ -90,6 +91,15 @@ Use `--check` in publication CI to reject a stale checked-in catalog. Dynamic
|
||||
on permissions, policy, configuration, and live provider state; the export
|
||||
records which modules have such additional documentation.
|
||||
|
||||
Static topics localize title, summary, and body through `translations`.
|
||||
Rendered metadata such as steps, fields, limitations, consequences, and
|
||||
verification uses Core's opt-in `structured_translation_version="1"` plus
|
||||
`structured_translations` contract. The registry validates exact shape before
|
||||
Docs overlays the requested locale. The public catalog reports structured
|
||||
adoption separately. A reviewed coverage-baseline file sets monotonic minima
|
||||
and maxima so publication CI also rejects localization or coverage regressions
|
||||
after generated output is refreshed.
|
||||
|
||||
Pressing `F1` resolves the focused field or action first, then its containing
|
||||
dialog or section, current page, and owning module. The shell sends the focused
|
||||
context together with `fallback_context` and `module`; Docs selects the first
|
||||
|
||||
@@ -3,6 +3,49 @@
|
||||
The docs module renders documentation for the current GovOPlaN instance instead
|
||||
of showing a static product manual.
|
||||
|
||||
## Finding and grouping help
|
||||
|
||||
The Help Center searches authorized titles, summaries, body text, area names,
|
||||
and public topic tags. The shared multi-selection list filter uses OR between
|
||||
selected tags; Select all removes the restriction, Clear all yields no results.
|
||||
Search results deduplicate semantic topic IDs. Selecting a result clears the
|
||||
filters and opens its topic.
|
||||
|
||||
Topics by area groups each topic under its source module and authorized related
|
||||
modules. Contributors may add public keyword strings in `metadata.tags` and
|
||||
additional stable module IDs in `metadata.areas`. The Docs-owned
|
||||
user projection accepts up to 32 tags of at most 80 characters and 32 explicit
|
||||
area IDs of at most 255 characters. The
|
||||
`area_module_ids` response is authoritative: user responses omit related areas
|
||||
without an actor-visible route, while preserving the topic's source area.
|
||||
Existing role, tenant, locale, version, and configured-state topic authorization
|
||||
is unchanged. Tags are public descriptive content, not a place for private
|
||||
configuration, credentials, or hidden capability names.
|
||||
|
||||
Topic IDs identify content; parent-qualified navigation occurrence IDs identify
|
||||
one position in the tree. `topic` URLs remain supported; a tree click also sets
|
||||
`occurrence`, so reload highlights and reveals only the selected position.
|
||||
Expanding a repeated topic never expands its other occurrences.
|
||||
|
||||
### Deutsch
|
||||
|
||||
Die Suche berücksichtigt berechtigte Titel, Zusammenfassungen, Thementexte,
|
||||
Bereichsnamen und öffentliche Schlagwörter. Mehrere ausgewählte Schlagwörter
|
||||
werden mit ODER verknüpft. Alle auswählen entfernt die Einschränkung; Auswahl
|
||||
aufheben ergibt keine Treffer. Suchergebnisse enthalten jedes Thema einmal.
|
||||
Die Auswahl eines Treffers setzt die Filter zurück und öffnet das Thema.
|
||||
|
||||
Themen nach Bereich ordnet Beiträge dem Quellmodul und berechtigten verwandten
|
||||
Modulen zu. Modulautoren können öffentliche Stichwörter in `metadata.tags` und
|
||||
weitere stabile Modulkennungen in `metadata.areas` angeben. Für Benutzer werden
|
||||
verwandte Bereiche ohne sichtbare Route nicht ausgegeben. Suche und Filter
|
||||
erweitern niemals die Dokumentationsberechtigung. Zugangsdaten und private
|
||||
Konfiguration gehören nicht in Schlagwörter.
|
||||
|
||||
Ein Thema kann in mehreren Zweigen erscheinen. Eine positionsbezogene Kennung
|
||||
im Link speichert, welches Vorkommen ausgewählt wurde. Nur dieses Vorkommen
|
||||
wird hervorgehoben; Aufklappen öffnet nicht zugleich die anderen Fundstellen.
|
||||
|
||||
## Inputs
|
||||
|
||||
The documentation context is built from:
|
||||
|
||||
@@ -29,6 +29,21 @@ Semantic content is bounded plain text. Links must be local absolute paths or HT
|
||||
|
||||
Search indexes only published revisions and always requires provider reauthorization before returning a result. Generic public documentation generation reads static manifest topics only, so it cannot include tenant semantic entries. The separately authorized tenant export includes current entries and immutable history and sends `private, no-store`.
|
||||
|
||||
Collection/context projection and search authorization load required revisions
|
||||
in request-local batches of at most 400 identifiers. Read-only projection loads
|
||||
published content only, never pending draft bodies, and remains available if a
|
||||
pending draft reference is broken. Editors still receive an explicit error for
|
||||
an unavailable current revision. Revision tenant, entry, and publication-state
|
||||
references must agree before content is projected or indexed. An audience
|
||||
denial is checked before calling the subject provider; an allowed audience does
|
||||
not replace the provider's current permission checks.
|
||||
|
||||
This reduces Docs-owned database round trips without caching authorization
|
||||
between requests or importing subject-module internals. Owner-provider reads
|
||||
remain independent. The batch size is not a collection or export limit: complete
|
||||
semantic catalogues and separately authorized history exports can still require
|
||||
work proportional to their size.
|
||||
|
||||
## Backup, recovery, and module removal
|
||||
|
||||
Back up `docs_semantic_entries` and `docs_semantic_revisions` together with Core tenant and audit state. Restoring only one table breaks revision pointers and is unsupported. The installer blocks normal uninstall while rows remain. Destructive retirement is explicit, requires a database snapshot, and drops revision history before entries.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"README.md"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-docs"
|
||||
version = "0.1.19"
|
||||
version = "0.1.23"
|
||||
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.18",
|
||||
"govoplan-core>=0.1.45",
|
||||
"govoplan-access>=0.1.18",
|
||||
]
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.18"
|
||||
__version__ = "0.1.23"
|
||||
|
||||
@@ -17,6 +17,7 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
localized_documentation_metadata,
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
@@ -39,6 +40,7 @@ from govoplan_docs.backend.sources import (
|
||||
)
|
||||
from govoplan_docs.backend.semantic_service import (
|
||||
list_semantic_entries,
|
||||
prefetch_semantic_revisions,
|
||||
select_locale_entries,
|
||||
semantic_entry_payload,
|
||||
)
|
||||
@@ -829,6 +831,11 @@ def _classify_documentation(
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []}
|
||||
installed = {manifest.id for manifest in registry.manifests()}
|
||||
visible_area_modules = frozenset(
|
||||
str(item["module_id"])
|
||||
for item in _route_items(registry.manifests(), principal)
|
||||
if item["visible"]
|
||||
) if documentation_type == "user" else frozenset(installed)
|
||||
visible_runtime_paths = frozenset([
|
||||
"/settings", # Authenticated shell route, not contributed by a module manifest.
|
||||
*(
|
||||
@@ -908,6 +915,7 @@ def _classify_documentation(
|
||||
visible_runtime_paths=visible_runtime_paths,
|
||||
configuration=configuration,
|
||||
resolved_version=resolved_version,
|
||||
visible_area_modules=visible_area_modules,
|
||||
))
|
||||
return layers
|
||||
|
||||
@@ -1010,6 +1018,7 @@ def _semantic_documentation_topics(
|
||||
list_semantic_entries(session, principal),
|
||||
locale=locale,
|
||||
)
|
||||
revisions = prefetch_semantic_revisions(session, principal, entries=entries, editor=False)
|
||||
topics: list[DocumentationTopic] = []
|
||||
for entry in entries:
|
||||
payload = semantic_entry_payload(
|
||||
@@ -1019,6 +1028,7 @@ def _semantic_documentation_topics(
|
||||
entry=entry,
|
||||
editor=False,
|
||||
requested_locale=locale,
|
||||
revisions=revisions,
|
||||
)
|
||||
if payload is None:
|
||||
continue
|
||||
@@ -1429,9 +1439,14 @@ def _documentation_topic_payload(
|
||||
visible_runtime_paths: frozenset[str],
|
||||
configuration: Mapping[str, DocumentationConfigurationDecision],
|
||||
resolved_version: str,
|
||||
visible_area_modules: frozenset[str] = frozenset(),
|
||||
) -> dict[str, Any]:
|
||||
module_id = topic.source_module_id or source_module_id
|
||||
translation_locale, translation = _translation_for_locale(topic, locale)
|
||||
structured_translation_locale = _structured_translation_locale(topic, locale)
|
||||
localized_metadata = localized_documentation_metadata(
|
||||
topic, structured_translation_locale
|
||||
)
|
||||
kind = _documentation_topic_kind(topic)
|
||||
payload = {
|
||||
"id": topic.id,
|
||||
@@ -1459,6 +1474,8 @@ def _documentation_topic_payload(
|
||||
"i18n_key": topic.i18n_key or topic.id,
|
||||
"locale": locale,
|
||||
"translation_locale": translation_locale,
|
||||
"structured_translation_locale": structured_translation_locale,
|
||||
"structured_translation_version": topic.structured_translation_version,
|
||||
"version": {
|
||||
"resolved": resolved_version,
|
||||
"minimum": topic.version_min,
|
||||
@@ -1477,19 +1494,30 @@ def _documentation_topic_payload(
|
||||
"conditions": [_documentation_condition_payload(condition) for condition in topic.conditions],
|
||||
"links": [_documentation_link_payload(link) for link in topic.links],
|
||||
"related_modules": list(topic.related_modules),
|
||||
"area_module_ids": sorted({
|
||||
module_id,
|
||||
*(
|
||||
area for area in (
|
||||
*topic.related_modules,
|
||||
*_bounded_string_list(localized_metadata.get("areas"), maximum_items=32, maximum_length=255),
|
||||
)
|
||||
if area in visible_area_modules
|
||||
),
|
||||
}),
|
||||
"unlocks": list(topic.unlocks),
|
||||
"configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}),
|
||||
"configuration_states": [
|
||||
_documentation_configuration_payload(configuration[key])
|
||||
for key in sorted(configuration)
|
||||
],
|
||||
"metadata": dict(topic.metadata),
|
||||
"metadata": localized_metadata,
|
||||
}
|
||||
if documentation_type == "admin":
|
||||
return payload
|
||||
return {
|
||||
"id": payload["id"],
|
||||
"source_module_id": payload["source_module_id"],
|
||||
"area_module_ids": payload["area_module_ids"],
|
||||
"kind": payload["kind"],
|
||||
"anchor_id": payload["anchor_id"],
|
||||
"title": payload["title"],
|
||||
@@ -1511,6 +1539,12 @@ def _documentation_topic_payload(
|
||||
"i18n_key": "",
|
||||
"locale": locale,
|
||||
"translation_locale": payload["translation_locale"],
|
||||
"structured_translation_locale": payload[
|
||||
"structured_translation_locale"
|
||||
],
|
||||
"structured_translation_version": payload[
|
||||
"structured_translation_version"
|
||||
],
|
||||
"conditions": [],
|
||||
"links": [
|
||||
_documentation_link_payload(link)
|
||||
@@ -1521,7 +1555,7 @@ def _documentation_topic_payload(
|
||||
"unlocks": list(topic.unlocks),
|
||||
"configuration_keys": [],
|
||||
"configuration_states": [],
|
||||
"metadata": _user_topic_metadata(kind, topic.metadata),
|
||||
"metadata": _user_topic_metadata(kind, localized_metadata),
|
||||
}
|
||||
|
||||
|
||||
@@ -1542,6 +1576,9 @@ def _user_topic_metadata(kind: str, metadata: Mapping[str, Any]) -> dict[str, An
|
||||
value = _bounded_string(metadata.get(key), maximum=255)
|
||||
if value:
|
||||
projected[key] = value
|
||||
tags = _bounded_string_list(metadata.get("tags"), maximum_items=32, maximum_length=80)
|
||||
if tags:
|
||||
projected["tags"] = tags
|
||||
if kind == "reference" and isinstance(metadata.get("fields"), list):
|
||||
fields = [_user_field_metadata(item) for item in metadata["fields"][:64] if isinstance(item, Mapping)]
|
||||
if fields:
|
||||
@@ -1662,6 +1699,13 @@ def _translation_for_locale(topic: DocumentationTopic, locale: str) -> tuple[str
|
||||
return "source", {}
|
||||
|
||||
|
||||
def _structured_translation_locale(topic: DocumentationTopic, locale: str) -> str:
|
||||
for candidate in _locale_candidates(locale):
|
||||
if candidate in topic.structured_translations:
|
||||
return candidate
|
||||
return "source"
|
||||
|
||||
|
||||
def _locale_candidates(locale: str) -> tuple[str, ...]:
|
||||
normalized = _normalize_locale(locale)
|
||||
base = normalized.split("-", 1)[0]
|
||||
|
||||
@@ -43,6 +43,7 @@ from govoplan_docs.backend.semantic_service import (
|
||||
create_semantic_entry,
|
||||
get_semantic_entry,
|
||||
list_semantic_entries,
|
||||
prefetch_semantic_revisions,
|
||||
publication_policy,
|
||||
publish_semantic_entry,
|
||||
retire_semantic_entry,
|
||||
@@ -167,6 +168,7 @@ def list_entries(
|
||||
subject_kind=subject_kind,
|
||||
)
|
||||
selected = entries if editor else select_locale_entries(entries, locale=locale)
|
||||
revisions = prefetch_semantic_revisions(session, principal, entries=selected, editor=editor)
|
||||
items = [
|
||||
item
|
||||
for entry in selected
|
||||
@@ -178,6 +180,7 @@ def list_entries(
|
||||
entry=entry,
|
||||
editor=editor,
|
||||
requested_locale=locale,
|
||||
revisions=revisions,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
|
||||
@@ -131,7 +131,7 @@ def _dsar_provider(_context: ModuleContext) -> DocsDsarProvider:
|
||||
manifest = ModuleManifest(
|
||||
id="docs",
|
||||
name="Docs",
|
||||
version="0.1.19",
|
||||
version="0.1.23",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -302,6 +302,46 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="docs.workflow.find-help",
|
||||
title="Find help by area and keyword",
|
||||
summary="Search visible help topics and use area and keyword tags to narrow the results.",
|
||||
body=(
|
||||
"Enter words from your question in Search help topics. Titles, summaries, topic text, area names, and contributed tags are searched together. "
|
||||
"Areas and tags is a multi-selection dropdown: selected tags match any of those tags; Select all removes the restriction and Clear all selects no topics. "
|
||||
"Topics by area includes all visible guidance associated with an area, including guidance contributed by another module. "
|
||||
"A topic can appear in several branches; only the occurrence you select is highlighted, and expanding it does not expand its other occurrences. "
|
||||
"Search results show each topic once. Choosing a result clears the filters and opens the topic. Search and tags only narrow the documentation already authorized for your role. "
|
||||
"Administrators and module authors contribute public keywords in DocumentationTopic metadata.tags and optional module-area IDs in metadata.areas; the source module and authorized related_modules also supply area tags. "
|
||||
"Use stable module IDs for areas and readable keywords for tags; do not put credentials, private configuration, or hidden capability names in public search tags. "
|
||||
"User area facets omit related modules without a visible route; administrative documentation still requires its separate permission."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
order=9,
|
||||
conditions=(DocumentationCondition(required_scopes=(DOCS_READ_SCOPE,)),),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Hilfe nach Bereich und Stichwort finden",
|
||||
"summary": "Sichtbare Hilfethemen durchsuchen und Ergebnisse mit Bereichen und Schlagwörtern eingrenzen.",
|
||||
"body": (
|
||||
"Geben Sie unter Hilfethemen suchen Wörter aus Ihrer Frage ein. Titel, Zusammenfassungen, Thementexte, Bereichsnamen und beigetragene Schlagwörter werden gemeinsam durchsucht. "
|
||||
"Bereiche und Schlagwörter ist eine Auswahlliste mit Mehrfachauswahl: Ein ausgewähltes Schlagwort genügt für einen Treffer. Alle auswählen entfernt die Einschränkung, Auswahl aufheben wählt keine Themen. "
|
||||
"Themen nach Bereich enthält sämtliche sichtbaren Hinweise eines Bereichs, auch Beiträge anderer Module. "
|
||||
"Ein Thema kann in mehreren Zweigen erscheinen; nur die angeklickte Stelle wird hervorgehoben, und das Aufklappen öffnet nicht zugleich die anderen Vorkommen. "
|
||||
"Suchergebnisse zeigen jedes Thema einmal. Die Auswahl eines Ergebnisses setzt die Filter zurück und öffnet das Thema. Suche und Schlagwörter grenzen ausschließlich die bereits für Ihre Rolle freigegebene Dokumentation ein. "
|
||||
"Administratoren und Modulautoren hinterlegen öffentliche Stichwörter in DocumentationTopic metadata.tags und optionale Modul-Bereichskennungen in metadata.areas; das Quellmodul und berechtigte related_modules liefern ebenfalls Bereichsschlagwörter. "
|
||||
"Verwenden Sie stabile Modulkennungen für Bereiche und lesbare Stichwörter für Schlagwörter. Zugangsdaten, private Konfiguration und Namen verborgener Fähigkeiten gehören nicht in öffentliche Suchschlagwörter. "
|
||||
"Bereichsfilter der Benutzerdokumentation zeigen keine zugeordneten Module ohne sichtbare Route. Administrative Dokumentation benötigt weiterhin ihre gesonderte Berechtigung."
|
||||
),
|
||||
},
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"tags": ["Help", "Hilfe", "Search", "Suche", "Tags", "Schlagwörter"],
|
||||
"help_contexts": ["docs.help-center.search"],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="docs.semantic-documentation",
|
||||
title="Tenant semantic documentation",
|
||||
@@ -311,7 +351,8 @@ manifest = ModuleManifest(
|
||||
"Every save creates an immutable revision. Tenant policy chooses direct publication or an independent reviewer. "
|
||||
"Published content remains subject to the subject's current authorization, the documentation audience and classification, tenant isolation, and locale selection. "
|
||||
"Changed, missing, superseded, or temporarily unavailable subjects are shown explicitly; direct links, contextual help, search, caches, and tenant exports apply the same read-time authorization. "
|
||||
"Retirement and supersession preserve history. Generic public documentation exports never include tenant semantic entries; administrators use the separately authorized tenant export."
|
||||
"Retirement and supersession preserve history. Generic public documentation exports never include tenant semantic entries; administrators use the separately authorized tenant export. "
|
||||
"Collection, configured-context, and search-authorization reads batch the required revisions in request-local groups of at most 400 identifiers instead of fetching revisions once per entry. Read-only views load published content, not pending draft bodies. Each revision must belong to the same tenant and entry, and a published pointer must reference a published revision; inconsistent references fail closed. Audience denial avoids unnecessary subject-provider work, while allowed results still require the owning subject's current authorization. No cross-request permission cache is introduced; complete tenant exports and full history remain separately authorized operations."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -339,7 +380,31 @@ manifest = ModuleManifest(
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={"kind": "workflow"},
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Semantische Dokumentation des Mandanten",
|
||||
"summary": "Erläutern, was konfigurierte Formulare, Felder, Workflows, Schritte und andere stabile Fachobjekte in diesem Mandanten bedeuten.",
|
||||
"body": (
|
||||
"Autorinnen und Autoren wählen ein berechtigtes Fachobjekt aus, das sein besitzendes Modul bereitstellt, "
|
||||
"und verfassen sprachspezifische Hinweise als Klartext. Jeder Speichervorgang erzeugt eine unveränderliche Revision. "
|
||||
"Die Mandantenrichtlinie legt direkte Veröffentlichung oder eine unabhängige Prüfung fest. Veröffentlichte Inhalte "
|
||||
"unterliegen weiterhin der aktuellen Berechtigung für das Fachobjekt, der Zielgruppe und Klassifizierung der Dokumentation, "
|
||||
"der Mandantentrennung und der Sprachauswahl. Geänderte, fehlende, abgelöste oder vorübergehend nicht verfügbare Fachobjekte "
|
||||
"werden ausdrücklich gekennzeichnet; Direktlinks, Kontexthilfe, Suche, Zwischenspeicher und Mandantenexporte wenden dieselbe "
|
||||
"Berechtigungsprüfung beim Lesen an. Stilllegung und Ablösung bewahren die Historie. Allgemeine öffentliche Dokumentationsexporte "
|
||||
"enthalten niemals semantische Mandanteneinträge; für diese steht der getrennt berechtigte Mandantenexport bereit. "
|
||||
"Listen, Konfigurationskontext und Suchberechtigungsprüfung laden benötigte Revisionen anfragebezogen in Gruppen von höchstens 400 Kennungen statt einzeln je Eintrag. "
|
||||
"Nur lesbare Ansichten laden veröffentlichte Inhalte und keine offenen Entwurfstexte. Jede Revision muss zum selben Mandanten und Eintrag gehören; "
|
||||
"ein Veröffentlichungsverweis muss auf eine veröffentlichte Revision zeigen. Widersprüchliche Verweise werden abgewiesen. "
|
||||
"Bei einer nicht berechtigten Zielgruppe entfällt unnötige Arbeit des Fachobjekt-Providers; zulässige Ergebnisse erfordern weiterhin dessen aktuelle Berechtigungsprüfung. "
|
||||
"Es entsteht kein anfrageübergreifender Berechtigungszwischenspeicher. Vollständiger Mandantenexport und Historie bleiben getrennt berechtigte Vorgänge."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["docs.semantic-documentation.publish"],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="docs.data-subject-requests",
|
||||
@@ -378,6 +443,20 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
related_modules=("access", "audit", "policy"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Semantische Docs-Zuordnungen in einer Betroffenenanfrage prüfen",
|
||||
"summary": "Minimierte Verweise auf Autorenschaft, Prüfung, Eigentümerschaft und fachliche Zuständigkeit exportieren, ohne unbeteiligte mandanteneigene Hinweise offenzulegen.",
|
||||
"body": (
|
||||
"Docs gleicht exakte Konto- und namensraumgebundene Verweise auf semantische Einträge innerhalb des aktiven Mandanten ab. "
|
||||
"Die Projektion nennt Eintrag, Revision, Fachobjekt, Sprache, Lebenszyklusstatus und die übereinstimmenden Felder, schließt den "
|
||||
"verfassten Inhalt jedoch aus. Zuordnungen veröffentlichter, abgelöster und stillgelegter Inhalte sind unveränderliche Nachweise "
|
||||
"der Konfigurationssteuerung und werden mit Begründung aufbewahrt. Zuordnungen aus Entwürfen erfordern eine manuelle fachliche "
|
||||
"Prüfung, damit Eigentümerschaft oder Zuständigkeit vor einer möglichen Anonymisierung neu zugewiesen werden können; Docs führt "
|
||||
"keine automatische Löschung aus."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin?section=tenant-data-subject-requests",
|
||||
@@ -397,8 +476,8 @@ manifest = ModuleManifest(
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Dokumentation dieses Systems",
|
||||
"summary": "Diese Dokumentation beginnt mit den installierten Modulen, der aktiven Konfiguration und den Funktionen, die fuer diese Rolle sichtbar sind.",
|
||||
"body": "Module koennen feste Dokumentationsabschnitte beitragen. Wenn Inhalte von Tenant-Regeln, installierten Integrationen oder Betriebsoptionen abhaengen, kann ein Modul laufzeitbasierte Dokumentation registrieren.",
|
||||
"summary": "Diese Dokumentation beginnt mit den installierten Modulen, der aktiven Konfiguration und den Funktionen, die für diese Rolle sichtbar sind.",
|
||||
"body": "Module können feste Dokumentationsabschnitte beitragen. Wenn Inhalte von Mandantenregeln, installierten Integrationen oder Betriebsoptionen abhängen, kann ein Modul laufzeitbasierte Dokumentation registrieren.",
|
||||
},
|
||||
},
|
||||
links=(
|
||||
@@ -428,7 +507,8 @@ manifest = ModuleManifest(
|
||||
"The public exporter reads DocumentationTopic contributions from all installed packages or sibling module checkouts, "
|
||||
"projects German and English content, and records documentation coverage. Runtime providers remain in the authenticated "
|
||||
"Docs surface because their output can depend on the current actor, policy, configuration, and live service state. "
|
||||
"Publication CI should run the export check and reject a stale source digest."
|
||||
"Rendered steps, fields, limitations, consequences, and verification use Core's versioned same-shape structured-translation contract. "
|
||||
"Publication CI runs both the source-digest check and a reviewed monotonic coverage baseline, so regenerating the catalog cannot conceal a regression."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -442,7 +522,8 @@ manifest = ModuleManifest(
|
||||
"Der öffentliche Export liest die DocumentationTopic-Beiträge aus allen installierten Paketen oder benachbarten Modulquellen, "
|
||||
"projiziert deutsche und englische Inhalte und weist Dokumentationslücken aus. Laufzeit-Provider verbleiben in der authentifizierten "
|
||||
"Dokumentationsoberfläche, da ihre Ausgabe von Rolle, Richtlinie, Konfiguration und Dienstzustand abhängen kann. "
|
||||
"Die Veröffentlichungs-CI soll den Quelldigest prüfen und veraltete Exporte ablehnen."
|
||||
"Gerenderte Schritte, Felder, Einschränkungen, Folgen und Prüfhinweise verwenden den versionierten, formgleichen Vertrag für strukturierte Übersetzungen in Core. "
|
||||
"Die Veröffentlichungs-CI prüft sowohl den Quelldigest als auch einen freigegebenen monotonen Abdeckungsstand, damit das Neuerzeugen des Katalogs keine Verschlechterung verdecken kann."
|
||||
),
|
||||
}
|
||||
},
|
||||
@@ -472,7 +553,7 @@ manifest = ModuleManifest(
|
||||
"de": {
|
||||
"title": "Architektur der institutionellen Steuerung",
|
||||
"summary": "GovOPlaN modelliert institutionelle Verantwortung, gesteuerte Arbeit, formale Ergebnisse, Nachweise und die Datenhoheit externer Systeme, ohne alle Begriffe in den Kern oder eine monolithische Anwendung zu ziehen.",
|
||||
"body": "Organisationen, Identitaeten, IDM, Zugriff und Richtlinien beantworten unterschiedliche Teile der Frage, wer handeln darf. Mandate, Leistungen, Verfahrensbeteiligte und formale Entscheidungen beginnen als gemeinsame Vertraege und werden erst bei nachgewiesenem eigenstaendigem Lebenszyklus zu Modulen. Integrationen erklaeren technische Reife und Datenhoheit getrennt.",
|
||||
"body": "Organisationen, Identitäten, IDM, Zugriff und Richtlinien beantworten unterschiedliche Teile der Frage, wer handeln darf. Mandate, Leistungen, Verfahrensbeteiligte und formale Entscheidungen beginnen als gemeinsame Verträge und werden erst bei nachgewiesenem eigenständigem Lebenszyklus zu Modulen. Integrationen erklären technische Reife und Datenhoheit getrennt.",
|
||||
},
|
||||
},
|
||||
links=(
|
||||
@@ -508,6 +589,17 @@ manifest = ModuleManifest(
|
||||
audience=("user", "tenant_admin", "operator", "module_admin"),
|
||||
order=20,
|
||||
i18n_key="docs.topic.pattern.field_help",
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Hinweis am Feld",
|
||||
"summary": "Eine kleine Hilfemarkierung neben einer Beschriftung gibt lokalen Kontext, ohne dichte Formulare in Handbücher zu verwandeln.",
|
||||
"body": (
|
||||
"Verwenden Sie die Markierung für kurze Erläuterungen zu einem Feld, einer Option oder einem kompakten Begriff. "
|
||||
"Verweisen Sie auf ein Ablauf- oder Referenzthema, wenn Schritte, API-Zuordnung, Richtlinienherkunft oder betriebliche "
|
||||
"Einzelheiten benötigt werden."
|
||||
),
|
||||
}
|
||||
},
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Documentation experience concept",
|
||||
@@ -532,6 +624,23 @@ manifest = ModuleManifest(
|
||||
"access.workflow.grant-user-access",
|
||||
],
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"purpose": (
|
||||
"Beschriftungen bleiben schnell erfassbar, während kurze Erläuterungen bei Bedarf verfügbar sind."
|
||||
),
|
||||
"when_used": (
|
||||
"Formular- und Umschalterbeschriftungen, Zeilen mit wirksamen Werten und kompakte Verwaltungsbegriffe."
|
||||
),
|
||||
"user_explanation": (
|
||||
"Öffnen Sie die Markierung, wenn eine Beschriftung unklar ist. Sie erläutert die lokale Auswahl in ein oder zwei Sätzen."
|
||||
),
|
||||
"admin_explanation": (
|
||||
"Feldhilfe bleibt am Feld. Längere Verfahrens-, API- oder Richtlinienerläuterungen gehören in verknüpfte Ablauf- oder Referenzthemen."
|
||||
),
|
||||
}
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="docs.pattern.contextual-help",
|
||||
@@ -549,6 +658,18 @@ manifest = ModuleManifest(
|
||||
audience=("user", "tenant_admin", "operator", "module_admin"),
|
||||
order=21,
|
||||
i18n_key="docs.topic.pattern.contextual_help",
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Kontextsensitive Hilfe",
|
||||
"summary": "Mit F1 Hilfe zum aktuellen Seiten-, Feld-, Aktions- oder Dialogkontext öffnen.",
|
||||
"body": (
|
||||
"GovOPlaN löst Hilfe zuerst für das fokussierte Feld oder die fokussierte Aktion auf, danach für den Dialog oder Abschnitt, "
|
||||
"die aktuelle Seite und schließlich das besitzende Modul. Ist eine genaue Dokumentation vorhanden, wird sie angezeigt; andernfalls "
|
||||
"dient die Seiten- oder Moduldokumentation als Rückfall. Die Hilfe-Schaltfläche in der Titelleiste öffnet den aktuellen Seitenkontext. "
|
||||
"Die Dokumentation bleibt nach Zielgruppe, Berechtigungen und konfigurierten Modulen des aktuellen Kontos gefiltert."
|
||||
),
|
||||
}
|
||||
},
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Contextual help contract",
|
||||
@@ -584,6 +705,19 @@ manifest = ModuleManifest(
|
||||
audience=("user", "tenant_admin", "operator", "module_admin"),
|
||||
order=22,
|
||||
i18n_key="docs.topic.reference.temporal_data_context",
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Zeitlicher Datenkontext",
|
||||
"summary": "Festlegen, ob Seiten aktuell gültige Datensätze, zu einem gewählten Zeitpunkt gültige Datensätze oder alle Gültigkeitszustände zeigen.",
|
||||
"body": (
|
||||
"Die Kalendersteuerung in der Titelleiste setzt die Gültigkeitszeit für beteiligte Module. Aktuell ist die neutrale "
|
||||
"Voreinstellung. Zeitpunkt zeigt Datensätze, die zum gewählten Moment gültig sind; Alle umfasst historische und zukünftige "
|
||||
"Gültigkeitszustände. Die Aufzeichnungszeit bleibt davon getrennt und beschreibt, wann die Plattform eine Tatsache erfahren "
|
||||
"oder gespeichert hat. Berechtigungen werden stets gegenwärtig ausgewertet; eine Zeitauswahl stellt daher niemals frühere "
|
||||
"Zugriffsrechte wieder her."
|
||||
),
|
||||
}
|
||||
},
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Temporal data read contract",
|
||||
@@ -617,6 +751,19 @@ manifest = ModuleManifest(
|
||||
required_modules=("organizations", "identity", "idm", "access"),
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Abgrenzung von Organisationen, Identität, IDM und Zugriff",
|
||||
"summary": "Organizations definiert Strukturen und Funktionen, Identity Personen und Konten, IDM die Zuordnung von Identitäten zu Funktionen und Access die daraus entstehenden Rollen und Rechte.",
|
||||
"body": (
|
||||
"Verwenden Sie Organizations für Einheitstypen, Strukturen, Beziehungen, Organisationseinheiten und Funktionsdefinitionen. "
|
||||
"Identity verwaltet normalisierte Identitäten und Kontoverknüpfungen. IDM ordnet eine Identität oder ein Konto einer Funktion "
|
||||
"in einer Organisationseinheit zu und bildet dabei auch Delegation oder Handeln für andere ab. Access überführt anerkannte "
|
||||
"Funktionsmerkmale in Rollen und Berechtigungen. Diese Aufteilung trennt das Organisationsmodell vom Identitätslebenszyklus "
|
||||
"und hält Autorisierungsentscheidungen ausdrücklich nachvollziehbar."
|
||||
),
|
||||
}
|
||||
},
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Organizations", href="/organizations", kind="runtime"
|
||||
@@ -646,6 +793,17 @@ manifest = ModuleManifest(
|
||||
},
|
||||
],
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"admin_explanation": (
|
||||
"Auswirkungen von Funktionen auf Rollen gehören Access. Änderungen an IDM-Zuordnungen können unabhängig von Änderungen am Organisationsmodell gesteuert werden."
|
||||
),
|
||||
"user_explanation": (
|
||||
"Eine Person kann eine Funktion innehaben, weil IDM ihre Identität mit der Organisationsfunktion verknüpft. Access entscheidet, welche Anwendungsberechtigungen diese Funktion gewährt."
|
||||
),
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
documentation_sources=(
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
@@ -23,7 +23,11 @@ from govoplan_docs.backend.db.models import (
|
||||
SemanticDocumentationEntry,
|
||||
SemanticDocumentationRevision,
|
||||
)
|
||||
from govoplan_docs.backend.semantic_service import semantic_entry_payload
|
||||
from govoplan_docs.backend.semantic_service import (
|
||||
prefetch_semantic_revisions,
|
||||
published_revision,
|
||||
semantic_entry_payload,
|
||||
)
|
||||
|
||||
|
||||
PROVIDER_ID = "docs.semantic_documentation"
|
||||
@@ -58,8 +62,12 @@ class SemanticDocumentationSearchSource:
|
||||
select(SemanticDocumentationEntry, SemanticDocumentationRevision)
|
||||
.join(
|
||||
SemanticDocumentationRevision,
|
||||
SemanticDocumentationRevision.id
|
||||
== SemanticDocumentationEntry.published_revision_id,
|
||||
and_(
|
||||
SemanticDocumentationRevision.id == SemanticDocumentationEntry.published_revision_id,
|
||||
SemanticDocumentationRevision.entry_id == SemanticDocumentationEntry.id,
|
||||
SemanticDocumentationRevision.tenant_id == SemanticDocumentationEntry.tenant_id,
|
||||
SemanticDocumentationRevision.lifecycle_state == "published",
|
||||
),
|
||||
)
|
||||
.where(
|
||||
SemanticDocumentationEntry.tenant_id == request.tenant_id,
|
||||
@@ -105,15 +113,32 @@ class SemanticDocumentationSearchSource:
|
||||
if not isinstance(principal, ApiPrincipal) or not principal.has(DOCS_READ_SCOPE):
|
||||
return decisions
|
||||
db = _session(session)
|
||||
for item in requests:
|
||||
reference = item.reference
|
||||
valid_requests = [
|
||||
item
|
||||
for item in requests
|
||||
if (
|
||||
reference.tenant_id != principal.tenant_id
|
||||
or reference.module_id != "docs"
|
||||
or reference.resource_type != RESOURCE_TYPE
|
||||
):
|
||||
continue
|
||||
entry = db.get(SemanticDocumentationEntry, reference.resource_id)
|
||||
item.reference.tenant_id == principal.tenant_id
|
||||
and item.reference.module_id == "docs"
|
||||
and item.reference.resource_type == RESOURCE_TYPE
|
||||
)
|
||||
]
|
||||
ids = sorted({item.reference.resource_id for item in valid_requests})
|
||||
entries: dict[str, SemanticDocumentationEntry] = {}
|
||||
for offset in range(0, len(ids), 400):
|
||||
rows = db.scalars(
|
||||
select(SemanticDocumentationEntry).where(
|
||||
SemanticDocumentationEntry.id.in_(ids[offset:offset + 400]),
|
||||
SemanticDocumentationEntry.tenant_id == principal.tenant_id,
|
||||
SemanticDocumentationEntry.lifecycle_state.in_(("draft", "published")),
|
||||
)
|
||||
)
|
||||
entries.update((entry.id, entry) for entry in rows)
|
||||
revisions = prefetch_semantic_revisions(
|
||||
db, principal, entries=tuple(entries.values()), editor=False,
|
||||
)
|
||||
for item in valid_requests:
|
||||
reference = item.reference
|
||||
entry = entries.get(reference.resource_id)
|
||||
if (
|
||||
entry is None
|
||||
or entry.tenant_id != principal.tenant_id
|
||||
@@ -127,6 +152,7 @@ class SemanticDocumentationSearchSource:
|
||||
principal,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
revisions=revisions,
|
||||
)
|
||||
decisions[reference.key] = bool(
|
||||
payload
|
||||
@@ -153,8 +179,12 @@ class SemanticDocumentationSearchSource:
|
||||
db = _session(session)
|
||||
entry = db.get(SemanticDocumentationEntry, event.resource.id)
|
||||
revision = (
|
||||
db.get(SemanticDocumentationRevision, entry.published_revision_id)
|
||||
if entry is not None and entry.published_revision_id
|
||||
published_revision(db, entry)
|
||||
if (
|
||||
entry is not None
|
||||
and entry.tenant_id == event.tenant.id
|
||||
and entry.lifecycle_state in {"draft", "published"}
|
||||
)
|
||||
else None
|
||||
)
|
||||
visible = bool(
|
||||
@@ -162,6 +192,9 @@ class SemanticDocumentationSearchSource:
|
||||
and entry.tenant_id == event.tenant.id
|
||||
and entry.lifecycle_state in {"draft", "published"}
|
||||
and revision is not None
|
||||
and revision.entry_id == entry.id
|
||||
and revision.tenant_id == entry.tenant_id
|
||||
and revision.lifecycle_state == "published"
|
||||
)
|
||||
cursor = event.event_id
|
||||
document = _document(entry, revision, change_cursor=cursor) if visible else None
|
||||
|
||||
@@ -396,13 +396,21 @@ def semantic_entry_history(
|
||||
def current_revision(
|
||||
session: Session,
|
||||
entry: SemanticDocumentationEntry,
|
||||
*,
|
||||
revisions: Mapping[str, SemanticDocumentationRevision] | None = None,
|
||||
) -> SemanticDocumentationRevision:
|
||||
revision = None
|
||||
if entry.current_revision_id:
|
||||
revision = (
|
||||
session.get(SemanticDocumentationRevision, entry.current_revision_id)
|
||||
if entry.current_revision_id
|
||||
else None
|
||||
revisions.get(entry.current_revision_id)
|
||||
if revisions is not None
|
||||
else session.get(SemanticDocumentationRevision, entry.current_revision_id)
|
||||
)
|
||||
if revision is None or revision.entry_id != entry.id:
|
||||
if (
|
||||
revision is None
|
||||
or revision.entry_id != entry.id
|
||||
or revision.tenant_id != entry.tenant_id
|
||||
):
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic documentation current revision is unavailable."
|
||||
)
|
||||
@@ -412,14 +420,60 @@ def current_revision(
|
||||
def published_revision(
|
||||
session: Session,
|
||||
entry: SemanticDocumentationEntry,
|
||||
*,
|
||||
revisions: Mapping[str, SemanticDocumentationRevision] | None = None,
|
||||
) -> SemanticDocumentationRevision | None:
|
||||
if not entry.published_revision_id:
|
||||
return None
|
||||
revision = session.get(
|
||||
SemanticDocumentationRevision,
|
||||
entry.published_revision_id,
|
||||
revision = (
|
||||
revisions.get(entry.published_revision_id)
|
||||
if revisions is not None
|
||||
else session.get(SemanticDocumentationRevision, entry.published_revision_id)
|
||||
)
|
||||
return revision if revision is not None and revision.entry_id == entry.id else None
|
||||
if (
|
||||
revision is None
|
||||
or revision.entry_id != entry.id
|
||||
or revision.tenant_id != entry.tenant_id
|
||||
or revision.lifecycle_state != "published"
|
||||
):
|
||||
return None
|
||||
return revision
|
||||
|
||||
|
||||
def prefetch_semantic_revisions(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
entries: Sequence[SemanticDocumentationEntry],
|
||||
editor: bool,
|
||||
) -> dict[str, SemanticDocumentationRevision]:
|
||||
"""Load only the revisions this request may project, in bounded SQL batches.
|
||||
|
||||
This is request-local data loading, never an authorization cache. Payload
|
||||
projection still validates each entry/revision pair and its owner subject.
|
||||
Readers have no reason to load the bodies of pending unpublished drafts.
|
||||
"""
|
||||
tenant_id = _principal_tenant_id(principal)
|
||||
revision_ids = sorted({
|
||||
revision_id
|
||||
for entry in entries
|
||||
if entry.tenant_id == tenant_id
|
||||
for revision_id in (
|
||||
entry.published_revision_id,
|
||||
entry.current_revision_id if editor else None,
|
||||
)
|
||||
if revision_id
|
||||
})
|
||||
revisions: dict[str, SemanticDocumentationRevision] = {}
|
||||
for offset in range(0, len(revision_ids), 400):
|
||||
rows = session.query(SemanticDocumentationRevision).filter(
|
||||
SemanticDocumentationRevision.tenant_id == tenant_id,
|
||||
SemanticDocumentationRevision.id.in_(revision_ids[offset:offset + 400]),
|
||||
)
|
||||
if not editor:
|
||||
rows = rows.filter(SemanticDocumentationRevision.lifecycle_state == "published")
|
||||
revisions.update((revision.id, revision) for revision in rows)
|
||||
return revisions
|
||||
|
||||
|
||||
def entry_subject_reference(
|
||||
@@ -487,12 +541,16 @@ def semantic_entry_payload(
|
||||
entry: SemanticDocumentationEntry,
|
||||
editor: bool,
|
||||
requested_locale: str | None = None,
|
||||
revisions: Mapping[str, SemanticDocumentationRevision] | None = None,
|
||||
) -> dict[str, object] | None:
|
||||
current = current_revision(session, entry)
|
||||
published = published_revision(session, entry)
|
||||
selected = current if editor else published
|
||||
if entry.tenant_id != _principal_tenant_id(principal):
|
||||
return None
|
||||
published = published_revision(session, entry, revisions=revisions)
|
||||
selected = current_revision(session, entry, revisions=revisions) if editor else published
|
||||
if selected is None:
|
||||
return None
|
||||
if not content_visible_to_principal(selected.content, principal):
|
||||
return None
|
||||
resolution = resolve_entry_subject(
|
||||
session,
|
||||
registry,
|
||||
@@ -505,8 +563,6 @@ def semantic_entry_payload(
|
||||
unavailable = resolution.availability == "temporarily_unavailable"
|
||||
if unavailable and not editor:
|
||||
return None
|
||||
if not content_visible_to_principal(selected.content, principal):
|
||||
return None
|
||||
subject = resolution.subject
|
||||
required_scopes = subject.required_scopes if subject is not None else ()
|
||||
if any(not _principal_has(principal, scope) for scope in required_scopes):
|
||||
@@ -523,7 +579,7 @@ def semantic_entry_payload(
|
||||
"lifecycle_state": entry.lifecycle_state,
|
||||
"effective_state": selected.lifecycle_state,
|
||||
"pending_draft": bool(
|
||||
published is not None and current.id != published.id
|
||||
published is not None and entry.current_revision_id != published.id
|
||||
),
|
||||
"current_revision": entry.current_revision,
|
||||
"selected_revision": selected.revision,
|
||||
@@ -965,6 +1021,7 @@ __all__ = [
|
||||
"entry_subject_reference",
|
||||
"get_semantic_entry",
|
||||
"list_semantic_entries",
|
||||
"prefetch_semantic_revisions",
|
||||
"publication_policy",
|
||||
"publish_semantic_entry",
|
||||
"published_revision",
|
||||
|
||||
@@ -15,10 +15,12 @@ from typing import Any
|
||||
|
||||
from govoplan_core.core.discovery import discover_module_manifests
|
||||
from govoplan_core.core.modules import (
|
||||
DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
ModuleManifest,
|
||||
localizable_documentation_metadata_keys,
|
||||
)
|
||||
|
||||
|
||||
@@ -102,12 +104,12 @@ def documentation_coverage_markdown(catalog: Mapping[str, Any]) -> str:
|
||||
"2. Give every user-facing module at least one scope-conditioned workflow topic and one field/consequence reference.",
|
||||
"3. Give every configurable module an administrator topic covering permissions, policy provenance, retention, and operational consequences.",
|
||||
"4. Keep live provider-state and instance-specific limitations in `documentation_providers`; do not publish them as generic facts.",
|
||||
"5. Add a versioned localization contract for structured metadata such as steps, fields, limitations, and verification; these values currently retain their manifest source language.",
|
||||
"5. Adopt the versioned structured-localization contract for steps, fields, limitations, consequences, and verification; the coverage column tracks this migration independently from title/body completeness.",
|
||||
"",
|
||||
"## Module gaps",
|
||||
"",
|
||||
"| Module | Topics | Missing German | Missing coverage |",
|
||||
"| --- | ---: | ---: | --- |",
|
||||
"| Module | Topics | Missing German | Structured German | Missing coverage |",
|
||||
"| --- | ---: | ---: | ---: | --- |",
|
||||
]
|
||||
gaps_by_id = {str(item.get("module_id")): item for item in gaps}
|
||||
for module in modules:
|
||||
@@ -117,7 +119,9 @@ def documentation_coverage_markdown(catalog: Mapping[str, Any]) -> str:
|
||||
missing = ", ".join(str(item) for item in gap.get("missing", ())) or "-"
|
||||
lines.append(
|
||||
f"| `{module_id}` | {coverage.get('topic_count', 0)} | "
|
||||
f"{coverage.get('missing_german_topic_count', 0)} | {missing} |"
|
||||
f"{coverage.get('missing_german_topic_count', 0)} | "
|
||||
f"{coverage.get('german_structured_complete_topic_count', 0)}/"
|
||||
f"{coverage.get('structured_localizable_topic_count', 0)} | {missing} |"
|
||||
)
|
||||
lines.extend(("", "Generated file. Edit module manifests, then regenerate this report.", ""))
|
||||
return "\n".join(lines)
|
||||
@@ -238,6 +242,7 @@ def _topic_payload(module_id: str, topic: DocumentationTopic) -> dict[str, Any]:
|
||||
if key != "kind"
|
||||
}
|
||||
),
|
||||
**_structured_content_payload(topic),
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +263,36 @@ def _localized_topic(topic: DocumentationTopic, locale: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _localized_content(topic: DocumentationTopic, locale: str) -> dict[str, Any]:
|
||||
localizable_keys = localizable_documentation_metadata_keys(topic)
|
||||
translation = topic.structured_translations.get(locale, {})
|
||||
translated_fields = sorted(set(localizable_keys).intersection(translation))
|
||||
complete = locale == "en" or not localizable_keys or (
|
||||
topic.structured_translation_version
|
||||
== DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION
|
||||
and len(translated_fields) == len(localizable_keys)
|
||||
)
|
||||
return {
|
||||
"content": _public_value(dict(translation)),
|
||||
"source_locale": locale if translated_fields else "en",
|
||||
"translated_fields": translated_fields,
|
||||
"complete": complete,
|
||||
}
|
||||
|
||||
|
||||
def _structured_content_payload(topic: DocumentationTopic) -> dict[str, Any]:
|
||||
if not localizable_documentation_metadata_keys(topic):
|
||||
return {}
|
||||
return {
|
||||
"structured_translation_version": topic.structured_translation_version,
|
||||
"content_localizations": {
|
||||
locale: _localized_content(topic, locale)
|
||||
for locale in SUPPORTED_LOCALES
|
||||
if locale != "en"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _module_coverage(
|
||||
manifest: ModuleManifest,
|
||||
topics: Sequence[Mapping[str, Any]],
|
||||
@@ -270,6 +305,21 @@ def _module_coverage(
|
||||
for topic in topics
|
||||
if not _mapping(_mapping(topic.get("localizations")).get("de")).get("complete")
|
||||
]
|
||||
structured_localizable = [
|
||||
topic
|
||||
for topic in manifest.documentation
|
||||
if localizable_documentation_metadata_keys(topic)
|
||||
]
|
||||
german_structured_complete = [
|
||||
topic
|
||||
for topic in structured_localizable
|
||||
if _localized_content(topic, "de")["complete"]
|
||||
]
|
||||
missing_german_structured = [
|
||||
topic.id
|
||||
for topic in structured_localizable
|
||||
if not _localized_content(topic, "de")["complete"]
|
||||
]
|
||||
missing: list[str] = []
|
||||
if not has_user:
|
||||
missing.append("user documentation")
|
||||
@@ -291,6 +341,15 @@ def _module_coverage(
|
||||
),
|
||||
"missing_german_topic_count": len(missing_german),
|
||||
"missing_german_topic_ids": missing_german,
|
||||
"structured_localizable_topic_count": len(structured_localizable),
|
||||
"structured_translation_contract_topic_count": sum(
|
||||
topic.structured_translation_version
|
||||
== DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION
|
||||
for topic in structured_localizable
|
||||
),
|
||||
"german_structured_complete_topic_count": len(german_structured_complete),
|
||||
"missing_german_structured_topic_count": len(missing_german_structured),
|
||||
"missing_german_structured_topic_ids": missing_german_structured,
|
||||
"kinds": sorted(kinds),
|
||||
"missing": missing,
|
||||
}
|
||||
@@ -314,6 +373,30 @@ def _coverage_summary(modules: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
"module_count": len(modules),
|
||||
"topic_count": topic_count,
|
||||
"german_complete_topic_count": topic_count - missing_german,
|
||||
"structured_localizable_topic_count": sum(
|
||||
int(
|
||||
_mapping(module.get("coverage")).get(
|
||||
"structured_localizable_topic_count", 0
|
||||
)
|
||||
)
|
||||
for module in modules
|
||||
),
|
||||
"structured_translation_contract_topic_count": sum(
|
||||
int(
|
||||
_mapping(module.get("coverage")).get(
|
||||
"structured_translation_contract_topic_count", 0
|
||||
)
|
||||
)
|
||||
for module in modules
|
||||
),
|
||||
"german_structured_complete_topic_count": sum(
|
||||
int(
|
||||
_mapping(module.get("coverage")).get(
|
||||
"german_structured_complete_topic_count", 0
|
||||
)
|
||||
)
|
||||
for module in modules
|
||||
),
|
||||
"runtime_provider_module_count": sum(
|
||||
int(module.get("runtime_documentation_provider_count", 0)) > 0
|
||||
for module in modules
|
||||
@@ -323,6 +406,79 @@ def _coverage_summary(modules: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def coverage_baseline(catalog: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Build the monotonic public-documentation coverage baseline."""
|
||||
|
||||
summary = _mapping(catalog.get("summary"))
|
||||
topic_count = int(summary.get("topic_count", 0))
|
||||
german_complete = int(summary.get("german_complete_topic_count", 0))
|
||||
structured_localizable = int(
|
||||
summary.get("structured_localizable_topic_count", 0)
|
||||
)
|
||||
structured_complete = int(
|
||||
summary.get("german_structured_complete_topic_count", 0)
|
||||
)
|
||||
return {
|
||||
"schema_version": "1",
|
||||
"minimum": {
|
||||
"german_complete_topic_count": german_complete,
|
||||
"structured_translation_contract_topic_count": int(
|
||||
summary.get("structured_translation_contract_topic_count", 0)
|
||||
),
|
||||
"german_structured_complete_topic_count": structured_complete,
|
||||
},
|
||||
"maximum": {
|
||||
"missing_german_topic_count": topic_count - german_complete,
|
||||
"gap_module_count": int(summary.get("gap_module_count", 0)),
|
||||
"missing_german_structured_topic_count": (
|
||||
structured_localizable - structured_complete
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def coverage_regression_issues(
|
||||
catalog: Mapping[str, Any], baseline: Mapping[str, Any]
|
||||
) -> tuple[str, ...]:
|
||||
"""Return monotonic coverage failures against a reviewed baseline."""
|
||||
|
||||
if str(baseline.get("schema_version")) != "1":
|
||||
return ("unsupported coverage baseline schema_version",)
|
||||
summary = _mapping(catalog.get("summary"))
|
||||
minimum = _mapping(baseline.get("minimum"))
|
||||
maximum = _mapping(baseline.get("maximum"))
|
||||
actual = {
|
||||
"german_complete_topic_count": int(
|
||||
summary.get("german_complete_topic_count", 0)
|
||||
),
|
||||
"structured_translation_contract_topic_count": int(
|
||||
summary.get("structured_translation_contract_topic_count", 0)
|
||||
),
|
||||
"german_structured_complete_topic_count": int(
|
||||
summary.get("german_structured_complete_topic_count", 0)
|
||||
),
|
||||
"missing_german_topic_count": int(summary.get("topic_count", 0))
|
||||
- int(summary.get("german_complete_topic_count", 0)),
|
||||
"gap_module_count": int(summary.get("gap_module_count", 0)),
|
||||
"missing_german_structured_topic_count": int(
|
||||
summary.get("structured_localizable_topic_count", 0)
|
||||
)
|
||||
- int(summary.get("german_structured_complete_topic_count", 0)),
|
||||
}
|
||||
issues: list[str] = []
|
||||
for key, expected in minimum.items():
|
||||
if key in actual and actual[key] < int(expected):
|
||||
issues.append(
|
||||
f"coverage {key} regressed: {actual[key]} is below {int(expected)}"
|
||||
)
|
||||
for key, expected in maximum.items():
|
||||
if key in actual and actual[key] > int(expected):
|
||||
issues.append(
|
||||
f"coverage {key} regressed: {actual[key]} exceeds {int(expected)}"
|
||||
)
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _topic_kind(topic: DocumentationTopic) -> str:
|
||||
raw = topic.metadata.get("kind")
|
||||
if isinstance(raw, str):
|
||||
@@ -394,6 +550,7 @@ def _parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--coverage-output", type=Path)
|
||||
parser.add_argument("--workspace-root", type=Path)
|
||||
parser.add_argument("--coverage-baseline", type=Path)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
@@ -408,10 +565,22 @@ def main(argv: Iterable[str] | None = None) -> int:
|
||||
collect_manifest_sources(workspace_root=args.workspace_root)
|
||||
)
|
||||
if args.check:
|
||||
if catalog_matches_sources(args.output, catalog):
|
||||
return 0
|
||||
failed = False
|
||||
if not catalog_matches_sources(args.output, catalog):
|
||||
print(f"Public documentation catalog is stale: {args.output}", file=sys.stderr)
|
||||
failed = True
|
||||
if args.coverage_baseline is not None:
|
||||
try:
|
||||
baseline = json.loads(
|
||||
args.coverage_baseline.read_text(encoding="utf-8")
|
||||
)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"Invalid coverage baseline: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
for issue in coverage_regression_issues(catalog, _mapping(baseline)):
|
||||
print(f"Public documentation {issue}", file=sys.stderr)
|
||||
failed = True
|
||||
return int(failed)
|
||||
write_public_catalog(
|
||||
args.output,
|
||||
catalog,
|
||||
|
||||
@@ -52,6 +52,78 @@ class FakePrincipal:
|
||||
|
||||
|
||||
class DocsContextTests(unittest.TestCase):
|
||||
def test_public_topic_tags_and_areas_are_bounded_and_authorized(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="services", name="Services", version="1.0.0",
|
||||
nav_items=(NavItem(path="/services", label="Services", required_any=("services:read",)),),
|
||||
))
|
||||
registry.register(ModuleManifest(
|
||||
id="hidden", name="Hidden", version="1.0.0",
|
||||
nav_items=(NavItem(path="/hidden", label="Hidden", required_any=("hidden:read",)),),
|
||||
))
|
||||
registry.register(ModuleManifest(
|
||||
id="forms", name="Forms", version="1.0.0",
|
||||
documentation=(DocumentationTopic(
|
||||
id="forms.service-guidance", title="Service application", summary="Application help",
|
||||
documentation_types=("user", "admin"), related_modules=("services", "hidden", "uninstalled"),
|
||||
metadata={"kind": "reference", "tags": ["Application", "Antrag", None, 12, "x" * 81], "areas": ["services", "hidden"]},
|
||||
),),
|
||||
))
|
||||
user_layers = _classify_documentation(registry, FakePrincipal({"docs:documentation:read", "services:read"}), settings=None, session=None, documentation_type="user", locale="de")
|
||||
topic = next(item for layer in user_layers.values() for item in layer if item["id"] == "forms.service-guidance")
|
||||
self.assertEqual(topic["area_module_ids"], ["forms", "services"])
|
||||
self.assertEqual(topic["metadata"]["tags"], ["Application", "Antrag"])
|
||||
self.assertEqual(topic["related_modules"], [])
|
||||
self.assertNotIn("areas", topic["metadata"])
|
||||
hidden_layers = _classify_documentation(registry, FakePrincipal({"docs:documentation:read"}), settings=None, session=None, documentation_type="user", locale="de")
|
||||
topic = next(item for layer in hidden_layers.values() for item in layer if item["id"] == "forms.service-guidance")
|
||||
self.assertEqual(topic["area_module_ids"], ["forms"])
|
||||
|
||||
def test_structured_topic_metadata_uses_requested_locale(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="example.reference.localized",
|
||||
title="Reference",
|
||||
summary="Reference summary.",
|
||||
documentation_types=("admin",),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"limitations": ["English limitation."],
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"limitations": ["Deutsche Einschränkung."]
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
layers = _classify_documentation(
|
||||
registry,
|
||||
FakePrincipal({"docs:documentation:read"}),
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="admin",
|
||||
locale="de-DE",
|
||||
)
|
||||
|
||||
topic = layers["configured"][0]
|
||||
self.assertEqual("de", topic["structured_translation_locale"])
|
||||
self.assertEqual("1", topic["structured_translation_version"])
|
||||
self.assertEqual(
|
||||
["Deutsche Einschränkung."], topic["metadata"]["limitations"]
|
||||
)
|
||||
|
||||
def test_user_provider_state_omits_binding_details(self) -> None:
|
||||
state = {
|
||||
"configured": True,
|
||||
|
||||
@@ -6,16 +6,36 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.modules import DocumentationTopic, ModuleManifest
|
||||
from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest
|
||||
from govoplan_docs.public_export import (
|
||||
ManifestSource,
|
||||
build_public_catalog,
|
||||
catalog_matches_sources,
|
||||
coverage_baseline,
|
||||
coverage_regression_issues,
|
||||
documentation_coverage_markdown,
|
||||
write_public_catalog,
|
||||
)
|
||||
|
||||
|
||||
class PublicDocumentationExportTests(unittest.TestCase):
|
||||
def test_docs_manifest_has_complete_german_public_coverage(self) -> None:
|
||||
module = build_public_catalog(
|
||||
(ManifestSource(get_docs_manifest()),)
|
||||
)["modules"][0]
|
||||
|
||||
self.assertEqual(10, module["coverage"]["topic_count"])
|
||||
self.assertEqual(0, module["coverage"]["missing_german_topic_count"])
|
||||
self.assertEqual([], module["coverage"]["missing_german_topic_ids"])
|
||||
self.assertEqual(2, module["coverage"]["structured_localizable_topic_count"])
|
||||
self.assertEqual(
|
||||
2, module["coverage"]["german_structured_complete_topic_count"]
|
||||
)
|
||||
self.assertEqual(
|
||||
[], module["coverage"]["missing_german_structured_topic_ids"]
|
||||
)
|
||||
self.assertEqual([], module["coverage"]["missing"])
|
||||
|
||||
def test_catalog_projects_localized_manifest_topics_and_gaps(self) -> None:
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
@@ -28,7 +48,11 @@ class PublicDocumentationExportTests(unittest.TestCase):
|
||||
summary="Perform the example workflow.",
|
||||
body="Open and finish it.",
|
||||
documentation_types=("user", "admin"),
|
||||
metadata={"kind": "workflow"},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"steps": ["Review", "Execute"],
|
||||
"verification": "Confirm completion.",
|
||||
},
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Beispiel verwenden",
|
||||
@@ -36,6 +60,13 @@ class PublicDocumentationExportTests(unittest.TestCase):
|
||||
"body": "Öffnen und abschließen.",
|
||||
}
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"steps": ["Prüfen", "Ausführen"],
|
||||
"verification": "Den Abschluss bestätigen.",
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -48,7 +79,12 @@ class PublicDocumentationExportTests(unittest.TestCase):
|
||||
self.assertEqual("Beispiel verwenden", topic["localizations"]["de"]["title"])
|
||||
self.assertTrue(topic["localizations"]["de"]["complete"])
|
||||
self.assertEqual("workflow", topic["kind"])
|
||||
self.assertEqual({}, topic["content"])
|
||||
self.assertEqual(["Review", "Execute"], topic["content"]["steps"])
|
||||
self.assertEqual(
|
||||
["Prüfen", "Ausführen"],
|
||||
topic["content_localizations"]["de"]["content"]["steps"],
|
||||
)
|
||||
self.assertTrue(topic["content_localizations"]["de"]["complete"])
|
||||
self.assertIn(
|
||||
"field/consequence reference",
|
||||
catalog["modules"][0]["coverage"]["missing"],
|
||||
@@ -148,6 +184,57 @@ class PublicDocumentationExportTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual("operator-workflow", topic["kind"])
|
||||
|
||||
def test_coverage_baseline_rejects_text_and_structured_regressions(self) -> None:
|
||||
healthy = build_public_catalog(
|
||||
(
|
||||
ManifestSource(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="example.reference",
|
||||
title="Reference",
|
||||
summary="Reference summary.",
|
||||
body="Reference body.",
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Referenz",
|
||||
"summary": "Referenzzusammenfassung.",
|
||||
"body": "Referenzinhalt.",
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"limitations": ["Source limitation."],
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"limitations": [
|
||||
"Einschränkung der Quelle."
|
||||
]
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
baseline = coverage_baseline(healthy)
|
||||
self.assertEqual((), coverage_regression_issues(healthy, baseline))
|
||||
|
||||
regressed = json.loads(json.dumps(healthy))
|
||||
regressed["summary"]["german_complete_topic_count"] = 0
|
||||
regressed["summary"]["german_structured_complete_topic_count"] = 0
|
||||
issues = coverage_regression_issues(regressed, baseline)
|
||||
self.assertTrue(any("german_complete_topic_count" in issue for issue in issues))
|
||||
self.assertTrue(
|
||||
any("german_structured_complete_topic_count" in issue for issue in issues)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillRequest,
|
||||
SearchResourceReference,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_docs.backend.db.models import (
|
||||
SemanticDocumentationEntry,
|
||||
SemanticDocumentationRevision,
|
||||
)
|
||||
from govoplan_docs.backend.search_source import (
|
||||
PROVIDER_ID,
|
||||
RESOURCE_TYPE,
|
||||
SemanticDocumentationSearchSource,
|
||||
)
|
||||
from govoplan_docs.backend.semantic_service import (
|
||||
SemanticDocumentationError,
|
||||
current_revision,
|
||||
list_semantic_entries,
|
||||
prefetch_semantic_revisions,
|
||||
semantic_entry_payload,
|
||||
)
|
||||
from test_semantic_documentation import (
|
||||
_content,
|
||||
_principal,
|
||||
_Registry,
|
||||
_SubjectProvider,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def select_queries(engine):
|
||||
statements: list[str] = []
|
||||
|
||||
def observe(_connection, _cursor, statement, _parameters, _context, _many):
|
||||
if statement.lstrip().upper().startswith("SELECT"):
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(engine, "before_cursor_execute", observe)
|
||||
try:
|
||||
yield statements
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", observe)
|
||||
|
||||
|
||||
class SemanticReadEfficiencyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
SemanticDocumentationEntry.__table__,
|
||||
SemanticDocumentationRevision.__table__,
|
||||
],
|
||||
)
|
||||
self.provider = _SubjectProvider()
|
||||
self.registry = _Registry(self.provider)
|
||||
self.principal = _principal("author")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.engine.dispose()
|
||||
|
||||
def seed(self, count: int) -> None:
|
||||
with Session(self.engine) as session:
|
||||
for index in range(count):
|
||||
identifier = f"entry-{index:04}"
|
||||
session.add(
|
||||
SemanticDocumentationEntry(
|
||||
id=identifier,
|
||||
tenant_id="tenant-1",
|
||||
subject_stable_key="same-subject",
|
||||
subject_module_id="forms",
|
||||
subject_kind="form",
|
||||
subject_id="form-1",
|
||||
locale=f"de-{index:03}",
|
||||
lifecycle_state="draft",
|
||||
current_revision=2,
|
||||
current_revision_id=f"draft-{index:04}",
|
||||
published_revision_id=f"published-{index:04}",
|
||||
created_by="author",
|
||||
updated_by="author",
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
for revision, state, title in (
|
||||
(1, "published", f"Published {index}"),
|
||||
(2, "draft", f"Unpublished secret {index}"),
|
||||
):
|
||||
session.add(
|
||||
SemanticDocumentationRevision(
|
||||
id=f"{state}-{index:04}",
|
||||
tenant_id="tenant-1",
|
||||
entry_id=identifier,
|
||||
revision=revision,
|
||||
lifecycle_state=state,
|
||||
action="publish" if revision == 1 else "save",
|
||||
change_reason="Fixture",
|
||||
content=_content(title),
|
||||
content_hash=f"hash-{state}-{index}",
|
||||
authored_by="author",
|
||||
search_text=title,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def test_reader_and_editor_batching_preserve_payloads_with_two_queries_for_forty_entries(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(40)
|
||||
for editor in (False, True):
|
||||
with self.subTest(editor=editor):
|
||||
with (
|
||||
Session(self.engine) as session,
|
||||
select_queries(self.engine) as ordinary_queries,
|
||||
):
|
||||
entries = list_semantic_entries(session, self.principal)
|
||||
ordinary = [
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
self.principal,
|
||||
entry=entry,
|
||||
editor=editor,
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
with (
|
||||
Session(self.engine) as session,
|
||||
select_queries(self.engine) as batch_queries,
|
||||
):
|
||||
entries = list_semantic_entries(session, self.principal)
|
||||
revisions = prefetch_semantic_revisions(
|
||||
session, self.principal, entries=entries, editor=editor
|
||||
)
|
||||
batched = [
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
self.principal,
|
||||
entry=entry,
|
||||
editor=editor,
|
||||
revisions=revisions,
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
if not editor:
|
||||
self.assertTrue(
|
||||
all(
|
||||
item.lifecycle_state == "published"
|
||||
for item in revisions.values()
|
||||
)
|
||||
)
|
||||
self.assertTrue(all(item["pending_draft"] for item in batched))
|
||||
self.assertTrue(
|
||||
all(
|
||||
"Unpublished" not in item["content"]["title"]
|
||||
for item in batched
|
||||
)
|
||||
)
|
||||
self.assertEqual(ordinary, batched)
|
||||
self.assertEqual(81 if editor else 41, len(ordinary_queries))
|
||||
self.assertEqual(2, len(batch_queries))
|
||||
|
||||
def test_revision_prefetch_chunks_large_collections(self) -> None:
|
||||
self.seed(401)
|
||||
with Session(self.engine) as session, select_queries(self.engine) as queries:
|
||||
entries = list_semantic_entries(session, self.principal)
|
||||
revisions = prefetch_semantic_revisions(
|
||||
session, self.principal, entries=entries, editor=False
|
||||
)
|
||||
self.assertEqual(401, len(revisions))
|
||||
self.assertEqual(
|
||||
3, len(queries), "One entry query and two bounded revision batches."
|
||||
)
|
||||
|
||||
def test_search_authorization_batches_rows_without_weakening_owner_audience_or_revision_checks(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(40)
|
||||
requests = tuple(
|
||||
SearchAuthorizationRequest(
|
||||
reference=SearchResourceReference(
|
||||
tenant_id="tenant-1",
|
||||
module_id="docs",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=f"entry-{index:04}",
|
||||
),
|
||||
source_revision=f"published-{index:04}",
|
||||
)
|
||||
for index in range(40)
|
||||
)
|
||||
source = SemanticDocumentationSearchSource(self.registry)
|
||||
with Session(self.engine) as session, select_queries(self.engine) as queries:
|
||||
decisions = source.authorize(session, self.principal, requests=requests)
|
||||
self.assertTrue(all(decisions.values()))
|
||||
self.assertEqual(2, len(queries))
|
||||
self.provider.denied_accounts.add(self.principal.account_id)
|
||||
with Session(self.engine) as session:
|
||||
self.assertFalse(
|
||||
any(
|
||||
source.authorize(
|
||||
session, self.principal, requests=requests
|
||||
).values()
|
||||
)
|
||||
)
|
||||
self.provider.denied_accounts.clear()
|
||||
stale = SearchAuthorizationRequest(
|
||||
reference=requests[0].reference, source_revision="old-publication"
|
||||
)
|
||||
with Session(self.engine) as session:
|
||||
self.assertFalse(
|
||||
source.authorize(session, self.principal, requests=(stale,))[
|
||||
stale.reference.key
|
||||
]
|
||||
)
|
||||
|
||||
def test_audience_denial_precedes_provider_work_and_foreign_entries_do_not_load_revisions(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(1)
|
||||
with Session(self.engine) as session:
|
||||
revision = session.get(SemanticDocumentationRevision, "published-0000")
|
||||
revision.content = {
|
||||
**revision.content,
|
||||
"classification": "restricted",
|
||||
"audience": ["account:other"],
|
||||
}
|
||||
session.commit()
|
||||
with (
|
||||
Session(self.engine) as session,
|
||||
patch.object(
|
||||
self.provider, "resolve_subject", wraps=self.provider.resolve_subject
|
||||
) as resolve,
|
||||
):
|
||||
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session, self.registry, self.principal, entry=entry, editor=False
|
||||
)
|
||||
)
|
||||
resolve.assert_not_called()
|
||||
with select_queries(self.engine) as queries:
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
_principal("other", tenant_id="tenant-other"),
|
||||
entry=entry,
|
||||
editor=False,
|
||||
)
|
||||
)
|
||||
self.assertEqual([], queries)
|
||||
|
||||
def test_cross_tenant_or_cross_entry_revision_references_fail_closed_in_reading_and_indexing(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(2)
|
||||
source = SemanticDocumentationSearchSource(self.registry)
|
||||
publication_event = PlatformEvent(
|
||||
type="docs.semantic.published",
|
||||
module_id="docs",
|
||||
tenant=EventTenantRef(id="tenant-1"),
|
||||
resource=EventObjectRef(type=RESOURCE_TYPE, id="entry-0000"),
|
||||
)
|
||||
with Session(self.engine) as session:
|
||||
changes = source.index_changes_for_event(
|
||||
session, event=publication_event, delivery_key="valid-fixture"
|
||||
)
|
||||
self.assertEqual("upsert", changes[0].kind)
|
||||
self.assertEqual("published-0000", changes[0].document.source_revision)
|
||||
for corruption in ("tenant", "entry"):
|
||||
with self.subTest(corruption=corruption), Session(self.engine) as session:
|
||||
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
||||
revision = session.get(SemanticDocumentationRevision, "published-0000")
|
||||
if corruption == "tenant":
|
||||
revision.tenant_id = "tenant-other"
|
||||
else:
|
||||
revision.tenant_id = "tenant-1"
|
||||
revision.entry_id = "entry-0001"
|
||||
revision.revision = 3
|
||||
session.commit()
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
self.principal,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
)
|
||||
)
|
||||
page = SemanticDocumentationSearchSource(self.registry).backfill(
|
||||
session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
rebuild_id="fixture",
|
||||
limit=100,
|
||||
),
|
||||
)
|
||||
self.assertNotIn(
|
||||
"entry-0000", {document.resource_id for document in page.documents}
|
||||
)
|
||||
changes = source.index_changes_for_event(
|
||||
session,
|
||||
event=publication_event,
|
||||
delivery_key=f"invalid-{corruption}",
|
||||
)
|
||||
self.assertEqual("delete", changes[0].kind)
|
||||
self.assertIsNone(changes[0].document)
|
||||
|
||||
def test_published_reader_does_not_depend_on_unavailable_draft_and_editor_still_validates_it(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(1)
|
||||
with Session(self.engine) as session:
|
||||
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
||||
draft = session.get(
|
||||
SemanticDocumentationRevision, entry.current_revision_id
|
||||
)
|
||||
draft.tenant_id = "tenant-other"
|
||||
session.commit()
|
||||
self.assertEqual(
|
||||
"Published 0",
|
||||
semantic_entry_payload(
|
||||
session, self.registry, self.principal, entry=entry, editor=False
|
||||
)["content"]["title"],
|
||||
)
|
||||
with self.assertRaises(SemanticDocumentationError):
|
||||
current_revision(session, entry)
|
||||
|
||||
def test_unpublished_revision_pointer_cannot_promote_a_draft_into_reading_or_search(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(1)
|
||||
with Session(self.engine) as session:
|
||||
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
||||
entry.published_revision_id = entry.current_revision_id
|
||||
session.commit()
|
||||
revisions = prefetch_semantic_revisions(
|
||||
session, self.principal, entries=(entry,), editor=False
|
||||
)
|
||||
self.assertEqual({}, revisions)
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
self.principal,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
revisions=revisions,
|
||||
)
|
||||
)
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session, self.registry, self.principal, entry=entry, editor=False
|
||||
)
|
||||
)
|
||||
page = SemanticDocumentationSearchSource(self.registry).backfill(
|
||||
session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
rebuild_id="fixture",
|
||||
limit=100,
|
||||
),
|
||||
)
|
||||
self.assertEqual((), page.documents)
|
||||
changes = SemanticDocumentationSearchSource(
|
||||
self.registry
|
||||
).index_changes_for_event(
|
||||
session,
|
||||
delivery_key="draft-fixture",
|
||||
event=PlatformEvent(
|
||||
type="docs.semantic.published",
|
||||
module_id="docs",
|
||||
tenant=EventTenantRef(id="tenant-1"),
|
||||
resource=EventObjectRef(type=RESOURCE_TYPE, id=entry.id),
|
||||
),
|
||||
)
|
||||
self.assertEqual("delete", changes[0].kind)
|
||||
self.assertIsNone(changes[0].document)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -16,7 +16,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -166,6 +166,8 @@ export type DocsDocumentationTopic = {
|
||||
i18n_key: string;
|
||||
locale: string;
|
||||
translation_locale: string;
|
||||
structured_translation_locale: string;
|
||||
structured_translation_version?: string | null;
|
||||
version: {
|
||||
resolved: string;
|
||||
minimum?: string | null;
|
||||
@@ -176,6 +178,7 @@ export type DocsDocumentationTopic = {
|
||||
conditions: DocsDocumentationCondition[];
|
||||
links: DocsDocumentationLink[];
|
||||
related_modules: string[];
|
||||
area_module_ids?: string[];
|
||||
unlocks: string[];
|
||||
configuration_keys: string[];
|
||||
configuration_states: Array<{
|
||||
|
||||
@@ -8,9 +8,15 @@ import {
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
ExplorerTree,
|
||||
FilterBar,
|
||||
FormField,
|
||||
MultiSelectFilter,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
SegmentedControl,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatusBadge,
|
||||
WorkspaceLayout,
|
||||
adminErrorMessage,
|
||||
@@ -30,6 +36,7 @@ import {
|
||||
type DocsSource,
|
||||
type DocsSourceDetail
|
||||
} from "../../api/docs";
|
||||
import { ancestorOccurrenceIds, documentationTagOptions, documentationTags, matchesDocumentationTopic, qualifyTreeOccurrences, selectedTreeOccurrence, topicAreaIds } from "./docsDiscovery";
|
||||
|
||||
type DocumentationType = "admin" | "user";
|
||||
|
||||
@@ -68,13 +75,15 @@ type OutlineItem = {
|
||||
export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
const location = useLocation();
|
||||
const navigate = useGuardedNavigate();
|
||||
const { language } = usePlatformLanguage();
|
||||
const { language, translateText } = usePlatformLanguage();
|
||||
const [context, setContext] = useState<DocsContext | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const loadSequence = useRef(0);
|
||||
const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search));
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set());
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[] | null>(null);
|
||||
const locale = localeFromSearch(location.search) ?? language;
|
||||
const selectedVersion = versionFromSearch(location.search);
|
||||
const adminDocs = documentationType === "admin";
|
||||
@@ -109,6 +118,12 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
const treeNodes = useMemo(() => docsTreeNodes(context, adminDocs), [context, adminDocs]);
|
||||
const pages = useMemo(() => flattenTreePages(treeNodes), [treeNodes]);
|
||||
const selectedPage = selectedPageFromSearch(location.search, pages);
|
||||
const selectedNode = selectedTreeOccurrence(treeNodes, selectedPage?.id ?? "", new URLSearchParams(location.search).get("occurrence"));
|
||||
const allTopics = useMemo(() => allDocumentationTopics(context), [context]);
|
||||
const tagOptions = useMemo(() => documentationTagOptions(allTopics, context), [allTopics, context]);
|
||||
const matchingTopics = useMemo(() => allTopics.filter((topic) => matchesDocumentationTopic(topic, context, searchQuery, selectedTags)), [allTopics, context, searchQuery, selectedTags]);
|
||||
const filtering = Boolean(searchQuery.trim()) || selectedTags !== null;
|
||||
const visibleTreeNodes = useMemo(() => filtering ? filterTopicTree(treeNodes, new Set(matchingTopics.map((topic) => topic.id))) : treeNodes, [filtering, treeNodes, matchingTopics]);
|
||||
const topicById = useMemo(() => topicIndex(context), [context]);
|
||||
const visibleRoutes = context?.layers.configured.routes ?? [];
|
||||
const availableRoutes = context?.layers.available.routes ?? [];
|
||||
@@ -122,7 +137,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPage) return;
|
||||
const ancestorIds = ancestorNodeIdsForPage(treeNodes, selectedPage.id);
|
||||
const ancestorIds = ancestorOccurrenceIds(treeNodes, selectedNode?.id ?? "");
|
||||
if (!ancestorIds.length) return;
|
||||
setExpandedNodes((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -131,7 +146,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [treeNodes, selectedPage?.id]);
|
||||
}, [treeNodes, selectedNode?.id]);
|
||||
|
||||
return (
|
||||
<WorkspaceLayout
|
||||
@@ -165,14 +180,24 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<FilterBar layout="stack">
|
||||
<FormField label="i18n:govoplan-docs.search_topics">
|
||||
<input type="search" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder={translateText("i18n:govoplan-docs.search_topics_hint")} />
|
||||
</FormField>
|
||||
<MultiSelectFilter label="i18n:govoplan-docs.topic_tags" options={tagOptions} value={selectedTags} onChange={setSelectedTags} />
|
||||
{filtering && <>
|
||||
<span role="status" className="muted">{translateText("i18n:govoplan-docs.matching_topics").replace("{count}", String(matchingTopics.length))}</span>
|
||||
<Button onClick={() => { setSearchQuery(""); setSelectedTags(null); }}>{translateText("i18n:govoplan-docs.clear_topic_filters")}</Button>
|
||||
</>}
|
||||
</FilterBar>
|
||||
</div>
|
||||
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
||||
<ExplorerTree
|
||||
nodes={treeNodes}
|
||||
nodes={visibleTreeNodes}
|
||||
getNodeId={(node) => node.id}
|
||||
getNodeLabel={(node) => node.title}
|
||||
getNodeChildren={(node) => node.children}
|
||||
activeId={activeNodeIdForPage(treeNodes, selectedPage?.id ?? "")}
|
||||
activeId={selectedNode?.id ?? ""}
|
||||
expandedIds={expandedNodes}
|
||||
depth={0}
|
||||
childrenBaseClassName="docs-tree-children"
|
||||
@@ -181,11 +206,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
toggleBaseClassName="docs-tree-toggle"
|
||||
nodeButtonBaseClassName="docs-tree-page"
|
||||
getNodeWrapStyle={(_node, context) => ({ paddingLeft: `${context.depth * 14}px` })}
|
||||
getNodeButtonClassName={(node) => selectedPage?.id === node.page.id ? "is-active" : ""}
|
||||
getNodeButtonClassName={(node) => selectedNode?.id === node.id ? "is-active" : ""}
|
||||
renderToggleIcon={(_node, context) => context.hasChildren ? context.expanded ? <ChevronDown size={15} /> : <ChevronRight size={15} /> : <span className="docs-tree-toggle-placeholder" />}
|
||||
renderNodeContent={(node) => node.title}
|
||||
onToggle={(node) => toggleNode(node.id)}
|
||||
onOpen={(node) => selectPage(node.page)}
|
||||
onOpen={(node) => { selectPage(node.page, node.id); setSearchQuery(""); setSelectedTags(null); }}
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -205,7 +230,16 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
>
|
||||
<div className="docs-content">
|
||||
<main className="docs-page-main">
|
||||
<SelectedPageContent
|
||||
{filtering && <section aria-label={translateText("i18n:govoplan-docs.search_results")}>
|
||||
<h2>{translateText("i18n:govoplan-docs.search_results")}</h2>
|
||||
{matchingTopics.length ? <SelectionList label="i18n:govoplan-docs.search_results">{matchingTopics.map((topic) => <SelectionListItem
|
||||
key={topic.id} aria-label={topic.title} selected={selectedPage?.id === topic.id}
|
||||
onClick={() => { selectPage(topicPage(topic)); setSearchQuery(""); setSelectedTags(null); }}
|
||||
>
|
||||
<SelectionListItemContent title={topic.title} description={[topic.summary, documentationTags(topic, context).map((tag) => tag.label).join(" · ")].filter(Boolean).join(" — ")} />
|
||||
</SelectionListItem>)}</SelectionList> : <p className="muted">{translateText("i18n:govoplan-docs.no_matching_topics")}</p>}
|
||||
</section>}
|
||||
{!filtering && <SelectedPageContent
|
||||
page={selectedPage}
|
||||
adminDocs={adminDocs}
|
||||
documentationType={documentationType}
|
||||
@@ -218,9 +252,9 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
evidenceSources={context?.layers.evidence.sources ?? []}
|
||||
settings={settings}
|
||||
locale={locale}
|
||||
/>
|
||||
/>}
|
||||
</main>
|
||||
<PageOutline items={outlineItems} />
|
||||
{!filtering && <PageOutline items={outlineItems} />}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</WorkspaceLayout>
|
||||
@@ -239,9 +273,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||
}
|
||||
|
||||
function selectPage(page: DocsPageNode) {
|
||||
function selectPage(page: DocsPageNode, occurrenceId?: string) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("topic", page.id);
|
||||
if (occurrenceId) params.set("occurrence", occurrenceId);
|
||||
else params.delete("occurrence");
|
||||
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||
}
|
||||
|
||||
@@ -629,8 +665,6 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
|
||||
const workflowTopics = context?.topic_groups.workflow ?? [];
|
||||
const referenceTopics = context?.topic_groups.reference ?? [];
|
||||
const patternTopics = context?.topic_groups.pattern ?? [];
|
||||
const systemTopicIds = new Set(systemTopics.map((topic) => topic.id));
|
||||
const moduleTopics = allTopics.filter((topic) => !systemTopicIds.has(topic.id));
|
||||
const workingTopics = uniqueTopics([...patternTopics, ...workflowTopics]);
|
||||
const troubleshootingTopics = allTopics.filter((topic) => !topic.active || topic.layer === "evidence");
|
||||
const basicsNode = categoryTreeNode(
|
||||
@@ -672,15 +706,15 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
|
||||
),
|
||||
{
|
||||
id: "tree:modules",
|
||||
title: "i18n:govoplan-docs.modules.04e9462c",
|
||||
title: "i18n:govoplan-docs.topic_areas",
|
||||
page: {
|
||||
id: "modules:overview",
|
||||
title: "i18n:govoplan-docs.modules.04e9462c",
|
||||
title: "i18n:govoplan-docs.topic_areas",
|
||||
kind: "topic-list",
|
||||
topics: moduleTopics,
|
||||
topics: allTopics,
|
||||
emptyText: "i18n:govoplan-docs.no_module_topics_found.0cbbdc9b"
|
||||
},
|
||||
children: moduleTreeNodes(moduleTopics, context)
|
||||
children: moduleTreeNodes(allTopics, context)
|
||||
}
|
||||
];
|
||||
if (adminDocs) {
|
||||
@@ -695,7 +729,7 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
|
||||
emptyText: "i18n:govoplan-docs.no_administration_topics_found.6213dff2"
|
||||
},
|
||||
children: [
|
||||
...referenceTopics.map(topicTreeNode),
|
||||
...referenceTopics.map((topic) => topicTreeNode(topic)),
|
||||
{
|
||||
id: "tree:administration:technical-reference",
|
||||
title: "i18n:govoplan-docs.technical_reference.f271430d",
|
||||
@@ -717,7 +751,7 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
|
||||
"i18n:govoplan-docs.no_troubleshooting_topics_found.8c275468"
|
||||
));
|
||||
}
|
||||
return nodes;
|
||||
return qualifyTreeOccurrences(nodes);
|
||||
}
|
||||
|
||||
function categoryTreeNode(id: string, title: string, topics: DocsDocumentationTopic[], emptyText: string): DocsTreeNode {
|
||||
@@ -757,9 +791,11 @@ function topicTreeNode(topic: DocsDocumentationTopic): DocsTreeNode {
|
||||
function moduleTreeNodes(topics: DocsDocumentationTopic[], context: DocsContext | null): DocsTreeNode[] {
|
||||
const topicsByModule = new Map<string, DocsDocumentationTopic[]>();
|
||||
for (const topic of topics) {
|
||||
const moduleTopics = topicsByModule.get(topic.source_module_id) ?? [];
|
||||
for (const areaId of topicAreaIds(topic, context)) {
|
||||
const moduleTopics = topicsByModule.get(areaId) ?? [];
|
||||
moduleTopics.push(topic);
|
||||
topicsByModule.set(topic.source_module_id, moduleTopics);
|
||||
topicsByModule.set(areaId, moduleTopics);
|
||||
}
|
||||
}
|
||||
const configuredModules = context?.layers.configured.modules ?? [];
|
||||
const moduleOrder = new Map(configuredModules.map((module, index) => [module.id, index]));
|
||||
@@ -823,23 +859,12 @@ function defaultExpandedNodeIds(nodes: DocsTreeNode[]): string[] {
|
||||
return nodes.filter((node) => node.children.length).map((node) => node.id);
|
||||
}
|
||||
|
||||
function ancestorNodeIdsForPage(nodes: DocsTreeNode[], pageId: string, ancestors: string[] = []): string[] {
|
||||
for (const node of nodes) {
|
||||
if (node.page.id === pageId) return ancestors;
|
||||
const match = ancestorNodeIdsForPage(node.children, pageId, [...ancestors, node.id]);
|
||||
if (match.length) return match;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function activeNodeIdForPage(nodes: DocsTreeNode[], pageId: string): string {
|
||||
if (!pageId) return "";
|
||||
for (const node of nodes) {
|
||||
if (node.page.id === pageId) return node.id;
|
||||
const childId = activeNodeIdForPage(node.children, pageId);
|
||||
if (childId) return childId;
|
||||
}
|
||||
return "";
|
||||
function filterTopicTree(nodes: DocsTreeNode[], matchingIds: Set<string>): DocsTreeNode[] {
|
||||
return nodes.flatMap((node) => {
|
||||
const children = filterTopicTree(node.children, matchingIds);
|
||||
const matches = node.page.kind === "topic" && matchingIds.has(node.page.topic.id);
|
||||
return matches || children.length ? [{ ...node, children }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function allDocumentationTopics(context: DocsContext | null): DocsDocumentationTopic[] {
|
||||
@@ -1140,7 +1165,7 @@ function compactSourceRecord(value: object): Array<[string, unknown]> {
|
||||
}
|
||||
|
||||
function humanizeSourceKey(value: string): string {
|
||||
const words = value.replaceAll("_", " ");
|
||||
const words = value.replace(/_/g, " ");
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ export default function SemanticDocumentationPage({ settings }: { settings: ApiS
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void load(), loading }}
|
||||
primaryActions={selectedEntry?.lifecycle_state === "draft" && !dirty ? (
|
||||
<Button onClick={() => void transition("publish")}>
|
||||
<Button helpContextId="docs.semantic-documentation.publish" helpModuleId="docs" onClick={() => void transition("publish")}>
|
||||
<Check size={16} /> Publish
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { DocsContext, DocsDocumentationTopic } from "../../api/docs";
|
||||
|
||||
export type DocumentationTag = { value: string; label: string };
|
||||
|
||||
export function topicAreaIds(topic: DocsDocumentationTopic, context: DocsContext | null): string[] {
|
||||
const visibleModules = new Set(context?.layers.configured.modules.map((module) => module.id) ?? []);
|
||||
return [...new Set(topic.area_module_ids ?? [
|
||||
topic.source_module_id,
|
||||
...topic.related_modules.filter((id) => visibleModules.has(id))
|
||||
])];
|
||||
}
|
||||
|
||||
export function documentationTags(topic: DocsDocumentationTopic, context: DocsContext | null): DocumentationTag[] {
|
||||
const modules = new Map(context?.layers.configured.modules.map((module) => [module.id, module.name]) ?? []);
|
||||
const areas = topicAreaIds(topic, context).map((id) => ({ value: `area:${id}`, label: modules.get(id) ?? humanizeArea(id) }));
|
||||
const tags = Array.isArray(topic.metadata.tags) ? topic.metadata.tags : [];
|
||||
return [...areas, ...tags.filter((tag): tag is string => typeof tag === "string" && Boolean(tag.trim()))
|
||||
.map((tag) => ({ value: `tag:${normalizeSearch(tag)}`, label: tag.trim() }))]
|
||||
.filter((tag, index, all) => all.findIndex((other) => other.value === tag.value) === index);
|
||||
}
|
||||
|
||||
export function documentationTagOptions(topics: DocsDocumentationTopic[], context: DocsContext | null): DocumentationTag[] {
|
||||
const tags = new Map<string, DocumentationTag>();
|
||||
for (const topic of topics) for (const tag of documentationTags(topic, context)) tags.set(tag.value, tag);
|
||||
return [...tags.values()].sort((left, right) => left.label.localeCompare(right.label));
|
||||
}
|
||||
|
||||
export function matchesDocumentationTopic(
|
||||
topic: DocsDocumentationTopic, context: DocsContext | null, query: string, selectedTags: string[] | null
|
||||
): boolean {
|
||||
const tags = documentationTags(topic, context);
|
||||
if (selectedTags !== null && !tags.some((tag) => selectedTags.includes(tag.value))) return false;
|
||||
const content = normalizeSearch([topic.title, topic.summary, topic.body, ...tags.flatMap((tag) => [tag.label, tag.value])].join(" "));
|
||||
return normalizeSearch(query).split(/\s+/).filter(Boolean).every((word) => content.includes(word));
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string): string {
|
||||
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function humanizeArea(value: string): string {
|
||||
return value.replace(/^govoplan[-_]/, "").split(/[-_]+/).filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
||||
}
|
||||
|
||||
type TreeNode<T> = { id: string; page: { id: string }; children: T[] };
|
||||
|
||||
/** Semantic topic IDs remain stable; navigation IDs identify one occurrence only. */
|
||||
export function qualifyTreeOccurrences<T extends TreeNode<T>>(nodes: T[], parentId = ""): T[] {
|
||||
return nodes.map((node) => {
|
||||
const id = `${parentId}/${encodeURIComponent(node.id)}`;
|
||||
return { ...node, id, children: qualifyTreeOccurrences(node.children, id) };
|
||||
});
|
||||
}
|
||||
|
||||
export function selectedTreeOccurrence<T extends TreeNode<T>>(nodes: T[], pageId: string, occurrenceId: string | null): T | null {
|
||||
const all = flattenTreeOccurrences(nodes);
|
||||
return all.find((node) => node.id === occurrenceId && node.page.id === pageId)
|
||||
?? all.find((node) => node.page.id === pageId) ?? null;
|
||||
}
|
||||
|
||||
export function flattenTreeOccurrences<T extends TreeNode<T>>(nodes: T[]): T[] {
|
||||
return nodes.flatMap((node) => [node, ...flattenTreeOccurrences(node.children)]);
|
||||
}
|
||||
|
||||
export function ancestorOccurrenceIds<T extends TreeNode<T>>(nodes: T[], occurrenceId: string, ancestors: string[] = []): string[] {
|
||||
for (const node of nodes) {
|
||||
if (node.id === occurrenceId) return ancestors;
|
||||
const found = ancestorOccurrenceIds(node.children, occurrenceId, [...ancestors, node.id]);
|
||||
if (found.length) return found;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -2,6 +2,14 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-docs.search_topics": "Search help topics",
|
||||
"i18n:govoplan-docs.search_topics_hint": "Question, keyword or tag…",
|
||||
"i18n:govoplan-docs.topic_tags": "Areas and tags",
|
||||
"i18n:govoplan-docs.topic_areas": "Topics by area",
|
||||
"i18n:govoplan-docs.matching_topics": "{count} matching topics",
|
||||
"i18n:govoplan-docs.clear_topic_filters": "Clear filters",
|
||||
"i18n:govoplan-docs.search_results": "Matching help topics",
|
||||
"i18n:govoplan-docs.no_matching_topics": "No topics match. Try another keyword or clear the tag filter.",
|
||||
"i18n:govoplan-docs.additional_permissions.8042cb01": "additional permissions",
|
||||
"i18n:govoplan-docs.about_govoplan.6b2d7127": "About GovOPlaN",
|
||||
"i18n:govoplan-docs.admin_docs.bf504a56": "Admin docs",
|
||||
@@ -104,6 +112,14 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-docs.your_documentation.8a4cd9a3": "Your documentation"
|
||||
},
|
||||
"de": {
|
||||
"i18n:govoplan-docs.search_topics": "Hilfethemen suchen",
|
||||
"i18n:govoplan-docs.search_topics_hint": "Frage, Stichwort oder Schlagwort…",
|
||||
"i18n:govoplan-docs.topic_tags": "Bereiche und Schlagwörter",
|
||||
"i18n:govoplan-docs.topic_areas": "Themen nach Bereich",
|
||||
"i18n:govoplan-docs.matching_topics": "{count} passende Themen",
|
||||
"i18n:govoplan-docs.clear_topic_filters": "Filter zurücksetzen",
|
||||
"i18n:govoplan-docs.search_results": "Passende Hilfethemen",
|
||||
"i18n:govoplan-docs.no_matching_topics": "Keine passenden Themen. Versuchen Sie ein anderes Stichwort oder setzen Sie den Schlagwortfilter zurück.",
|
||||
"i18n:govoplan-docs.additional_permissions.8042cb01": "additional permissions",
|
||||
"i18n:govoplan-docs.about_govoplan.6b2d7127": "Über GovOPlaN",
|
||||
"i18n:govoplan-docs.admin_docs.bf504a56": "Administrationsdokumentation",
|
||||
|
||||
Reference in New Issue
Block a user