Release govoplan-docs v0.1.23: unify help discovery and batch semantic reads
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -311,7 +351,8 @@ manifest = ModuleManifest(
|
|||||||
"Every save creates an immutable revision. Tenant policy chooses direct publication or an independent reviewer. "
|
"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. "
|
"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"),
|
||||||
@@ -351,7 +392,12 @@ manifest = ModuleManifest(
|
|||||||
"der Mandantentrennung und der Sprachauswahl. Geänderte, fehlende, abgelöste oder vorübergehend nicht verfügbare Fachobjekte "
|
"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 "
|
"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."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = (
|
revision = None
|
||||||
session.get(SemanticDocumentationRevision, entry.current_revision_id)
|
if entry.current_revision_id:
|
||||||
if entry.current_revision_id
|
revision = (
|
||||||
else None
|
revisions.get(entry.current_revision_id)
|
||||||
)
|
if revisions is not None
|
||||||
if revision is None or revision.entry_id != entry.id:
|
else session.get(SemanticDocumentationRevision, entry.current_revision_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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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",
|
"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",
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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)) {
|
||||||
moduleTopics.push(topic);
|
const moduleTopics = topicsByModule.get(areaId) ?? [];
|
||||||
topicsByModule.set(topic.source_module_id, moduleTopics);
|
moduleTopics.push(topic);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
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",
|
||||||
|
|||||||
Reference in New Issue
Block a user