From cbe434de02dff8c2e809d2b0fe87130cd053ea6d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 8 Sep 2026 01:32:36 +0200 Subject: [PATCH] Release govoplan-docs v0.1.23: unify help discovery and batch semantic reads --- docs/INSTANCE_AWARE_DOCUMENTATION.md | 43 ++ docs/SEMANTIC_DOCUMENTATION.md | 15 + package.json | 4 +- pyproject.toml | 4 +- src/govoplan_docs/__init__.py | 2 +- src/govoplan_docs/backend/api/v1/routes.py | 24 ++ .../backend/api/v1/semantic_routes.py | 3 + src/govoplan_docs/backend/manifest.py | 52 ++- src/govoplan_docs/backend/search_source.py | 61 ++- src/govoplan_docs/backend/semantic_service.py | 89 +++- tests/test_docs_context.py | 28 ++ tests/test_public_export.py | 2 +- tests/test_semantic_read_efficiency.py | 397 ++++++++++++++++++ webui/package.json | 4 +- webui/src/api/docs.ts | 1 + webui/src/features/docs/DocsPage.tsx | 105 +++-- webui/src/features/docs/docsDiscovery.ts | 73 ++++ webui/src/i18n/generatedTranslations.ts | 16 + 18 files changed, 842 insertions(+), 81 deletions(-) create mode 100644 tests/test_semantic_read_efficiency.py create mode 100644 webui/src/features/docs/docsDiscovery.ts diff --git a/docs/INSTANCE_AWARE_DOCUMENTATION.md b/docs/INSTANCE_AWARE_DOCUMENTATION.md index e252e65..dfbc6d2 100644 --- a/docs/INSTANCE_AWARE_DOCUMENTATION.md +++ b/docs/INSTANCE_AWARE_DOCUMENTATION.md @@ -3,6 +3,49 @@ The docs module renders documentation for the current GovOPlaN instance instead of showing a static product manual. +## Finding and grouping help + +The Help Center searches authorized titles, summaries, body text, area names, +and public topic tags. The shared multi-selection list filter uses OR between +selected tags; Select all removes the restriction, Clear all yields no results. +Search results deduplicate semantic topic IDs. Selecting a result clears the +filters and opens its topic. + +Topics by area groups each topic under its source module and authorized related +modules. Contributors may add public keyword strings in `metadata.tags` and +additional stable module IDs in `metadata.areas`. The Docs-owned +user projection accepts up to 32 tags of at most 80 characters and 32 explicit +area IDs of at most 255 characters. The +`area_module_ids` response is authoritative: user responses omit related areas +without an actor-visible route, while preserving the topic's source area. +Existing role, tenant, locale, version, and configured-state topic authorization +is unchanged. Tags are public descriptive content, not a place for private +configuration, credentials, or hidden capability names. + +Topic IDs identify content; parent-qualified navigation occurrence IDs identify +one position in the tree. `topic` URLs remain supported; a tree click also sets +`occurrence`, so reload highlights and reveals only the selected position. +Expanding a repeated topic never expands its other occurrences. + +### Deutsch + +Die Suche berücksichtigt berechtigte Titel, Zusammenfassungen, Thementexte, +Bereichsnamen und öffentliche Schlagwörter. Mehrere ausgewählte Schlagwörter +werden mit ODER verknüpft. Alle auswählen entfernt die Einschränkung; Auswahl +aufheben ergibt keine Treffer. Suchergebnisse enthalten jedes Thema einmal. +Die Auswahl eines Treffers setzt die Filter zurück und öffnet das Thema. + +Themen nach Bereich ordnet Beiträge dem Quellmodul und berechtigten verwandten +Modulen zu. Modulautoren können öffentliche Stichwörter in `metadata.tags` und +weitere stabile Modulkennungen in `metadata.areas` angeben. Für Benutzer werden +verwandte Bereiche ohne sichtbare Route nicht ausgegeben. Suche und Filter +erweitern niemals die Dokumentationsberechtigung. Zugangsdaten und private +Konfiguration gehören nicht in Schlagwörter. + +Ein Thema kann in mehreren Zweigen erscheinen. Eine positionsbezogene Kennung +im Link speichert, welches Vorkommen ausgewählt wurde. Nur dieses Vorkommen +wird hervorgehoben; Aufklappen öffnet nicht zugleich die anderen Fundstellen. + ## Inputs The documentation context is built from: diff --git a/docs/SEMANTIC_DOCUMENTATION.md b/docs/SEMANTIC_DOCUMENTATION.md index b9c3acc..57e1f1a 100644 --- a/docs/SEMANTIC_DOCUMENTATION.md +++ b/docs/SEMANTIC_DOCUMENTATION.md @@ -29,6 +29,21 @@ Semantic content is bounded plain text. Links must be local absolute paths or HT Search indexes only published revisions and always requires provider reauthorization before returning a result. Generic public documentation generation reads static manifest topics only, so it cannot include tenant semantic entries. The separately authorized tenant export includes current entries and immutable history and sends `private, no-store`. +Collection/context projection and search authorization load required revisions +in request-local batches of at most 400 identifiers. Read-only projection loads +published content only, never pending draft bodies, and remains available if a +pending draft reference is broken. Editors still receive an explicit error for +an unavailable current revision. Revision tenant, entry, and publication-state +references must agree before content is projected or indexed. An audience +denial is checked before calling the subject provider; an allowed audience does +not replace the provider's current permission checks. + +This reduces Docs-owned database round trips without caching authorization +between requests or importing subject-module internals. Owner-provider reads +remain independent. The batch size is not a collection or export limit: complete +semantic catalogues and separately authorized history exports can still require +work proportional to their size. + ## Backup, recovery, and module removal Back up `docs_semantic_entries` and `docs_semantic_revisions` together with Core tenant and audit state. Restoring only one table breaks revision pointers and is unsupported. The installer blocks normal uninstall while rows remain. Destructive retirement is explicit, requires a database snapshot, and drops revision history before entries. diff --git a/package.json b/package.json index c2be8de..313f34c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/docs-webui", - "version": "0.1.22", + "version": "0.1.23", "private": true, "type": "module", "main": "webui/src/index.ts", @@ -17,7 +17,7 @@ "README.md" ], "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.45", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/pyproject.toml b/pyproject.toml index ac49fd9..180525a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-docs" -version = "0.1.22" +version = "0.1.23" description = "GovOPlaN documentation module for configured-system, available, and evidence documentation." readme = "README.md" requires-python = ">=3.12" authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.37", + "govoplan-core>=0.1.45", "govoplan-access>=0.1.18", ] diff --git a/src/govoplan_docs/__init__.py b/src/govoplan_docs/__init__.py index deb01d3..b44d9e8 100644 --- a/src/govoplan_docs/__init__.py +++ b/src/govoplan_docs/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "0.1.22" +__version__ = "0.1.23" diff --git a/src/govoplan_docs/backend/api/v1/routes.py b/src/govoplan_docs/backend/api/v1/routes.py index 7002d49..7d6acfc 100644 --- a/src/govoplan_docs/backend/api/v1/routes.py +++ b/src/govoplan_docs/backend/api/v1/routes.py @@ -40,6 +40,7 @@ from govoplan_docs.backend.sources import ( ) from govoplan_docs.backend.semantic_service import ( list_semantic_entries, + prefetch_semantic_revisions, select_locale_entries, semantic_entry_payload, ) @@ -830,6 +831,11 @@ def _classify_documentation( ) -> dict[str, list[dict[str, Any]]]: layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []} installed = {manifest.id for manifest in registry.manifests()} + visible_area_modules = frozenset( + str(item["module_id"]) + for item in _route_items(registry.manifests(), principal) + if item["visible"] + ) if documentation_type == "user" else frozenset(installed) visible_runtime_paths = frozenset([ "/settings", # Authenticated shell route, not contributed by a module manifest. *( @@ -909,6 +915,7 @@ def _classify_documentation( visible_runtime_paths=visible_runtime_paths, configuration=configuration, resolved_version=resolved_version, + visible_area_modules=visible_area_modules, )) return layers @@ -1011,6 +1018,7 @@ def _semantic_documentation_topics( list_semantic_entries(session, principal), locale=locale, ) + revisions = prefetch_semantic_revisions(session, principal, entries=entries, editor=False) topics: list[DocumentationTopic] = [] for entry in entries: payload = semantic_entry_payload( @@ -1020,6 +1028,7 @@ def _semantic_documentation_topics( entry=entry, editor=False, requested_locale=locale, + revisions=revisions, ) if payload is None: continue @@ -1430,6 +1439,7 @@ def _documentation_topic_payload( visible_runtime_paths: frozenset[str], configuration: Mapping[str, DocumentationConfigurationDecision], resolved_version: str, + visible_area_modules: frozenset[str] = frozenset(), ) -> dict[str, Any]: module_id = topic.source_module_id or source_module_id translation_locale, translation = _translation_for_locale(topic, locale) @@ -1484,6 +1494,16 @@ def _documentation_topic_payload( "conditions": [_documentation_condition_payload(condition) for condition in topic.conditions], "links": [_documentation_link_payload(link) for link in topic.links], "related_modules": list(topic.related_modules), + "area_module_ids": sorted({ + module_id, + *( + area for area in ( + *topic.related_modules, + *_bounded_string_list(localized_metadata.get("areas"), maximum_items=32, maximum_length=255), + ) + if area in visible_area_modules + ), + }), "unlocks": list(topic.unlocks), "configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}), "configuration_states": [ @@ -1497,6 +1517,7 @@ def _documentation_topic_payload( return { "id": payload["id"], "source_module_id": payload["source_module_id"], + "area_module_ids": payload["area_module_ids"], "kind": payload["kind"], "anchor_id": payload["anchor_id"], "title": payload["title"], @@ -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) if value: projected[key] = value + tags = _bounded_string_list(metadata.get("tags"), maximum_items=32, maximum_length=80) + if tags: + projected["tags"] = tags if kind == "reference" and isinstance(metadata.get("fields"), list): fields = [_user_field_metadata(item) for item in metadata["fields"][:64] if isinstance(item, Mapping)] if fields: diff --git a/src/govoplan_docs/backend/api/v1/semantic_routes.py b/src/govoplan_docs/backend/api/v1/semantic_routes.py index a14c17b..e3607c6 100644 --- a/src/govoplan_docs/backend/api/v1/semantic_routes.py +++ b/src/govoplan_docs/backend/api/v1/semantic_routes.py @@ -43,6 +43,7 @@ from govoplan_docs.backend.semantic_service import ( create_semantic_entry, get_semantic_entry, list_semantic_entries, + prefetch_semantic_revisions, publication_policy, publish_semantic_entry, retire_semantic_entry, @@ -167,6 +168,7 @@ def list_entries( subject_kind=subject_kind, ) selected = entries if editor else select_locale_entries(entries, locale=locale) + revisions = prefetch_semantic_revisions(session, principal, entries=selected, editor=editor) items = [ item for entry in selected @@ -178,6 +180,7 @@ def list_entries( entry=entry, editor=editor, requested_locale=locale, + revisions=revisions, ) ) is not None diff --git a/src/govoplan_docs/backend/manifest.py b/src/govoplan_docs/backend/manifest.py index 29ae151..0be9869 100644 --- a/src/govoplan_docs/backend/manifest.py +++ b/src/govoplan_docs/backend/manifest.py @@ -131,7 +131,7 @@ def _dsar_provider(_context: ModuleContext) -> DocsDsarProvider: manifest = ModuleManifest( id="docs", name="Docs", - version="0.1.22", + version="0.1.23", required_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, @@ -302,6 +302,46 @@ manifest = ModuleManifest( ), ), documentation=( + DocumentationTopic( + id="docs.workflow.find-help", + title="Find help by area and keyword", + summary="Search visible help topics and use area and keyword tags to narrow the results.", + body=( + "Enter words from your question in Search help topics. Titles, summaries, topic text, area names, and contributed tags are searched together. " + "Areas and tags is a multi-selection dropdown: selected tags match any of those tags; Select all removes the restriction and Clear all selects no topics. " + "Topics by area includes all visible guidance associated with an area, including guidance contributed by another module. " + "A topic can appear in several branches; only the occurrence you select is highlighted, and expanding it does not expand its other occurrences. " + "Search results show each topic once. Choosing a result clears the filters and opens the topic. Search and tags only narrow the documentation already authorized for your role. " + "Administrators and module authors contribute public keywords in DocumentationTopic metadata.tags and optional module-area IDs in metadata.areas; the source module and authorized related_modules also supply area tags. " + "Use stable module IDs for areas and readable keywords for tags; do not put credentials, private configuration, or hidden capability names in public search tags. " + "User area facets omit related modules without a visible route; administrative documentation still requires its separate permission." + ), + layer="always", + documentation_types=("admin", "user"), + order=9, + conditions=(DocumentationCondition(required_scopes=(DOCS_READ_SCOPE,)),), + translations={ + "de": { + "title": "Hilfe nach Bereich und Stichwort finden", + "summary": "Sichtbare Hilfethemen durchsuchen und Ergebnisse mit Bereichen und Schlagwörtern eingrenzen.", + "body": ( + "Geben Sie unter Hilfethemen suchen Wörter aus Ihrer Frage ein. Titel, Zusammenfassungen, Thementexte, Bereichsnamen und beigetragene Schlagwörter werden gemeinsam durchsucht. " + "Bereiche und Schlagwörter ist eine Auswahlliste mit Mehrfachauswahl: Ein ausgewähltes Schlagwort genügt für einen Treffer. Alle auswählen entfernt die Einschränkung, Auswahl aufheben wählt keine Themen. " + "Themen nach Bereich enthält sämtliche sichtbaren Hinweise eines Bereichs, auch Beiträge anderer Module. " + "Ein Thema kann in mehreren Zweigen erscheinen; nur die angeklickte Stelle wird hervorgehoben, und das Aufklappen öffnet nicht zugleich die anderen Vorkommen. " + "Suchergebnisse zeigen jedes Thema einmal. Die Auswahl eines Ergebnisses setzt die Filter zurück und öffnet das Thema. Suche und Schlagwörter grenzen ausschließlich die bereits für Ihre Rolle freigegebene Dokumentation ein. " + "Administratoren und Modulautoren hinterlegen öffentliche Stichwörter in DocumentationTopic metadata.tags und optionale Modul-Bereichskennungen in metadata.areas; das Quellmodul und berechtigte related_modules liefern ebenfalls Bereichsschlagwörter. " + "Verwenden Sie stabile Modulkennungen für Bereiche und lesbare Stichwörter für Schlagwörter. Zugangsdaten, private Konfiguration und Namen verborgener Fähigkeiten gehören nicht in öffentliche Suchschlagwörter. " + "Bereichsfilter der Benutzerdokumentation zeigen keine zugeordneten Module ohne sichtbare Route. Administrative Dokumentation benötigt weiterhin ihre gesonderte Berechtigung." + ), + }, + }, + metadata={ + "kind": "workflow", + "tags": ["Help", "Hilfe", "Search", "Suche", "Tags", "Schlagwörter"], + "help_contexts": ["docs.help-center.search"], + }, + ), DocumentationTopic( id="docs.semantic-documentation", title="Tenant semantic documentation", @@ -311,7 +351,8 @@ manifest = ModuleManifest( "Every save creates an immutable revision. Tenant policy chooses direct publication or an independent reviewer. " "Published content remains subject to the subject's current authorization, the documentation audience and classification, tenant isolation, and locale selection. " "Changed, missing, superseded, or temporarily unavailable subjects are shown explicitly; direct links, contextual help, search, caches, and tenant exports apply the same read-time authorization. " - "Retirement and supersession preserve history. Generic public documentation exports never include tenant semantic entries; administrators use the separately authorized tenant export." + "Retirement and supersession preserve history. Generic public documentation exports never include tenant semantic entries; administrators use the separately authorized tenant export. " + "Collection, configured-context, and search-authorization reads batch the required revisions in request-local groups of at most 400 identifiers instead of fetching revisions once per entry. Read-only views load published content, not pending draft bodies. Each revision must belong to the same tenant and entry, and a published pointer must reference a published revision; inconsistent references fail closed. Audience denial avoids unnecessary subject-provider work, while allowed results still require the owning subject's current authorization. No cross-request permission cache is introduced; complete tenant exports and full history remain separately authorized operations." ), layer="always", documentation_types=("admin", "user"), @@ -351,7 +392,12 @@ manifest = ModuleManifest( "der Mandantentrennung und der Sprachauswahl. Geänderte, fehlende, abgelöste oder vorübergehend nicht verfügbare Fachobjekte " "werden ausdrücklich gekennzeichnet; Direktlinks, Kontexthilfe, Suche, Zwischenspeicher und Mandantenexporte wenden dieselbe " "Berechtigungsprüfung beim Lesen an. Stilllegung und Ablösung bewahren die Historie. Allgemeine öffentliche Dokumentationsexporte " - "enthalten niemals semantische Mandanteneinträge; für diese steht der getrennt berechtigte Mandantenexport bereit." + "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." ), } }, diff --git a/src/govoplan_docs/backend/search_source.py b/src/govoplan_docs/backend/search_source.py index 5a14a88..1a420fb 100644 --- a/src/govoplan_docs/backend/search_source.py +++ b/src/govoplan_docs/backend/search_source.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence from urllib.parse import quote -from sqlalchemy import func, select +from sqlalchemy import and_, func, select from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal @@ -23,7 +23,11 @@ from govoplan_docs.backend.db.models import ( SemanticDocumentationEntry, SemanticDocumentationRevision, ) -from govoplan_docs.backend.semantic_service import semantic_entry_payload +from govoplan_docs.backend.semantic_service import ( + prefetch_semantic_revisions, + published_revision, + semantic_entry_payload, +) PROVIDER_ID = "docs.semantic_documentation" @@ -58,8 +62,12 @@ class SemanticDocumentationSearchSource: select(SemanticDocumentationEntry, SemanticDocumentationRevision) .join( SemanticDocumentationRevision, - SemanticDocumentationRevision.id - == SemanticDocumentationEntry.published_revision_id, + and_( + SemanticDocumentationRevision.id == SemanticDocumentationEntry.published_revision_id, + SemanticDocumentationRevision.entry_id == SemanticDocumentationEntry.id, + SemanticDocumentationRevision.tenant_id == SemanticDocumentationEntry.tenant_id, + SemanticDocumentationRevision.lifecycle_state == "published", + ), ) .where( SemanticDocumentationEntry.tenant_id == request.tenant_id, @@ -105,15 +113,32 @@ class SemanticDocumentationSearchSource: if not isinstance(principal, ApiPrincipal) or not principal.has(DOCS_READ_SCOPE): return decisions db = _session(session) - for item in requests: - reference = item.reference + valid_requests = [ + item + for item in requests if ( - reference.tenant_id != principal.tenant_id - or reference.module_id != "docs" - or reference.resource_type != RESOURCE_TYPE - ): - continue - entry = db.get(SemanticDocumentationEntry, reference.resource_id) + item.reference.tenant_id == principal.tenant_id + and item.reference.module_id == "docs" + and item.reference.resource_type == RESOURCE_TYPE + ) + ] + ids = sorted({item.reference.resource_id for item in valid_requests}) + entries: dict[str, SemanticDocumentationEntry] = {} + for offset in range(0, len(ids), 400): + rows = db.scalars( + select(SemanticDocumentationEntry).where( + SemanticDocumentationEntry.id.in_(ids[offset:offset + 400]), + SemanticDocumentationEntry.tenant_id == principal.tenant_id, + SemanticDocumentationEntry.lifecycle_state.in_(("draft", "published")), + ) + ) + entries.update((entry.id, entry) for entry in rows) + revisions = prefetch_semantic_revisions( + db, principal, entries=tuple(entries.values()), editor=False, + ) + for item in valid_requests: + reference = item.reference + entry = entries.get(reference.resource_id) if ( entry is None or entry.tenant_id != principal.tenant_id @@ -127,6 +152,7 @@ class SemanticDocumentationSearchSource: principal, entry=entry, editor=False, + revisions=revisions, ) decisions[reference.key] = bool( payload @@ -153,8 +179,12 @@ class SemanticDocumentationSearchSource: db = _session(session) entry = db.get(SemanticDocumentationEntry, event.resource.id) revision = ( - db.get(SemanticDocumentationRevision, entry.published_revision_id) - if entry is not None and entry.published_revision_id + published_revision(db, entry) + if ( + entry is not None + and entry.tenant_id == event.tenant.id + and entry.lifecycle_state in {"draft", "published"} + ) else None ) visible = bool( @@ -162,6 +192,9 @@ class SemanticDocumentationSearchSource: and entry.tenant_id == event.tenant.id and entry.lifecycle_state in {"draft", "published"} and revision is not None + and revision.entry_id == entry.id + and revision.tenant_id == entry.tenant_id + and revision.lifecycle_state == "published" ) cursor = event.event_id document = _document(entry, revision, change_cursor=cursor) if visible else None diff --git a/src/govoplan_docs/backend/semantic_service.py b/src/govoplan_docs/backend/semantic_service.py index ee9322f..4931e35 100644 --- a/src/govoplan_docs/backend/semantic_service.py +++ b/src/govoplan_docs/backend/semantic_service.py @@ -396,13 +396,21 @@ def semantic_entry_history( def current_revision( session: Session, entry: SemanticDocumentationEntry, + *, + revisions: Mapping[str, SemanticDocumentationRevision] | None = None, ) -> SemanticDocumentationRevision: - revision = ( - session.get(SemanticDocumentationRevision, entry.current_revision_id) - if entry.current_revision_id - else None - ) - if revision is None or revision.entry_id != entry.id: + revision = None + if entry.current_revision_id: + revision = ( + revisions.get(entry.current_revision_id) + if revisions is not None + else session.get(SemanticDocumentationRevision, entry.current_revision_id) + ) + if ( + revision is None + or revision.entry_id != entry.id + or revision.tenant_id != entry.tenant_id + ): raise SemanticDocumentationError( "Semantic documentation current revision is unavailable." ) @@ -412,14 +420,60 @@ def current_revision( def published_revision( session: Session, entry: SemanticDocumentationEntry, + *, + revisions: Mapping[str, SemanticDocumentationRevision] | None = None, ) -> SemanticDocumentationRevision | None: if not entry.published_revision_id: return None - revision = session.get( - SemanticDocumentationRevision, - entry.published_revision_id, + revision = ( + revisions.get(entry.published_revision_id) + if revisions is not None + else session.get(SemanticDocumentationRevision, entry.published_revision_id) ) - return revision if revision is not None and revision.entry_id == entry.id else None + if ( + revision is None + or revision.entry_id != entry.id + or revision.tenant_id != entry.tenant_id + or revision.lifecycle_state != "published" + ): + return None + return revision + + +def prefetch_semantic_revisions( + session: Session, + principal: object, + *, + entries: Sequence[SemanticDocumentationEntry], + editor: bool, +) -> dict[str, SemanticDocumentationRevision]: + """Load only the revisions this request may project, in bounded SQL batches. + + This is request-local data loading, never an authorization cache. Payload + projection still validates each entry/revision pair and its owner subject. + Readers have no reason to load the bodies of pending unpublished drafts. + """ + tenant_id = _principal_tenant_id(principal) + revision_ids = sorted({ + revision_id + for entry in entries + if entry.tenant_id == tenant_id + for revision_id in ( + entry.published_revision_id, + entry.current_revision_id if editor else None, + ) + if revision_id + }) + revisions: dict[str, SemanticDocumentationRevision] = {} + for offset in range(0, len(revision_ids), 400): + rows = session.query(SemanticDocumentationRevision).filter( + SemanticDocumentationRevision.tenant_id == tenant_id, + SemanticDocumentationRevision.id.in_(revision_ids[offset:offset + 400]), + ) + if not editor: + rows = rows.filter(SemanticDocumentationRevision.lifecycle_state == "published") + revisions.update((revision.id, revision) for revision in rows) + return revisions def entry_subject_reference( @@ -487,12 +541,16 @@ def semantic_entry_payload( entry: SemanticDocumentationEntry, editor: bool, requested_locale: str | None = None, + revisions: Mapping[str, SemanticDocumentationRevision] | None = None, ) -> dict[str, object] | None: - current = current_revision(session, entry) - published = published_revision(session, entry) - selected = current if editor else published + if entry.tenant_id != _principal_tenant_id(principal): + return None + published = published_revision(session, entry, revisions=revisions) + selected = current_revision(session, entry, revisions=revisions) if editor else published if selected is None: return None + if not content_visible_to_principal(selected.content, principal): + return None resolution = resolve_entry_subject( session, registry, @@ -505,8 +563,6 @@ def semantic_entry_payload( unavailable = resolution.availability == "temporarily_unavailable" if unavailable and not editor: return None - if not content_visible_to_principal(selected.content, principal): - return None subject = resolution.subject required_scopes = subject.required_scopes if subject is not None else () if any(not _principal_has(principal, scope) for scope in required_scopes): @@ -523,7 +579,7 @@ def semantic_entry_payload( "lifecycle_state": entry.lifecycle_state, "effective_state": selected.lifecycle_state, "pending_draft": bool( - published is not None and current.id != published.id + published is not None and entry.current_revision_id != published.id ), "current_revision": entry.current_revision, "selected_revision": selected.revision, @@ -965,6 +1021,7 @@ __all__ = [ "entry_subject_reference", "get_semantic_entry", "list_semantic_entries", + "prefetch_semantic_revisions", "publication_policy", "publish_semantic_entry", "published_revision", diff --git a/tests/test_docs_context.py b/tests/test_docs_context.py index 96b2e56..170ed07 100644 --- a/tests/test_docs_context.py +++ b/tests/test_docs_context.py @@ -52,6 +52,34 @@ class FakePrincipal: class DocsContextTests(unittest.TestCase): + def test_public_topic_tags_and_areas_are_bounded_and_authorized(self) -> None: + registry = PlatformRegistry() + registry.register(ModuleManifest( + id="services", name="Services", version="1.0.0", + nav_items=(NavItem(path="/services", label="Services", required_any=("services:read",)),), + )) + registry.register(ModuleManifest( + id="hidden", name="Hidden", version="1.0.0", + nav_items=(NavItem(path="/hidden", label="Hidden", required_any=("hidden:read",)),), + )) + registry.register(ModuleManifest( + id="forms", name="Forms", version="1.0.0", + documentation=(DocumentationTopic( + id="forms.service-guidance", title="Service application", summary="Application help", + documentation_types=("user", "admin"), related_modules=("services", "hidden", "uninstalled"), + metadata={"kind": "reference", "tags": ["Application", "Antrag", None, 12, "x" * 81], "areas": ["services", "hidden"]}, + ),), + )) + user_layers = _classify_documentation(registry, FakePrincipal({"docs:documentation:read", "services:read"}), settings=None, session=None, documentation_type="user", locale="de") + topic = next(item for layer in user_layers.values() for item in layer if item["id"] == "forms.service-guidance") + self.assertEqual(topic["area_module_ids"], ["forms", "services"]) + self.assertEqual(topic["metadata"]["tags"], ["Application", "Antrag"]) + self.assertEqual(topic["related_modules"], []) + self.assertNotIn("areas", topic["metadata"]) + hidden_layers = _classify_documentation(registry, FakePrincipal({"docs:documentation:read"}), settings=None, session=None, documentation_type="user", locale="de") + topic = next(item for layer in hidden_layers.values() for item in layer if item["id"] == "forms.service-guidance") + self.assertEqual(topic["area_module_ids"], ["forms"]) + def test_structured_topic_metadata_uses_requested_locale(self) -> None: registry = PlatformRegistry() registry.register( diff --git a/tests/test_public_export.py b/tests/test_public_export.py index 6281486..addf49d 100644 --- a/tests/test_public_export.py +++ b/tests/test_public_export.py @@ -24,7 +24,7 @@ class PublicDocumentationExportTests(unittest.TestCase): (ManifestSource(get_docs_manifest()),) )["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([], module["coverage"]["missing_german_topic_ids"]) self.assertEqual(2, module["coverage"]["structured_localizable_topic_count"]) diff --git a/tests/test_semantic_read_efficiency.py b/tests/test_semantic_read_efficiency.py new file mode 100644 index 0000000..faf56ac --- /dev/null +++ b/tests/test_semantic_read_efficiency.py @@ -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() diff --git a/webui/package.json b/webui/package.json index edb0572..099a3d9 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/docs-webui", - "version": "0.1.22", + "version": "0.1.23", "private": true, "type": "module", "main": "src/index.ts", @@ -16,7 +16,7 @@ "typecheck": "tsc --noEmit" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.45", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/webui/src/api/docs.ts b/webui/src/api/docs.ts index d1fd893..57167a7 100644 --- a/webui/src/api/docs.ts +++ b/webui/src/api/docs.ts @@ -178,6 +178,7 @@ export type DocsDocumentationTopic = { conditions: DocsDocumentationCondition[]; links: DocsDocumentationLink[]; related_modules: string[]; + area_module_ids?: string[]; unlocks: string[]; configuration_keys: string[]; configuration_states: Array<{ diff --git a/webui/src/features/docs/DocsPage.tsx b/webui/src/features/docs/DocsPage.tsx index 79a3969..ef2ae60 100644 --- a/webui/src/features/docs/DocsPage.tsx +++ b/webui/src/features/docs/DocsPage.tsx @@ -8,9 +8,15 @@ import { Dialog, DismissibleAlert, ExplorerTree, + FilterBar, + FormField, + MultiSelectFilter, PageActionBar, PageLayout, SegmentedControl, + SelectionList, + SelectionListItem, + SelectionListItemContent, StatusBadge, WorkspaceLayout, adminErrorMessage, @@ -30,6 +36,7 @@ import { type DocsSource, type DocsSourceDetail } from "../../api/docs"; +import { ancestorOccurrenceIds, documentationTagOptions, documentationTags, matchesDocumentationTopic, qualifyTreeOccurrences, selectedTreeOccurrence, topicAreaIds } from "./docsDiscovery"; type DocumentationType = "admin" | "user"; @@ -68,13 +75,15 @@ type OutlineItem = { export default function DocsPage({ settings }: { settings: ApiSettings }) { const location = useLocation(); const navigate = useGuardedNavigate(); - const { language } = usePlatformLanguage(); + const { language, translateText } = usePlatformLanguage(); const [context, setContext] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const loadSequence = useRef(0); const [documentationType, setDocumentationType] = useState(() => documentationTypeFromSearch(location.search)); const [expandedNodes, setExpandedNodes] = useState>(() => new Set()); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedTags, setSelectedTags] = useState(null); const locale = localeFromSearch(location.search) ?? language; const selectedVersion = versionFromSearch(location.search); const adminDocs = documentationType === "admin"; @@ -109,6 +118,12 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) { const treeNodes = useMemo(() => docsTreeNodes(context, adminDocs), [context, adminDocs]); const pages = useMemo(() => flattenTreePages(treeNodes), [treeNodes]); const selectedPage = selectedPageFromSearch(location.search, pages); + const selectedNode = selectedTreeOccurrence(treeNodes, selectedPage?.id ?? "", new URLSearchParams(location.search).get("occurrence")); + const allTopics = useMemo(() => allDocumentationTopics(context), [context]); + const tagOptions = useMemo(() => documentationTagOptions(allTopics, context), [allTopics, context]); + const matchingTopics = useMemo(() => allTopics.filter((topic) => matchesDocumentationTopic(topic, context, searchQuery, selectedTags)), [allTopics, context, searchQuery, selectedTags]); + const filtering = Boolean(searchQuery.trim()) || selectedTags !== null; + const visibleTreeNodes = useMemo(() => filtering ? filterTopicTree(treeNodes, new Set(matchingTopics.map((topic) => topic.id))) : treeNodes, [filtering, treeNodes, matchingTopics]); const topicById = useMemo(() => topicIndex(context), [context]); const visibleRoutes = context?.layers.configured.routes ?? []; const availableRoutes = context?.layers.available.routes ?? []; @@ -122,7 +137,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) { useEffect(() => { if (!selectedPage) return; - const ancestorIds = ancestorNodeIdsForPage(treeNodes, selectedPage.id); + const ancestorIds = ancestorOccurrenceIds(treeNodes, selectedNode?.id ?? ""); if (!ancestorIds.length) return; setExpandedNodes((current) => { const next = new Set(current); @@ -131,7 +146,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) { } return next; }); - }, [treeNodes, selectedPage?.id]); + }, [treeNodes, selectedNode?.id]); return ( + + + setSearchQuery(event.target.value)} placeholder={translateText("i18n:govoplan-docs.search_topics_hint")} /> + + + {filtering && <> + {translateText("i18n:govoplan-docs.matching_topics").replace("{count}", String(matchingTopics.length))} + + } + @@ -205,7 +230,16 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) { >
- +

{translateText("i18n:govoplan-docs.search_results")}

+ {matchingTopics.length ? {matchingTopics.map((topic) => { selectPage(topicPage(topic)); setSearchQuery(""); setSelectedTags(null); }} + > + tag.label).join(" · ")].filter(Boolean).join(" — ")} /> + )} :

{translateText("i18n:govoplan-docs.no_matching_topics")}

} + } + {!filtering && + />}
- + {!filtering && }
@@ -239,9 +273,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) { navigate(`${location.pathname}?${params.toString()}`, { replace: true }); } - function selectPage(page: DocsPageNode) { + function selectPage(page: DocsPageNode, occurrenceId?: string) { const params = new URLSearchParams(location.search); params.set("topic", page.id); + if (occurrenceId) params.set("occurrence", occurrenceId); + else params.delete("occurrence"); navigate(`${location.pathname}?${params.toString()}`, { replace: true }); } @@ -629,8 +665,6 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre const workflowTopics = context?.topic_groups.workflow ?? []; const referenceTopics = context?.topic_groups.reference ?? []; const patternTopics = context?.topic_groups.pattern ?? []; - const systemTopicIds = new Set(systemTopics.map((topic) => topic.id)); - const moduleTopics = allTopics.filter((topic) => !systemTopicIds.has(topic.id)); const workingTopics = uniqueTopics([...patternTopics, ...workflowTopics]); const troubleshootingTopics = allTopics.filter((topic) => !topic.active || topic.layer === "evidence"); const basicsNode = categoryTreeNode( @@ -672,15 +706,15 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre ), { id: "tree:modules", - title: "i18n:govoplan-docs.modules.04e9462c", + title: "i18n:govoplan-docs.topic_areas", page: { id: "modules:overview", - title: "i18n:govoplan-docs.modules.04e9462c", + title: "i18n:govoplan-docs.topic_areas", kind: "topic-list", - topics: moduleTopics, + topics: allTopics, emptyText: "i18n:govoplan-docs.no_module_topics_found.0cbbdc9b" }, - children: moduleTreeNodes(moduleTopics, context) + children: moduleTreeNodes(allTopics, context) } ]; if (adminDocs) { @@ -695,7 +729,7 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre emptyText: "i18n:govoplan-docs.no_administration_topics_found.6213dff2" }, children: [ - ...referenceTopics.map(topicTreeNode), + ...referenceTopics.map((topic) => topicTreeNode(topic)), { id: "tree:administration:technical-reference", title: "i18n:govoplan-docs.technical_reference.f271430d", @@ -717,7 +751,7 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre "i18n:govoplan-docs.no_troubleshooting_topics_found.8c275468" )); } - return nodes; + return qualifyTreeOccurrences(nodes); } function categoryTreeNode(id: string, title: string, topics: DocsDocumentationTopic[], emptyText: string): DocsTreeNode { @@ -757,9 +791,11 @@ function topicTreeNode(topic: DocsDocumentationTopic): DocsTreeNode { function moduleTreeNodes(topics: DocsDocumentationTopic[], context: DocsContext | null): DocsTreeNode[] { const topicsByModule = new Map(); for (const topic of topics) { - const moduleTopics = topicsByModule.get(topic.source_module_id) ?? []; - moduleTopics.push(topic); - topicsByModule.set(topic.source_module_id, moduleTopics); + for (const areaId of topicAreaIds(topic, context)) { + const moduleTopics = topicsByModule.get(areaId) ?? []; + moduleTopics.push(topic); + topicsByModule.set(areaId, moduleTopics); + } } const configuredModules = context?.layers.configured.modules ?? []; const moduleOrder = new Map(configuredModules.map((module, index) => [module.id, index])); @@ -823,23 +859,12 @@ function defaultExpandedNodeIds(nodes: DocsTreeNode[]): string[] { return nodes.filter((node) => node.children.length).map((node) => node.id); } -function ancestorNodeIdsForPage(nodes: DocsTreeNode[], pageId: string, ancestors: string[] = []): string[] { - for (const node of nodes) { - if (node.page.id === pageId) return ancestors; - const match = ancestorNodeIdsForPage(node.children, pageId, [...ancestors, node.id]); - if (match.length) return match; - } - return []; -} - -function activeNodeIdForPage(nodes: DocsTreeNode[], pageId: string): string { - if (!pageId) return ""; - for (const node of nodes) { - if (node.page.id === pageId) return node.id; - const childId = activeNodeIdForPage(node.children, pageId); - if (childId) return childId; - } - return ""; +function filterTopicTree(nodes: DocsTreeNode[], matchingIds: Set): DocsTreeNode[] { + return nodes.flatMap((node) => { + const children = filterTopicTree(node.children, matchingIds); + const matches = node.page.kind === "topic" && matchingIds.has(node.page.topic.id); + return matches || children.length ? [{ ...node, children }] : []; + }); } function allDocumentationTopics(context: DocsContext | null): DocsDocumentationTopic[] { @@ -1140,7 +1165,7 @@ function compactSourceRecord(value: object): Array<[string, unknown]> { } function humanizeSourceKey(value: string): string { - const words = value.replaceAll("_", " "); + const words = value.replace(/_/g, " "); return words.charAt(0).toUpperCase() + words.slice(1); } diff --git a/webui/src/features/docs/docsDiscovery.ts b/webui/src/features/docs/docsDiscovery.ts new file mode 100644 index 0000000..102ee45 --- /dev/null +++ b/webui/src/features/docs/docsDiscovery.ts @@ -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(); + 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 = { id: string; page: { id: string }; children: T[] }; + +/** Semantic topic IDs remain stable; navigation IDs identify one occurrence only. */ +export function qualifyTreeOccurrences>(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>(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>(nodes: T[]): T[] { + return nodes.flatMap((node) => [node, ...flattenTreeOccurrences(node.children)]); +} + +export function ancestorOccurrenceIds>(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 []; +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 4bc1d5c..d17fbe4 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -2,6 +2,14 @@ import type { PlatformTranslations } from "@govoplan/core-webui"; export const generatedTranslations: PlatformTranslations = { "en": { + "i18n:govoplan-docs.search_topics": "Search help topics", + "i18n:govoplan-docs.search_topics_hint": "Question, keyword or tag…", + "i18n:govoplan-docs.topic_tags": "Areas and tags", + "i18n:govoplan-docs.topic_areas": "Topics by area", + "i18n:govoplan-docs.matching_topics": "{count} matching topics", + "i18n:govoplan-docs.clear_topic_filters": "Clear filters", + "i18n:govoplan-docs.search_results": "Matching help topics", + "i18n:govoplan-docs.no_matching_topics": "No topics match. Try another keyword or clear the tag filter.", "i18n:govoplan-docs.additional_permissions.8042cb01": "additional permissions", "i18n:govoplan-docs.about_govoplan.6b2d7127": "About GovOPlaN", "i18n:govoplan-docs.admin_docs.bf504a56": "Admin docs", @@ -104,6 +112,14 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-docs.your_documentation.8a4cd9a3": "Your documentation" }, "de": { + "i18n:govoplan-docs.search_topics": "Hilfethemen suchen", + "i18n:govoplan-docs.search_topics_hint": "Frage, Stichwort oder Schlagwort…", + "i18n:govoplan-docs.topic_tags": "Bereiche und Schlagwörter", + "i18n:govoplan-docs.topic_areas": "Themen nach Bereich", + "i18n:govoplan-docs.matching_topics": "{count} passende Themen", + "i18n:govoplan-docs.clear_topic_filters": "Filter zurücksetzen", + "i18n:govoplan-docs.search_results": "Passende Hilfethemen", + "i18n:govoplan-docs.no_matching_topics": "Keine passenden Themen. Versuchen Sie ein anderes Stichwort oder setzen Sie den Schlagwortfilter zurück.", "i18n:govoplan-docs.additional_permissions.8042cb01": "additional permissions", "i18n:govoplan-docs.about_govoplan.6b2d7127": "Über GovOPlaN", "i18n:govoplan-docs.admin_docs.bf504a56": "Administrationsdokumentation",