Release govoplan-docs v0.1.23: unify help discovery and batch semantic reads
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-09-08 01:32:36 +02:00
parent 1ae23b4e9d
commit cbe434de02
18 changed files with 842 additions and 81 deletions
+43
View File
@@ -3,6 +3,49 @@
The docs module renders documentation for the current GovOPlaN instance instead The docs module renders documentation for the current GovOPlaN instance instead
of showing a static product manual. 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 ## Inputs
The documentation context is built from: The documentation context is built from:
+15
View File
@@ -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`. 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 ## 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. 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
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.22", "version": "0.1.23",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -17,7 +17,7 @@
"README.md" "README.md"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-docs" name = "govoplan-docs"
version = "0.1.22" version = "0.1.23"
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation." description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.37", "govoplan-core>=0.1.45",
"govoplan-access>=0.1.18", "govoplan-access>=0.1.18",
] ]
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.22" __version__ = "0.1.23"
@@ -40,6 +40,7 @@ from govoplan_docs.backend.sources import (
) )
from govoplan_docs.backend.semantic_service import ( from govoplan_docs.backend.semantic_service import (
list_semantic_entries, list_semantic_entries,
prefetch_semantic_revisions,
select_locale_entries, select_locale_entries,
semantic_entry_payload, semantic_entry_payload,
) )
@@ -830,6 +831,11 @@ def _classify_documentation(
) -> dict[str, list[dict[str, Any]]]: ) -> dict[str, list[dict[str, Any]]]:
layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []} layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []}
installed = {manifest.id for manifest in registry.manifests()} 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([ visible_runtime_paths = frozenset([
"/settings", # Authenticated shell route, not contributed by a module manifest. "/settings", # Authenticated shell route, not contributed by a module manifest.
*( *(
@@ -909,6 +915,7 @@ def _classify_documentation(
visible_runtime_paths=visible_runtime_paths, visible_runtime_paths=visible_runtime_paths,
configuration=configuration, configuration=configuration,
resolved_version=resolved_version, resolved_version=resolved_version,
visible_area_modules=visible_area_modules,
)) ))
return layers return layers
@@ -1011,6 +1018,7 @@ def _semantic_documentation_topics(
list_semantic_entries(session, principal), list_semantic_entries(session, principal),
locale=locale, locale=locale,
) )
revisions = prefetch_semantic_revisions(session, principal, entries=entries, editor=False)
topics: list[DocumentationTopic] = [] topics: list[DocumentationTopic] = []
for entry in entries: for entry in entries:
payload = semantic_entry_payload( payload = semantic_entry_payload(
@@ -1020,6 +1028,7 @@ def _semantic_documentation_topics(
entry=entry, entry=entry,
editor=False, editor=False,
requested_locale=locale, requested_locale=locale,
revisions=revisions,
) )
if payload is None: if payload is None:
continue continue
@@ -1430,6 +1439,7 @@ def _documentation_topic_payload(
visible_runtime_paths: frozenset[str], visible_runtime_paths: frozenset[str],
configuration: Mapping[str, DocumentationConfigurationDecision], configuration: Mapping[str, DocumentationConfigurationDecision],
resolved_version: str, resolved_version: str,
visible_area_modules: frozenset[str] = frozenset(),
) -> dict[str, Any]: ) -> dict[str, Any]:
module_id = topic.source_module_id or source_module_id module_id = topic.source_module_id or source_module_id
translation_locale, translation = _translation_for_locale(topic, locale) translation_locale, translation = _translation_for_locale(topic, locale)
@@ -1484,6 +1494,16 @@ def _documentation_topic_payload(
"conditions": [_documentation_condition_payload(condition) for condition in topic.conditions], "conditions": [_documentation_condition_payload(condition) for condition in topic.conditions],
"links": [_documentation_link_payload(link) for link in topic.links], "links": [_documentation_link_payload(link) for link in topic.links],
"related_modules": list(topic.related_modules), "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), "unlocks": list(topic.unlocks),
"configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}), "configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}),
"configuration_states": [ "configuration_states": [
@@ -1497,6 +1517,7 @@ def _documentation_topic_payload(
return { return {
"id": payload["id"], "id": payload["id"],
"source_module_id": payload["source_module_id"], "source_module_id": payload["source_module_id"],
"area_module_ids": payload["area_module_ids"],
"kind": payload["kind"], "kind": payload["kind"],
"anchor_id": payload["anchor_id"], "anchor_id": payload["anchor_id"],
"title": payload["title"], "title": payload["title"],
@@ -1555,6 +1576,9 @@ def _user_topic_metadata(kind: str, metadata: Mapping[str, Any]) -> dict[str, An
value = _bounded_string(metadata.get(key), maximum=255) value = _bounded_string(metadata.get(key), maximum=255)
if value: if value:
projected[key] = 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): if kind == "reference" and isinstance(metadata.get("fields"), list):
fields = [_user_field_metadata(item) for item in metadata["fields"][:64] if isinstance(item, Mapping)] fields = [_user_field_metadata(item) for item in metadata["fields"][:64] if isinstance(item, Mapping)]
if fields: if fields:
@@ -43,6 +43,7 @@ from govoplan_docs.backend.semantic_service import (
create_semantic_entry, create_semantic_entry,
get_semantic_entry, get_semantic_entry,
list_semantic_entries, list_semantic_entries,
prefetch_semantic_revisions,
publication_policy, publication_policy,
publish_semantic_entry, publish_semantic_entry,
retire_semantic_entry, retire_semantic_entry,
@@ -167,6 +168,7 @@ def list_entries(
subject_kind=subject_kind, subject_kind=subject_kind,
) )
selected = entries if editor else select_locale_entries(entries, locale=locale) selected = entries if editor else select_locale_entries(entries, locale=locale)
revisions = prefetch_semantic_revisions(session, principal, entries=selected, editor=editor)
items = [ items = [
item item
for entry in selected for entry in selected
@@ -178,6 +180,7 @@ def list_entries(
entry=entry, entry=entry,
editor=editor, editor=editor,
requested_locale=locale, requested_locale=locale,
revisions=revisions,
) )
) )
is not None is not None
+47 -1
View File
@@ -131,7 +131,7 @@ def _dsar_provider(_context: ModuleContext) -> DocsDsarProvider:
manifest = ModuleManifest( manifest = ModuleManifest(
id="docs", id="docs",
name="Docs", name="Docs",
version="0.1.22", version="0.1.23",
required_capabilities=( required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -302,6 +302,46 @@ manifest = ModuleManifest(
), ),
), ),
documentation=( 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( DocumentationTopic(
id="docs.semantic-documentation", id="docs.semantic-documentation",
title="Tenant semantic documentation", title="Tenant semantic documentation",
@@ -312,6 +352,7 @@ manifest = ModuleManifest(
"Published content remains subject to the subject's current authorization, the documentation audience and classification, tenant isolation, and locale selection. " "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. " "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", layer="always",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -352,6 +393,11 @@ manifest = ModuleManifest(
"werden ausdrücklich gekennzeichnet; Direktlinks, Kontexthilfe, Suche, Zwischenspeicher und Mandantenexporte wenden dieselbe " "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 " "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. " "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."
), ),
} }
}, },
+47 -14
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from urllib.parse import quote from urllib.parse import quote
from sqlalchemy import func, select from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal from govoplan_core.auth import ApiPrincipal
@@ -23,7 +23,11 @@ from govoplan_docs.backend.db.models import (
SemanticDocumentationEntry, SemanticDocumentationEntry,
SemanticDocumentationRevision, 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" PROVIDER_ID = "docs.semantic_documentation"
@@ -58,8 +62,12 @@ class SemanticDocumentationSearchSource:
select(SemanticDocumentationEntry, SemanticDocumentationRevision) select(SemanticDocumentationEntry, SemanticDocumentationRevision)
.join( .join(
SemanticDocumentationRevision, SemanticDocumentationRevision,
SemanticDocumentationRevision.id and_(
== SemanticDocumentationEntry.published_revision_id, SemanticDocumentationRevision.id == SemanticDocumentationEntry.published_revision_id,
SemanticDocumentationRevision.entry_id == SemanticDocumentationEntry.id,
SemanticDocumentationRevision.tenant_id == SemanticDocumentationEntry.tenant_id,
SemanticDocumentationRevision.lifecycle_state == "published",
),
) )
.where( .where(
SemanticDocumentationEntry.tenant_id == request.tenant_id, SemanticDocumentationEntry.tenant_id == request.tenant_id,
@@ -105,15 +113,32 @@ class SemanticDocumentationSearchSource:
if not isinstance(principal, ApiPrincipal) or not principal.has(DOCS_READ_SCOPE): if not isinstance(principal, ApiPrincipal) or not principal.has(DOCS_READ_SCOPE):
return decisions return decisions
db = _session(session) db = _session(session)
for item in requests: valid_requests = [
reference = item.reference item
for item in requests
if ( if (
reference.tenant_id != principal.tenant_id item.reference.tenant_id == principal.tenant_id
or reference.module_id != "docs" and item.reference.module_id == "docs"
or reference.resource_type != RESOURCE_TYPE and item.reference.resource_type == RESOURCE_TYPE
): )
continue ]
entry = db.get(SemanticDocumentationEntry, reference.resource_id) 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 ( if (
entry is None entry is None
or entry.tenant_id != principal.tenant_id or entry.tenant_id != principal.tenant_id
@@ -127,6 +152,7 @@ class SemanticDocumentationSearchSource:
principal, principal,
entry=entry, entry=entry,
editor=False, editor=False,
revisions=revisions,
) )
decisions[reference.key] = bool( decisions[reference.key] = bool(
payload payload
@@ -153,8 +179,12 @@ class SemanticDocumentationSearchSource:
db = _session(session) db = _session(session)
entry = db.get(SemanticDocumentationEntry, event.resource.id) entry = db.get(SemanticDocumentationEntry, event.resource.id)
revision = ( revision = (
db.get(SemanticDocumentationRevision, entry.published_revision_id) published_revision(db, entry)
if entry is not None and entry.published_revision_id if (
entry is not None
and entry.tenant_id == event.tenant.id
and entry.lifecycle_state in {"draft", "published"}
)
else None else None
) )
visible = bool( visible = bool(
@@ -162,6 +192,9 @@ class SemanticDocumentationSearchSource:
and entry.tenant_id == event.tenant.id and entry.tenant_id == event.tenant.id
and entry.lifecycle_state in {"draft", "published"} and entry.lifecycle_state in {"draft", "published"}
and revision is not None 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 cursor = event.event_id
document = _document(entry, revision, change_cursor=cursor) if visible else None document = _document(entry, revision, change_cursor=cursor) if visible else None
+71 -14
View File
@@ -396,13 +396,21 @@ def semantic_entry_history(
def current_revision( def current_revision(
session: Session, session: Session,
entry: SemanticDocumentationEntry, entry: SemanticDocumentationEntry,
*,
revisions: Mapping[str, SemanticDocumentationRevision] | None = None,
) -> SemanticDocumentationRevision: ) -> SemanticDocumentationRevision:
revision = None
if entry.current_revision_id:
revision = ( revision = (
session.get(SemanticDocumentationRevision, entry.current_revision_id) revisions.get(entry.current_revision_id)
if entry.current_revision_id if revisions is not None
else 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( raise SemanticDocumentationError(
"Semantic documentation current revision is unavailable." "Semantic documentation current revision is unavailable."
) )
@@ -412,14 +420,60 @@ def current_revision(
def published_revision( def published_revision(
session: Session, session: Session,
entry: SemanticDocumentationEntry, entry: SemanticDocumentationEntry,
*,
revisions: Mapping[str, SemanticDocumentationRevision] | None = None,
) -> SemanticDocumentationRevision | None: ) -> SemanticDocumentationRevision | None:
if not entry.published_revision_id: if not entry.published_revision_id:
return None return None
revision = session.get( revision = (
SemanticDocumentationRevision, revisions.get(entry.published_revision_id)
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( def entry_subject_reference(
@@ -487,12 +541,16 @@ def semantic_entry_payload(
entry: SemanticDocumentationEntry, entry: SemanticDocumentationEntry,
editor: bool, editor: bool,
requested_locale: str | None = None, requested_locale: str | None = None,
revisions: Mapping[str, SemanticDocumentationRevision] | None = None,
) -> dict[str, object] | None: ) -> dict[str, object] | None:
current = current_revision(session, entry) if entry.tenant_id != _principal_tenant_id(principal):
published = published_revision(session, entry) return None
selected = current if editor else published published = published_revision(session, entry, revisions=revisions)
selected = current_revision(session, entry, revisions=revisions) if editor else published
if selected is None: if selected is None:
return None return None
if not content_visible_to_principal(selected.content, principal):
return None
resolution = resolve_entry_subject( resolution = resolve_entry_subject(
session, session,
registry, registry,
@@ -505,8 +563,6 @@ def semantic_entry_payload(
unavailable = resolution.availability == "temporarily_unavailable" unavailable = resolution.availability == "temporarily_unavailable"
if unavailable and not editor: if unavailable and not editor:
return None return None
if not content_visible_to_principal(selected.content, principal):
return None
subject = resolution.subject subject = resolution.subject
required_scopes = subject.required_scopes if subject is not None else () required_scopes = subject.required_scopes if subject is not None else ()
if any(not _principal_has(principal, scope) for scope in required_scopes): 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, "lifecycle_state": entry.lifecycle_state,
"effective_state": selected.lifecycle_state, "effective_state": selected.lifecycle_state,
"pending_draft": bool( "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, "current_revision": entry.current_revision,
"selected_revision": selected.revision, "selected_revision": selected.revision,
@@ -965,6 +1021,7 @@ __all__ = [
"entry_subject_reference", "entry_subject_reference",
"get_semantic_entry", "get_semantic_entry",
"list_semantic_entries", "list_semantic_entries",
"prefetch_semantic_revisions",
"publication_policy", "publication_policy",
"publish_semantic_entry", "publish_semantic_entry",
"published_revision", "published_revision",
+28
View File
@@ -52,6 +52,34 @@ class FakePrincipal:
class DocsContextTests(unittest.TestCase): 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: def test_structured_topic_metadata_uses_requested_locale(self) -> None:
registry = PlatformRegistry() registry = PlatformRegistry()
registry.register( registry.register(
+1 -1
View File
@@ -24,7 +24,7 @@ class PublicDocumentationExportTests(unittest.TestCase):
(ManifestSource(get_docs_manifest()),) (ManifestSource(get_docs_manifest()),)
)["modules"][0] )["modules"][0]
self.assertEqual(9, module["coverage"]["topic_count"]) self.assertEqual(10, module["coverage"]["topic_count"])
self.assertEqual(0, module["coverage"]["missing_german_topic_count"]) self.assertEqual(0, module["coverage"]["missing_german_topic_count"])
self.assertEqual([], module["coverage"]["missing_german_topic_ids"]) self.assertEqual([], module["coverage"]["missing_german_topic_ids"])
self.assertEqual(2, module["coverage"]["structured_localizable_topic_count"]) self.assertEqual(2, module["coverage"]["structured_localizable_topic_count"])
+397
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.22", "version": "0.1.23",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -16,7 +16,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+1
View File
@@ -178,6 +178,7 @@ export type DocsDocumentationTopic = {
conditions: DocsDocumentationCondition[]; conditions: DocsDocumentationCondition[];
links: DocsDocumentationLink[]; links: DocsDocumentationLink[];
related_modules: string[]; related_modules: string[];
area_module_ids?: string[];
unlocks: string[]; unlocks: string[];
configuration_keys: string[]; configuration_keys: string[];
configuration_states: Array<{ configuration_states: Array<{
+64 -39
View File
@@ -8,9 +8,15 @@ import {
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
ExplorerTree, ExplorerTree,
FilterBar,
FormField,
MultiSelectFilter,
PageActionBar, PageActionBar,
PageLayout, PageLayout,
SegmentedControl, SegmentedControl,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatusBadge, StatusBadge,
WorkspaceLayout, WorkspaceLayout,
adminErrorMessage, adminErrorMessage,
@@ -30,6 +36,7 @@ import {
type DocsSource, type DocsSource,
type DocsSourceDetail type DocsSourceDetail
} from "../../api/docs"; } from "../../api/docs";
import { ancestorOccurrenceIds, documentationTagOptions, documentationTags, matchesDocumentationTopic, qualifyTreeOccurrences, selectedTreeOccurrence, topicAreaIds } from "./docsDiscovery";
type DocumentationType = "admin" | "user"; type DocumentationType = "admin" | "user";
@@ -68,13 +75,15 @@ type OutlineItem = {
export default function DocsPage({ settings }: { settings: ApiSettings }) { export default function DocsPage({ settings }: { settings: ApiSettings }) {
const location = useLocation(); const location = useLocation();
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const { language } = usePlatformLanguage(); const { language, translateText } = usePlatformLanguage();
const [context, setContext] = useState<DocsContext | null>(null); const [context, setContext] = useState<DocsContext | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const loadSequence = useRef(0); const loadSequence = useRef(0);
const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search)); const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search));
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set()); 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 locale = localeFromSearch(location.search) ?? language;
const selectedVersion = versionFromSearch(location.search); const selectedVersion = versionFromSearch(location.search);
const adminDocs = documentationType === "admin"; const adminDocs = documentationType === "admin";
@@ -109,6 +118,12 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
const treeNodes = useMemo(() => docsTreeNodes(context, adminDocs), [context, adminDocs]); const treeNodes = useMemo(() => docsTreeNodes(context, adminDocs), [context, adminDocs]);
const pages = useMemo(() => flattenTreePages(treeNodes), [treeNodes]); const pages = useMemo(() => flattenTreePages(treeNodes), [treeNodes]);
const selectedPage = selectedPageFromSearch(location.search, pages); 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 topicById = useMemo(() => topicIndex(context), [context]);
const visibleRoutes = context?.layers.configured.routes ?? []; const visibleRoutes = context?.layers.configured.routes ?? [];
const availableRoutes = context?.layers.available.routes ?? []; const availableRoutes = context?.layers.available.routes ?? [];
@@ -122,7 +137,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
useEffect(() => { useEffect(() => {
if (!selectedPage) return; if (!selectedPage) return;
const ancestorIds = ancestorNodeIdsForPage(treeNodes, selectedPage.id); const ancestorIds = ancestorOccurrenceIds(treeNodes, selectedNode?.id ?? "");
if (!ancestorIds.length) return; if (!ancestorIds.length) return;
setExpandedNodes((current) => { setExpandedNodes((current) => {
const next = new Set(current); const next = new Set(current);
@@ -131,7 +146,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
} }
return next; return next;
}); });
}, [treeNodes, selectedPage?.id]); }, [treeNodes, selectedNode?.id]);
return ( return (
<WorkspaceLayout <WorkspaceLayout
@@ -165,14 +180,24 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
))} ))}
</select> </select>
</label> </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> </div>
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99"> <nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
<ExplorerTree <ExplorerTree
nodes={treeNodes} nodes={visibleTreeNodes}
getNodeId={(node) => node.id} getNodeId={(node) => node.id}
getNodeLabel={(node) => node.title} getNodeLabel={(node) => node.title}
getNodeChildren={(node) => node.children} getNodeChildren={(node) => node.children}
activeId={activeNodeIdForPage(treeNodes, selectedPage?.id ?? "")} activeId={selectedNode?.id ?? ""}
expandedIds={expandedNodes} expandedIds={expandedNodes}
depth={0} depth={0}
childrenBaseClassName="docs-tree-children" childrenBaseClassName="docs-tree-children"
@@ -181,11 +206,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
toggleBaseClassName="docs-tree-toggle" toggleBaseClassName="docs-tree-toggle"
nodeButtonBaseClassName="docs-tree-page" nodeButtonBaseClassName="docs-tree-page"
getNodeWrapStyle={(_node, context) => ({ paddingLeft: `${context.depth * 14}px` })} 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" />} renderToggleIcon={(_node, context) => context.hasChildren ? context.expanded ? <ChevronDown size={15} /> : <ChevronRight size={15} /> : <span className="docs-tree-toggle-placeholder" />}
renderNodeContent={(node) => node.title} renderNodeContent={(node) => node.title}
onToggle={(node) => toggleNode(node.id)} onToggle={(node) => toggleNode(node.id)}
onOpen={(node) => selectPage(node.page)} onOpen={(node) => { selectPage(node.page, node.id); setSearchQuery(""); setSelectedTags(null); }}
/> />
</nav> </nav>
</aside> </aside>
@@ -205,7 +230,16 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
> >
<div className="docs-content"> <div className="docs-content">
<main className="docs-page-main"> <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} page={selectedPage}
adminDocs={adminDocs} adminDocs={adminDocs}
documentationType={documentationType} documentationType={documentationType}
@@ -218,9 +252,9 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
evidenceSources={context?.layers.evidence.sources ?? []} evidenceSources={context?.layers.evidence.sources ?? []}
settings={settings} settings={settings}
locale={locale} locale={locale}
/> />}
</main> </main>
<PageOutline items={outlineItems} /> {!filtering && <PageOutline items={outlineItems} />}
</div> </div>
</PageLayout> </PageLayout>
</WorkspaceLayout> </WorkspaceLayout>
@@ -239,9 +273,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
navigate(`${location.pathname}?${params.toString()}`, { replace: true }); navigate(`${location.pathname}?${params.toString()}`, { replace: true });
} }
function selectPage(page: DocsPageNode) { function selectPage(page: DocsPageNode, occurrenceId?: string) {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
params.set("topic", page.id); params.set("topic", page.id);
if (occurrenceId) params.set("occurrence", occurrenceId);
else params.delete("occurrence");
navigate(`${location.pathname}?${params.toString()}`, { replace: true }); 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 workflowTopics = context?.topic_groups.workflow ?? [];
const referenceTopics = context?.topic_groups.reference ?? []; const referenceTopics = context?.topic_groups.reference ?? [];
const patternTopics = context?.topic_groups.pattern ?? []; 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 workingTopics = uniqueTopics([...patternTopics, ...workflowTopics]);
const troubleshootingTopics = allTopics.filter((topic) => !topic.active || topic.layer === "evidence"); const troubleshootingTopics = allTopics.filter((topic) => !topic.active || topic.layer === "evidence");
const basicsNode = categoryTreeNode( const basicsNode = categoryTreeNode(
@@ -672,15 +706,15 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
), ),
{ {
id: "tree:modules", id: "tree:modules",
title: "i18n:govoplan-docs.modules.04e9462c", title: "i18n:govoplan-docs.topic_areas",
page: { page: {
id: "modules:overview", id: "modules:overview",
title: "i18n:govoplan-docs.modules.04e9462c", title: "i18n:govoplan-docs.topic_areas",
kind: "topic-list", kind: "topic-list",
topics: moduleTopics, topics: allTopics,
emptyText: "i18n:govoplan-docs.no_module_topics_found.0cbbdc9b" emptyText: "i18n:govoplan-docs.no_module_topics_found.0cbbdc9b"
}, },
children: moduleTreeNodes(moduleTopics, context) children: moduleTreeNodes(allTopics, context)
} }
]; ];
if (adminDocs) { if (adminDocs) {
@@ -695,7 +729,7 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
emptyText: "i18n:govoplan-docs.no_administration_topics_found.6213dff2" emptyText: "i18n:govoplan-docs.no_administration_topics_found.6213dff2"
}, },
children: [ children: [
...referenceTopics.map(topicTreeNode), ...referenceTopics.map((topic) => topicTreeNode(topic)),
{ {
id: "tree:administration:technical-reference", id: "tree:administration:technical-reference",
title: "i18n:govoplan-docs.technical_reference.f271430d", 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" "i18n:govoplan-docs.no_troubleshooting_topics_found.8c275468"
)); ));
} }
return nodes; return qualifyTreeOccurrences(nodes);
} }
function categoryTreeNode(id: string, title: string, topics: DocsDocumentationTopic[], emptyText: string): DocsTreeNode { 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[] { function moduleTreeNodes(topics: DocsDocumentationTopic[], context: DocsContext | null): DocsTreeNode[] {
const topicsByModule = new Map<string, DocsDocumentationTopic[]>(); const topicsByModule = new Map<string, DocsDocumentationTopic[]>();
for (const topic of topics) { 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); moduleTopics.push(topic);
topicsByModule.set(topic.source_module_id, moduleTopics); topicsByModule.set(areaId, moduleTopics);
}
} }
const configuredModules = context?.layers.configured.modules ?? []; const configuredModules = context?.layers.configured.modules ?? [];
const moduleOrder = new Map(configuredModules.map((module, index) => [module.id, index])); 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); return nodes.filter((node) => node.children.length).map((node) => node.id);
} }
function ancestorNodeIdsForPage(nodes: DocsTreeNode[], pageId: string, ancestors: string[] = []): string[] { function filterTopicTree(nodes: DocsTreeNode[], matchingIds: Set<string>): DocsTreeNode[] {
for (const node of nodes) { return nodes.flatMap((node) => {
if (node.page.id === pageId) return ancestors; const children = filterTopicTree(node.children, matchingIds);
const match = ancestorNodeIdsForPage(node.children, pageId, [...ancestors, node.id]); const matches = node.page.kind === "topic" && matchingIds.has(node.page.topic.id);
if (match.length) return match; return matches || children.length ? [{ ...node, children }] : [];
} });
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 allDocumentationTopics(context: DocsContext | null): DocsDocumentationTopic[] { function allDocumentationTopics(context: DocsContext | null): DocsDocumentationTopic[] {
@@ -1140,7 +1165,7 @@ function compactSourceRecord(value: object): Array<[string, unknown]> {
} }
function humanizeSourceKey(value: string): string { function humanizeSourceKey(value: string): string {
const words = value.replaceAll("_", " "); const words = value.replace(/_/g, " ");
return words.charAt(0).toUpperCase() + words.slice(1); return words.charAt(0).toUpperCase() + words.slice(1);
} }
+73
View File
@@ -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 [];
}
+16
View File
@@ -2,6 +2,14 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "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.additional_permissions.8042cb01": "additional permissions",
"i18n:govoplan-docs.about_govoplan.6b2d7127": "About GovOPlaN", "i18n:govoplan-docs.about_govoplan.6b2d7127": "About GovOPlaN",
"i18n:govoplan-docs.admin_docs.bf504a56": "Admin docs", "i18n:govoplan-docs.admin_docs.bf504a56": "Admin docs",
@@ -104,6 +112,14 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-docs.your_documentation.8a4cd9a3": "Your documentation" "i18n:govoplan-docs.your_documentation.8a4cd9a3": "Your documentation"
}, },
"de": { "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.additional_permissions.8042cb01": "additional permissions",
"i18n:govoplan-docs.about_govoplan.6b2d7127": "Über GovOPlaN", "i18n:govoplan-docs.about_govoplan.6b2d7127": "Über GovOPlaN",
"i18n:govoplan-docs.admin_docs.bf504a56": "Administrationsdokumentation", "i18n:govoplan-docs.admin_docs.bf504a56": "Administrationsdokumentation",