3 Commits
Author SHA1 Message Date
zemion 705255f378 fix(webui): bind wiki publication to help
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 11:47:25 +02:00
zemion b9b9e9431a docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:23:36 +02:00
zemion 66c91351c9 feat: implement governed wiki vertical slice
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 13:08:12 +02:00
25 changed files with 5021 additions and 84 deletions
+37 -7
View File
@@ -4,13 +4,14 @@
**Repository type:** module (domain). **Repository type:** module (domain).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
`govoplan-wiki` owns collaborative, revisioned knowledge pages and spaces. `govoplan-wiki` owns governed collaborative knowledge spaces and hierarchical,
It is the native GovOPlaN alternative and integration target for tools such as revisioned pages. It is the native GovOPlaN alternative and integration target
MediaWiki and BlueSpice. for tools such as MediaWiki and BlueSpice.
The runtime module ID is `wiki`. This initial scaffold registers the domain The runtime module ID is `wiki`. The v0.1.20 vertical slice provides tenant-safe
boundary, permissions, roles, documentation, and module entry point. Runtime spaces, hierarchical drafts, immutable revisions, optimistic concurrency,
storage and WebUI routes will follow as bounded implementation slices. publication and redirects, labels, typed links, append-only comments, access
inheritance, ACL-aware Search, DSAR participation export, and a focused WebUI.
## Boundary ## Boundary
@@ -19,6 +20,34 @@ publishing state. It does not own binary file storage, formal records, product
documentation, workflow execution, or general-purpose collaborative document documentation, workflow execution, or general-purpose collaborative document
editing. editing.
## Runtime Surface
- `/api/v1/wiki/spaces` for governed spaces, access inheritance, publishing
mode, optimistic updates, and archival
- `/api/v1/wiki/pages` for page hierarchy, drafts, labels, references, redirects,
publication, and archival
- immutable page revision history and bounded unified-diff comparison
- append-only comments bound to the page revision under discussion
- `wiki.registry` for stable, authorization-checked cross-module references
- `wiki.pages` Search projection of the last published revision only
- `privacy.dsar.wiki` for exact-identifier, bounded participation export and
manual-review erasure planning
- `/wiki` tree/detail workspace built from shared Core layout, action-bar,
dialog, status, state, and selection primitives
Editing a published page creates a draft while the last published revision
remains visible to readers and Search. This prevents ordinary authoring from
silently withdrawing approved knowledge.
## Optional Integrations
Files and DMS own referenced binary content. Search discovers published pages.
Records can preserve a published revision as evidence. Projects, Cases, Tasks,
templates, and workflows can be linked without importing their lifecycles.
Connectors owns external Wiki discovery, credentials, transport,
reconciliation, and migration execution. Each absence leaves the native Wiki
usable and is documented as an explicit operational consequence.
See [docs/WIKI_DOMAIN_BOUNDARY.md](docs/WIKI_DOMAIN_BOUNDARY.md). See [docs/WIKI_DOMAIN_BOUNDARY.md](docs/WIKI_DOMAIN_BOUNDARY.md).
## Development ## Development
@@ -26,5 +55,6 @@ See [docs/WIKI_DOMAIN_BOUNDARY.md](docs/WIKI_DOMAIN_BOUNDARY.md).
```bash ```bash
cd /mnt/DATA/git/govoplan-wiki cd /mnt/DATA/git/govoplan-wiki
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \ PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests /mnt/DATA/git/govoplan/.venv/bin/python -m pytest -q
cd webui && node scripts/test-interface-pattern.mjs
``` ```
+33 -4
View File
@@ -24,8 +24,37 @@ revision as evidence. Search indexes only revisions the principal may
discover. Projects, Tickets, and Cases may link pages as living knowledge. discover. Projects, Tickets, and Cases may link pages as living knowledge.
Connectors owns MediaWiki/BlueSpice discovery, synchronization, and migration. Connectors owns MediaWiki/BlueSpice discovery, synchronization, and migration.
## First Slice ## Governed Vertical Slice
Implement spaces, revisioned pages, links, access checks, and The native slice stores a current editorial projection plus an immutable page
permission-aware search publication before adding rich editing or external revision for every creation, edit, publication, redirect, or archive action.
synchronization. Writes require the expected revision and an idempotency key. A published page
can gain a later draft without withdrawing its last published revision from
readers or Search.
Visibility is either tenant-wide or restricted to exact account, identity,
membership, group, role, or function-assignment tokens. Pages inherit their
space access by default and may define an explicit override. Permissions remain
distinct for reading, editing, commenting, publishing, and space
administration, and are checked again at each service action.
Typed links retain the owner module and stable resource identity. Attachment
links can target Files or DMS only; no binary bytes are copied into Wiki.
External provenance can be recorded without claiming transport authority.
Search receives only the last published revision, effective visibility, and
ACL tokens, and rechecks every result against current Wiki state. Drafts and
archives never become searchable documents. DSAR discovery matches exact
subject identifiers in ACL membership, actor attribution, and the subject's
own comments. Minimization remains a manual knowledge-owner and retention
decision.
## Current Limits
- external MediaWiki or BlueSpice discovery, credentials, migration execution,
synchronization, and reconciliation require a Connectors provider
- the initial editor stores governed plain text; rich-text editing and presence
awareness can be layered over the same revision contract later
- a page with active child pages must have those children moved or archived
before its path changes or it is archived, preventing silent descendant path
drift
+30 -4
View File
@@ -1,8 +1,34 @@
{ {
"name": "@govoplan/wiki", "name": "@govoplan/wiki-webui",
"version": "0.1.19", "version": "0.1.22",
"private": true, "private": true,
"description": "GovOPlaN collaborative Wiki module.", "description": "GovOPlaN governed collaborative Wiki module.",
"type": "module", "type": "module",
"peerDependencies": {} "main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/wiki.css": "./webui/src/styles/wiki.css"
},
"files": [
"webui/src",
"README.md",
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.31",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
} }
+3 -3
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-wiki" name = "govoplan-wiki"
version = "0.1.19" version = "0.1.22"
description = "GovOPlaN collaborative Wiki module." description = "GovOPlaN governed collaborative Wiki module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.18", "govoplan-core>=0.1.31",
"govoplan-access>=0.1.18", "govoplan-access>=0.1.18",
] ]
+17
View File
@@ -0,0 +1,17 @@
"""Wiki persistence models."""
from govoplan_wiki.backend.db.models import (
WikiComment,
WikiPage,
WikiPageRevision,
WikiSpace,
WikiSpaceHistory,
)
__all__ = [
"WikiComment",
"WikiPage",
"WikiPageRevision",
"WikiSpace",
"WikiSpaceHistory",
]
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class WikiSpace(Base, TimestampMixin):
__tablename__ = "wiki_spaces"
__table_args__ = (
UniqueConstraint("tenant_id", "space_key", name="uq_wiki_space_key"),
Index("ix_wiki_space_catalog", "tenant_id", "archived_at", "title"),
)
id: Mapped[str] = mapped_column(String(255), primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
space_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
visibility: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
acl_tokens: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
publish_mode: Mapped[str] = mapped_column(String(40), nullable=False)
archived_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
updated_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
class WikiSpaceHistory(Base, TimestampMixin):
__tablename__ = "wiki_space_history"
__table_args__ = (
UniqueConstraint(
"tenant_id", "space_id", "revision", name="uq_wiki_space_history_revision"
),
UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_wiki_space_history_idempotency"
),
Index("ix_wiki_space_history_timeline", "tenant_id", "space_id", "recorded_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
space_id: Mapped[str] = mapped_column(
ForeignKey("wiki_spaces.id", ondelete="RESTRICT"), nullable=False, index=True
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
change_reason: Mapped[str] = mapped_column(String(1_000), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
class WikiPage(Base, TimestampMixin):
__tablename__ = "wiki_pages"
__table_args__ = (
UniqueConstraint("tenant_id", "space_id", "path", name="uq_wiki_page_path"),
Index("ix_wiki_page_tree", "tenant_id", "space_id", "parent_page_id", "state"),
Index("ix_wiki_page_catalog", "tenant_id", "state", "updated_at"),
)
id: Mapped[str] = mapped_column(String(255), primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
space_id: Mapped[str] = mapped_column(
ForeignKey("wiki_spaces.id", ondelete="RESTRICT"), nullable=False, index=True
)
parent_page_id: Mapped[str | None] = mapped_column(
ForeignKey("wiki_pages.id", ondelete="RESTRICT"), nullable=True, index=True
)
slug: Mapped[str] = mapped_column(String(160), nullable=False)
path: Mapped[str] = mapped_column(String(1_000), nullable=False)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
published_revision: Mapped[int | None] = mapped_column(Integer, nullable=True)
state: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
visibility: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
inherits_access: Mapped[bool] = mapped_column(nullable=False, default=True)
acl_tokens: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
labels: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
links: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, nullable=False, default=list
)
redirect_page_id: Mapped[str | None] = mapped_column(
ForeignKey("wiki_pages.id", ondelete="RESTRICT"), nullable=True
)
search_text: Mapped[str] = mapped_column(Text, nullable=False)
published_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
archived_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
updated_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
class WikiPageRevision(Base, TimestampMixin):
__tablename__ = "wiki_page_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "page_id", "revision", name="uq_wiki_page_revision"
),
UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_wiki_page_idempotency"
),
Index("ix_wiki_page_revision_timeline", "tenant_id", "page_id", "recorded_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
page_id: Mapped[str] = mapped_column(
ForeignKey("wiki_pages.id", ondelete="RESTRICT"), nullable=False, index=True
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
change_reason: Mapped[str] = mapped_column(String(1_000), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
class WikiComment(Base, TimestampMixin):
__tablename__ = "wiki_comments"
__table_args__ = (
UniqueConstraint("tenant_id", "comment_id", name="uq_wiki_comment"),
UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_wiki_comment_idempotency"
),
Index("ix_wiki_comment_timeline", "tenant_id", "page_id", "recorded_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
page_id: Mapped[str] = mapped_column(
ForeignKey("wiki_pages.id", ondelete="RESTRICT"), nullable=False, index=True
)
comment_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
page_revision: Mapped[int] = mapped_column(Integer, nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
__all__ = [
"WikiComment",
"WikiPage",
"WikiPageRevision",
"WikiSpace",
"WikiSpaceHistory",
]
+352
View File
@@ -0,0 +1,352 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import UTC, datetime
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_wiki.backend.db.models import (
WikiComment,
WikiPage,
WikiPageRevision,
WikiSpace,
WikiSpaceHistory,
)
WIKI_DSAR_CAPABILITY = dsar_capability_name("wiki")
_MAX_RECORDS = 5_000
class WikiDsarProvider:
provider_id = "wiki"
module_id = "wiki"
def search_subject(
self, session: object, *, tenant_id: str, subject: DsarSubjectRef
) -> Sequence[DsarRecordRef]:
db = _session(session)
ids = _subject_ids(subject)
if not ids:
return ()
tokens = tuple(
f"{kind}:{value}" for kind, value in _subject_pairs(subject) if value
)
page_ids = set()
page_ids.update(
item[0]
for item in db.query(WikiPage.id)
.filter(
WikiPage.tenant_id == tenant_id,
or_(WikiPage.created_by.in_(ids), WikiPage.updated_by.in_(ids)),
)
.limit(_MAX_RECORDS + 1)
.all()
)
page_ids.update(
item[0]
for item in db.query(WikiPageRevision.page_id)
.filter(
WikiPageRevision.tenant_id == tenant_id,
WikiPageRevision.actor_id.in_(ids),
)
.distinct()
.limit(_MAX_RECORDS + 1)
.all()
)
page_ids.update(
item[0]
for item in db.query(WikiComment.page_id)
.filter(WikiComment.tenant_id == tenant_id, WikiComment.created_by.in_(ids))
.distinct()
.limit(_MAX_RECORDS + 1)
.all()
)
token_filter = or_(*(WikiPage.acl_tokens.contains(token) for token in tokens))
for row in (
db.query(WikiPage)
.filter(WikiPage.tenant_id == tenant_id, token_filter)
.limit(_MAX_RECORDS + 1)
):
if set(row.acl_tokens or ()) & set(tokens):
page_ids.add(row.id)
if len(page_ids) > _MAX_RECORDS:
raise ValueError("Wiki DSAR result limit exceeded; narrow the selectors.")
records = [
self._page_record(db, tenant_id, page_id, ids, tokens)
for page_id in sorted(page_ids)
]
space_token_filter = or_(
*(WikiSpace.acl_tokens.contains(token) for token in tokens)
)
for space in (
db.query(WikiSpace)
.filter(
WikiSpace.tenant_id == tenant_id,
or_(
WikiSpace.created_by.in_(ids),
WikiSpace.updated_by.in_(ids),
space_token_filter,
),
)
.limit(_MAX_RECORDS + 1)
):
actor_match = space.created_by in ids or space.updated_by in ids
acl_match = bool(set(space.acl_tokens or ()) & set(tokens))
if actor_match or acl_match:
records.append(self._space_record(db, space, ids, tokens))
if len(records) > _MAX_RECORDS:
raise ValueError("Wiki DSAR result limit exceeded; narrow the selectors.")
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if not _subject_ids(subject):
raise ValueError(
"Wiki DSAR requires an exact account, identity, or membership selector."
)
return tuple(
DsarErasureActionRef(
action_id=f"wiki:review:{item.resource_type}:{item.resource_id}",
provider_id=self.provider_id,
module_id=self.module_id,
kind="manual_review",
resource_type=item.resource_type,
resource_id=item.resource_id,
title=f"Review {item.title}",
rationale="Wiki ACL membership and comments may be minimized only after the page owner and applicable retention policy review the institutional knowledge record and immutable revision evidence.",
executable=False,
)
for item in records
if _valid_record(item)
)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if not _subject_ids(subject):
raise ValueError("Wiki DSAR requires an exact selector.")
results = []
for action in actions:
if (
action.provider_id != self.provider_id
or action.module_id != self.module_id
or action.kind != "manual_review"
or action.executable
):
raise ValueError(
"Wiki DSAR exposes non-executable manual-review actions only."
)
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary="Wiki content remains unchanged pending owner and retention review.",
evidence={"request_id": request_id},
)
)
return tuple(results)
def _page_record(
self,
db: Session,
tenant_id: str,
page_id: str,
ids: tuple[str, ...],
tokens: tuple[str, ...],
) -> DsarRecordRef:
row = (
db.query(WikiPage)
.filter(WikiPage.tenant_id == tenant_id, WikiPage.id == page_id)
.one()
)
activities = (
db.query(WikiPageRevision)
.filter(
WikiPageRevision.tenant_id == tenant_id,
WikiPageRevision.page_id == page_id,
WikiPageRevision.actor_id.in_(ids),
)
.order_by(WikiPageRevision.revision)
.limit(500)
.all()
)
comments = (
db.query(WikiComment)
.filter(
WikiComment.tenant_id == tenant_id,
WikiComment.page_id == page_id,
WikiComment.created_by.in_(ids),
)
.order_by(WikiComment.recorded_at)
.limit(500)
.all()
)
return DsarRecordRef(
provider_id="wiki",
module_id="wiki",
resource_type="wiki_page_participation",
resource_id=row.id,
category="collaborative_knowledge",
title=f"Wiki participation: {row.title}",
data={
"space_id": row.space_id,
"page_title": row.title,
"path": row.path,
"revision": row.revision,
"matching_acl_tokens": sorted(set(row.acl_tokens or ()) & set(tokens)),
"subject_activities": [
{
"revision": item.revision,
"event_type": item.event_type,
"recorded_at": _iso(item.recorded_at),
}
for item in activities
],
"subject_comments": [
{
"comment_id": item.comment_id,
"page_revision": item.page_revision,
"body": item.body[:20_000],
"recorded_at": _iso(item.recorded_at),
}
for item in comments
],
},
observed_at=_aware(row.updated_at),
immutable_evidence=True,
retention_reason="Published knowledge and revision attribution can be institutional accountability evidence.",
source_path=f"/wiki?pageId={row.id}",
)
def _space_record(
self, db: Session, row: WikiSpace, ids: tuple[str, ...], tokens: tuple[str, ...]
) -> DsarRecordRef:
history = (
db.query(WikiSpaceHistory)
.filter(
WikiSpaceHistory.tenant_id == row.tenant_id,
WikiSpaceHistory.space_id == row.id,
WikiSpaceHistory.actor_id.in_(ids),
)
.order_by(WikiSpaceHistory.revision)
.limit(500)
.all()
)
return DsarRecordRef(
provider_id="wiki",
module_id="wiki",
resource_type="wiki_space_participation",
resource_id=row.id,
category="collaborative_knowledge_administration",
title=f"Wiki space participation: {row.title}",
data={
"space_key": row.space_key,
"title": row.title,
"matching_acl_tokens": sorted(set(row.acl_tokens or ()) & set(tokens)),
"subject_activities": [
{
"revision": item.revision,
"event_type": item.event_type,
"recorded_at": _iso(item.recorded_at),
}
for item in history
],
},
observed_at=_aware(row.updated_at),
immutable_evidence=True,
retention_reason="Space administration is retained as governance evidence.",
source_path="/wiki",
)
def _subject_pairs(subject: DsarSubjectRef) -> tuple[tuple[str, str], ...]:
refs = subject.external_references
candidates = (
(
"account",
(subject.account_id, refs.get("wiki.account"), refs.get("access.account")),
),
(
"identity",
(subject.identity_id, refs.get("wiki.identity"), refs.get("identity.id")),
),
(
"membership",
(
subject.membership_id,
refs.get("wiki.membership"),
refs.get("tenancy.membership"),
),
),
)
pairs: list[tuple[str, str]] = []
for kind, raw_values in candidates:
values = {
str(value).strip() for value in raw_values if str(value or "").strip()
}
if len(values) > 1:
return ()
if values:
pairs.append((kind, next(iter(values))))
return tuple(pairs)
def _subject_ids(subject: DsarSubjectRef) -> tuple[str, ...]:
return tuple(dict.fromkeys(value for _kind, value in _subject_pairs(subject)))
def _valid_record(item: DsarRecordRef) -> bool:
if (
item.provider_id != "wiki"
or item.module_id != "wiki"
or item.resource_type
not in {"wiki_page_participation", "wiki_space_participation"}
):
raise ValueError("Foreign DSAR record supplied to Wiki.")
return True
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Wiki DSAR requires a SQLAlchemy session.")
return value
def _aware(value: datetime | None) -> datetime | None:
return (
value.replace(tzinfo=UTC)
if value is not None and (value.tzinfo is None or value.utcoffset() is None)
else value
)
def _iso(value: datetime | None) -> str | None:
return _aware(value).isoformat() if value else None
__all__ = ["WIKI_DSAR_CAPABILITY", "WikiDsarProvider"]
@@ -0,0 +1,79 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'wiki.admin.access-publishing': {'consequence_classes': {'publisher_mode': 'Die Verlagsautorität '
'wird an der '
'Aktionsgrenze erneut '
'bewertet.',
'restricted_without_acl': 'Die '
'Konfiguration '
'wird '
'abgelehnt, um '
'eine nicht '
'erreichbare '
'oder '
'versehentlich '
'ausgesetzte '
'Ressource zu '
'verhindern.'}},
'wiki.admin.data-subject-requests': {'consequence_classes': {'erasure': 'Der Anbieter erstellt '
'nicht ausführbare '
'manuelle '
'Überprüfungsaktionen und '
'ändert automatisch keine '
'Wissensnachweise.',
'uninstall': 'Anhaltende Wiki-Zeilen '
'blockieren die '
'Deinstallation, bis '
'die Migration, '
'Aufbewahrung oder '
'explizite '
'Pensionierung '
'abgeschlossen ist.'}},
'wiki.integrations.references-search': {'consequence_classes': {'connector_absent': 'Externe '
'Referenzen '
'können '
'aufgezeichnet '
'werden, '
'Synchronisation '
'oder '
'Migration '
'können '
'jedoch nicht '
'ausgeführt '
'werden.',
'files_absent': 'Referenzen '
'bleiben nur '
'Metadaten und es '
'wird keine '
'binäre Operation '
'angeboten.',
'search_absent': 'Veröffentlichte '
'Seiten sind '
'durch globale '
'Suche nicht '
'auffindbar.'}},
'wiki.module-boundary': {'consequence_classes': {'ownership': 'Wiki-Shops regulierten Text und '
'Referenzen, niemals binäre Inhalte '
'oder externe '
'Transportinformationen.'}},
'wiki.workflow.author-publish': {'consequence_classes': {'draft_after_publish': 'Die zuletzt '
'veröffentlichte '
'Revision bleibt '
'leserlich '
'sichtbar, bis '
'der neue Entwurf '
'explizit '
'veröffentlicht '
'wird.',
'stale_revision': 'Das Schreiben gibt '
'einen Konflikt zurück '
'und bewahrt sowohl '
'die gespeicherte '
'Revision als auch den '
'Entwurf des '
'Anrufers.'}}}
+479 -48
View File
@@ -1,25 +1,61 @@
from __future__ import annotations from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_wiki.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from pathlib import Path
from sqlalchemy import func
from govoplan_core.core.access import ( from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
) )
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest, ModuleManifest,
NavItem,
PermissionDefinition, PermissionDefinition,
ProductAreaContribution,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
ModuleMaturityEvidence,
)
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_wiki.backend.db import models as wiki_models
from govoplan_wiki.backend.dsar_provider import WIKI_DSAR_CAPABILITY, WikiDsarProvider
from govoplan_wiki.backend.search_source import create_wiki_search_source
from govoplan_wiki.backend.service import (
ADMIN_SCOPE,
CAPABILITY_WIKI_REGISTRY,
COMMENT_SCOPE,
PUBLISH_SCOPE,
READ_SCOPE,
SqlWikiRegistry,
WRITE_SCOPE,
)
MODULE_ID = "wiki" MODULE_ID = "wiki"
MODULE_NAME = "Wiki" MODULE_NAME = "Wiki"
MODULE_VERSION = "0.1.19" MODULE_VERSION = "0.1.22"
READ_SCOPE = "wiki:page:read"
WRITE_SCOPE = "wiki:page:write"
ADMIN_SCOPE = "wiki:space:admin"
OPTIONAL_DEPENDENCIES = ( OPTIONAL_DEPENDENCIES = (
"files", "files",
"dms", "dms",
@@ -31,14 +67,49 @@ OPTIONAL_DEPENDENCIES = (
"cases", "cases",
"templates", "templates",
"notifications", "notifications",
"connectors",
"policy",
) )
def _permission( def _router(context: ModuleContext):
scope: str, from govoplan_wiki.backend.router import create_router
label: str,
description: str, return create_router(context.registry)
) -> PermissionDefinition:
def _registry(_context: ModuleContext) -> SqlWikiRegistry:
return SqlWikiRegistry()
def _dsar(_context: ModuleContext) -> WikiDsarProvider:
return WikiDsarProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
states = {
state: int(count)
for state, count in session.query(wiki_models.WikiPage.state, func.count())
.filter(wiki_models.WikiPage.tenant_id == tenant_id)
.group_by(wiki_models.WikiPage.state)
.all()
}
return {
"wiki_spaces": int(
session.query(func.count(wiki_models.WikiSpace.id))
.filter(wiki_models.WikiSpace.tenant_id == tenant_id)
.scalar()
or 0
),
"wiki_pages": sum(states.values()),
"published_wiki_pages": int(
states.get("published", 0) + states.get("redirected", 0)
),
"draft_wiki_pages": int(states.get("draft", 0)),
}
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2) module_id, resource, action = scope.split(":", 2)
return PermissionDefinition( return PermissionDefinition(
scope=scope, scope=scope,
@@ -53,50 +124,84 @@ def _permission(
PERMISSIONS = ( PERMISSIONS = (
_permission(READ_SCOPE, "Read Wiki pages", "Discover and read accessible Wiki pages."), _permission(
_permission(WRITE_SCOPE, "Edit Wiki pages", "Create and revise pages in writable spaces."), READ_SCOPE,
_permission(ADMIN_SCOPE, "Administer Wiki spaces", "Configure spaces, publishing, and access."), "Read Wiki pages",
"Discover and read accessible published Wiki pages.",
),
_permission(
WRITE_SCOPE,
"Edit Wiki pages",
"Create and revise pages in accessible Wiki spaces.",
),
_permission(
COMMENT_SCOPE,
"Comment on Wiki pages",
"Add immutable comments to accessible Wiki pages.",
),
_permission(
PUBLISH_SCOPE,
"Publish Wiki pages",
"Publish, redirect, and archive governed Wiki pages.",
),
_permission(
ADMIN_SCOPE,
"Administer Wiki spaces",
"Configure Wiki spaces, access inheritance, and publishing policy.",
),
) )
ROLE_TEMPLATES = ( ROLE_TEMPLATES = (
RoleTemplate(
slug="wiki_editor",
name="Wiki editor",
description="Read and revise Wiki pages.",
permissions=(READ_SCOPE, WRITE_SCOPE),
),
RoleTemplate( RoleTemplate(
slug="wiki_reader", slug="wiki_reader",
name="Wiki reader", name="Wiki reader",
description="Read accessible Wiki pages.", description="Read accessible published Wiki pages.",
permissions=(READ_SCOPE,), permissions=(READ_SCOPE,),
), ),
RoleTemplate(
slug="wiki_editor",
name="Wiki editor",
description="Read, revise, and comment on accessible Wiki pages.",
permissions=(READ_SCOPE, WRITE_SCOPE, COMMENT_SCOPE),
),
RoleTemplate(
slug="wiki_publisher",
name="Wiki publisher",
description="Revise, comment on, publish, and archive accessible Wiki pages.",
permissions=(READ_SCOPE, WRITE_SCOPE, COMMENT_SCOPE, PUBLISH_SCOPE),
),
RoleTemplate(
slug="wiki_administrator",
name="Wiki administrator",
description="Configure spaces and govern the complete Wiki lifecycle.",
permissions=(
READ_SCOPE,
WRITE_SCOPE,
COMMENT_SCOPE,
PUBLISH_SCOPE,
ADMIN_SCOPE,
),
),
) )
DOCUMENTATION = ( DOCUMENTATION = (
DocumentationTopic( DocumentationTopic(
id="wiki.module-boundary", id="wiki.module-boundary",
title="Wiki module boundary", title="Wiki module boundary",
summary=( summary="Governed collaborative knowledge spaces, hierarchical pages, immutable revisions, links, labels, comments, access, and publishing.",
"Collaborative knowledge spaces, pages, revisions, links, labels, " body="Wiki owns native knowledge-space and page identity, hierarchy, drafts, immutable revisions, comments, labels, typed references, redirects, and publication state. Files or DMS owns binary content, Records owns formal record disposition, Docs owns product and configured-system guidance, and Connectors owns MediaWiki or BlueSpice transport.",
"access, and publishing."
),
body=(
"Wiki owns revisioned knowledge pages. Files/DMS owns binary "
"attachments, Records owns formal retention, and connectors own "
"MediaWiki/BlueSpice synchronization."
),
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"), audience=("user", "operator", "module_admin", "product_owner"),
related_modules=OPTIONAL_DEPENDENCIES,
translations={ translations={
"de": { "de": {
"title": "Modulgrenze von Wiki", "title": "Modulgrenze von Wiki",
"summary": "Gemeinsam bearbeitete Wissensbereiche, Seiten, Revisionen, Verknüpfungen, Schlagwörter, Zugriff und Veröffentlichung.", "summary": "Gesteuerte Wissensbereiche mit hierarchischen Seiten, unveränderlichen Revisionen, Verweisen, Schlagwörtern, Kommentaren, Zugriff und Veröffentlichung.",
"body": "Wiki verwaltet versionierte Wissensseiten. Files beziehungsweise DMS verwaltet binäre Anhänge, Records die formale Aufbewahrung und Connectors die Synchronisierung mit MediaWiki oder BlueSpice.", "body": "Wiki verwaltet die Identität nativer Wissensbereiche und Seiten, Hierarchie, Entwürfe, unveränderliche Revisionen, Kommentare, Schlagwörter, typisierte Verweise, Weiterleitungen und Veröffentlichungsstatus. Files beziehungsweise DMS verwaltet Binärinhalte, Records die formale Aufbewahrung, Docs die Produkt- und Konfigurationsdokumentation und Connectors den Transport zu MediaWiki oder BlueSpice.",
} }
}, },
related_modules=OPTIONAL_DEPENDENCIES,
links=( links=(
DocumentationLink( DocumentationLink(
label="Repository domain boundary", label="Repository domain boundary",
@@ -106,26 +211,186 @@ DOCUMENTATION = (
), ),
metadata={ metadata={
"kind": "reference", "kind": "reference",
"seed": True,
"consequence_classes": {
"seed_boundary": "Declares ownership and permissions only; no runtime workflow is available yet.",
},
"domain_objects": [ "domain_objects": [
"wiki space", "wiki space",
"wiki page", "wiki page",
"page revision", "page revision",
"page comment",
"page link", "page link",
"page label", "page label",
"publishing state", "publishing state",
], ],
"first_slice": ( "consequence_classes": {
"Implement spaces, revisioned pages, links, access checks, " "ownership": "Wiki stores governed text and references, never referenced binary content or external transport credentials."
"and permission-aware search publication." },
},
), ),
DocumentationTopic(
id="wiki.workflow.author-publish",
title="Author and publish Wiki knowledge",
summary="Browse a space tree, draft pages, compare immutable revisions, publish reviewed content, redirect successors, comment, and archive obsolete knowledge.",
body="Editors create a draft below an accessible parent, add labels and typed links, and save with the revision they loaded. A stale revision fails instead of overwriting newer work. Publishing appends another immutable revision. Later edits become a new draft while readers and Search continue to receive the last published revision. Publishers can redirect a page to another page in the same space or archive it after active child pages are moved or archived. Comments are append-only and record the page revision they discuss.",
layer="available",
documentation_types=("user", "admin"),
audience=("user", "module_admin", "operator"),
related_modules=("search", "files", "dms"),
conditions=(
DocumentationCondition(
any_scopes=(
READ_SCOPE,
WRITE_SCOPE,
COMMENT_SCOPE,
PUBLISH_SCOPE,
ADMIN_SCOPE,
)
),
),
translations={
"de": {
"title": "Wiki-Wissen bearbeiten und veröffentlichen",
"summary": "Bereichsbaum durchsuchen, Seiten entwerfen, unveränderliche Revisionen vergleichen, geprüfte Inhalte veröffentlichen, Nachfolger weiterleiten, kommentieren und veraltetes Wissen archivieren.",
"body": "Bearbeitende erstellen unter einer zugänglichen übergeordneten Seite einen Entwurf, ergänzen Schlagwörter und typisierte Verweise und speichern mit der geladenen Revision. Eine veraltete Revision überschreibt keine neuere Arbeit, sondern erzeugt einen Konflikt. Die Veröffentlichung fügt eine weitere unveränderliche Revision an. Spätere Änderungen werden zu einem neuen Entwurf, während Lesende und Search weiterhin die zuletzt veröffentlichte Revision erhalten. Veröffentlichende können auf eine andere Seite desselben Bereichs weiterleiten oder eine Seite archivieren, nachdem aktive Unterseiten verschoben oder archiviert wurden. Kommentare werden nur angefügt und halten die besprochene Seitenrevision fest.",
}
},
metadata={
"kind": "workflow",
"help_contexts": ["wiki.workspace", "wiki.editor", "wiki.revisions"],
"consequence_classes": {
"stale_revision": "The write returns a conflict and preserves both the stored revision and the caller's draft.",
"draft_after_publish": "The last published revision remains reader-visible until the new draft is explicitly published.",
},
},
),
DocumentationTopic(
id="wiki.admin.access-publishing",
title="Configure Wiki access and publishing",
summary="Separate read, edit, comment, publish, and space-administration authority; inherit or override tenant-safe ACLs; choose publisher-only or editor publishing.",
body="Every operation requires a tenant-bound principal and the matching Wiki permission. A restricted space or page uses exact account, identity, membership, group, role, or function-assignment tokens. Pages inherit their space access by default; an explicit page override can be tenant-visible or restricted but must not be empty when restricted. Space administrators bypass local ACLs for governance. The publishers mode requires the publish permission; the editors mode also lets a permitted editor publish. Archived spaces accept no new or revised content.",
layer="available",
documentation_types=("admin",),
audience=("module_admin", "operator", "product_owner"),
related_modules=("access", "policy", "tenancy"),
translations={
"de": {
"title": "Wiki-Zugriff und Veröffentlichung konfigurieren",
"summary": "Lese-, Bearbeitungs-, Kommentar-, Veröffentlichungs- und Bereichsadministration trennen, mandantensichere ACLs erben oder überschreiben und Veröffentlichung durch Veröffentlichende oder Bearbeitende wählen.",
"body": "Jeder Vorgang erfordert einen mandantengebundenen Akteur und die passende Wiki-Berechtigung. Ein eingeschränkter Bereich oder eine Seite verwendet exakte Kennungen für Konto, Identität, Mitgliedschaft, Gruppe, Rolle oder Funktionszuweisung. Seiten erben standardmäßig den Zugriff ihres Bereichs; eine ausdrückliche Seitenregel kann mandantenweit oder eingeschränkt sein, darf bei eingeschränktem Zugriff aber nicht leer sein. Bereichsadministrierende umgehen lokale ACLs für die Governance. Im Modus „publishers“ ist die Veröffentlichungsberechtigung erforderlich; im Modus „editors“ dürfen auch berechtigte Bearbeitende veröffentlichen. Archivierte Bereiche nehmen keine neuen oder geänderten Inhalte an.",
}
},
metadata={
"kind": "admin",
"help_contexts": ["wiki.admin.space", "wiki.admin.permissions"],
"consequence_classes": {
"restricted_without_acl": "Configuration is rejected to prevent an unreachable or accidentally exposed resource.",
"publisher_mode": "Publishing authority is evaluated again at the action boundary.",
},
},
),
DocumentationTopic(
id="wiki.integrations.references-search",
title="Use Wiki references and optional integrations",
summary="Link Files, DMS, Projects, Cases, Tasks, templates, workflows, and external pages without importing their lifecycles; publish ACL-aware Search documents.",
body="Wiki links store the owning module, resource type and identifier, relationship, optional safe URL, and external provenance. Attachment links may name Files or DMS only; Wiki stores no binary bytes. Published revisions become global Search documents with effective visibility and ACL tokens and are authorization-rechecked against current Wiki state. Drafts and archives are never indexed. Without Search, content remains browsable in Wiki. Without Files or DMS, existing references remain visible but upload and binary resolution belong to the absent provider. External Wiki transport, credentials, reconciliation, and migration execution remain connector-owned.",
layer="available",
documentation_types=("user", "admin"),
audience=("user", "module_admin", "operator", "integrator"),
related_modules=OPTIONAL_DEPENDENCIES,
conditions=(
DocumentationCondition(any_scopes=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)),
),
translations={
"de": {
"title": "Wiki-Verweise und optionale Integrationen verwenden",
"summary": "Files, DMS, Projects, Cases, Tasks, Vorlagen, Workflows und externe Seiten verknüpfen, ohne deren Lebenszyklen zu importieren, und ACL-geschützte Suchdokumente veröffentlichen.",
"body": "Wiki-Verweise speichern das besitzende Modul, Ressourcentyp und Kennung, die Beziehung, eine optionale sichere URL und externe Herkunft. Anhangsverweise dürfen nur Files oder DMS nennen; Wiki speichert keine Binärdaten. Veröffentlichte Revisionen werden mit effektiver Sichtbarkeit und ACL-Kennungen an die globale Suche übergeben und gegen den aktuellen Wiki-Zugriff erneut geprüft. Entwürfe und Archive werden nie indiziert. Ohne Search bleiben Inhalte im Wiki auffindbar. Ohne Files oder DMS bleiben vorhandene Verweise sichtbar; Hochladen und Auflösen von Binärinhalten gehören jedoch zum fehlenden Anbieter. Transport, Zugangsdaten, Abgleich und Migrationsausführung für externe Wikis verbleiben bei Connectors.",
}
},
metadata={
"kind": "workflow",
"help_contexts": ["wiki.editor.links", "wiki.admin.integrations"],
"consequence_classes": {
"search_absent": "Published pages are not discoverable through global Search.",
"files_absent": "References remain metadata-only and no binary operation is offered.",
"connector_absent": "External references can be recorded, but synchronization or migration cannot run.",
},
},
),
DocumentationTopic(
id="wiki.admin.data-subject-requests",
title="Retain, export, and retire Wiki evidence",
summary="Treat revisions and comments as governed institutional evidence and review personal references manually before minimization or destructive retirement.",
body="The Wiki DSAR provider matches exact account, identity, and membership identifiers in actor attribution, ACL tokens, and the subject's own comments. It returns bounded page or space context, the subject's actions and comments, and no unrelated comments, arbitrary request metadata, or hidden draft bodies. Erasure is never automatic: the knowledge owner and retention authority decide whether ACL membership or comments can be detached or minimized while immutable attribution is retained. Uninstall is blocked while Wiki rows exist. Destructive retirement requires an explicit database snapshot and retention review and removes only Wiki-owned tables; referenced Files, DMS, Records, or connector resources remain owned by their modules.",
layer="available",
documentation_types=("admin",),
audience=("privacy", "records_manager", "operator", "module_admin"),
related_modules=("records", "files", "dms", "connectors"),
translations={
"de": {
"title": "Wiki-Nachweise aufbewahren, exportieren und stilllegen",
"summary": "Revisionen und Kommentare als gesteuerte institutionelle Nachweise behandeln und Personenbezüge vor Minimierung oder destruktiver Stilllegung manuell prüfen.",
"body": "Der Wiki-DSAR-Anbieter gleicht exakte Konto-, Identitäts- und Mitgliedschaftskennungen in Akteurzuordnung, ACL-Kennungen und eigenen Kommentaren der betroffenen Person ab. Er liefert begrenzten Seiten- oder Bereichskontext, eigene Handlungen und Kommentare, jedoch keine fremden Kommentare, beliebigen Anfragemetadaten oder verborgenen Entwurfsinhalte. Eine Löschung erfolgt nie automatisch: Wissensverantwortliche und Aufbewahrungsstelle entscheiden, ob ACL-Mitgliedschaft oder Kommentare gelöst beziehungsweise minimiert werden dürfen, während unveränderliche Zuordnung erhalten bleibt. Eine Deinstallation ist blockiert, solange Wiki-Daten vorhanden sind. Destruktive Stilllegung erfordert eine ausdrückliche Datenbanksicherung und Aufbewahrungsprüfung und entfernt nur Wiki-eigene Tabellen; referenzierte Ressourcen in Files, DMS, Records oder Connectors verbleiben bei deren Modulen.",
}
},
metadata={
"kind": "admin",
"help_contexts": ["wiki.admin.dsar", "wiki.admin.retirement"],
"consequence_classes": {
"erasure": "The provider creates non-executable manual-review actions and changes no knowledge evidence automatically.",
"uninstall": "Persistent Wiki rows block uninstall until migration, retention, or explicit retirement is complete.",
},
}, },
), ),
) )
ARCHITECTURE = ModuleArchitectureDeclaration(
layer="content_records_evidence",
kind="domain",
maturity="vertical_slice",
evidence=(
ModuleMaturityEvidence(
kind="documentation",
reference="docs/WIKI_DOMAIN_BOUNDARY.md",
summary="Defines native Wiki, Docs, Files/DMS, Records, Search, and external-transport boundaries.",
),
ModuleMaturityEvidence(
kind="test",
reference="tests/test_wiki_service.py",
summary="Proves hierarchy, immutable publication, OCC, replay safety, ACLs, comments, references, Search, and tenant isolation.",
),
),
known_limits=(
"External MediaWiki and BlueSpice transport and migration execution require a connector provider.",
"The first editor stores governed plain text; rich-text and collaborative presence are later UI capabilities over the same immutable revision contract.",
"Moving or renaming a page with active children requires moving those children first so paths never change silently.",
),
supported_authority_modes=(
"native_authoritative",
"external_mirror",
"governed_sync",
"linked_reference",
),
owned_concepts=(
"wiki space",
"wiki page hierarchy",
"page revision",
"page comment",
"page link",
"page label",
"publishing state",
),
non_owned_concepts=(
"binary attachment content",
"formal record disposition",
"product documentation",
"external Wiki transport",
),
documentation=ModuleArchitectureDocumentation(
operations=("docs/WIKI_DOMAIN_BOUNDARY.md",)
),
)
manifest = ModuleManifest( manifest = ModuleManifest(
id=MODULE_ID, id=MODULE_ID,
name=MODULE_NAME, name=MODULE_NAME,
@@ -136,18 +401,184 @@ manifest = ModuleManifest(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
), ),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_WIKI_REGISTRY, version="1.0.0"),
ModuleInterfaceProvider(name=WIKI_DSAR_CAPABILITY, version="0.1.0"),
),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
documentation=DOCUMENTATION, route_factory=_router,
architecture=declared_module_architecture( nav_items=(
layer="content_records_evidence", NavItem(
kind="domain", path="/wiki",
maturity="scaffold", label="Wiki",
documentation_ref="docs/WIKI_DOMAIN_BOUNDARY.md", icon="book-open",
known_limits=("Wiki spaces, pages, revisions, publishing, and search projection are not implemented yet.",), required_any=(READ_SCOPE,),
owned_concepts=("wiki space", "wiki page", "page revision", "page link"), order=54,
non_owned_concepts=("binary attachment", "record disposition", "external MediaWiki page"), surface_id="wiki.navigation",
), ),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/wiki-webui",
routes=(
FrontendRoute(
path="/wiki",
component="WikiPage",
required_any=(READ_SCOPE,),
order=54,
surface_id="wiki.route.workspace",
),
),
nav_items=(
NavItem(
path="/wiki",
label="Wiki",
icon="book-open",
required_any=(READ_SCOPE,),
order=54,
surface_id="wiki.navigation",
),
),
product_areas=(
ProductAreaContribution(
id="records-documents",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.records_documents",
icon="folder",
description="i18n:govoplan-core.product_area.records_documents_description",
surface_ids=("wiki.route.workspace",),
order=30,
),
),
view_surfaces=(
ViewSurface(
id="wiki.section.space-tree",
module_id=MODULE_ID,
kind="section",
label="Wiki spaces and page tree",
parent_id="wiki.route.workspace",
order=10,
),
ViewSurface(
id="wiki.section.page",
module_id=MODULE_ID,
kind="section",
label="Wiki page",
parent_id="wiki.route.workspace",
order=20,
),
ViewSurface(
id="wiki.action.create-space",
module_id=MODULE_ID,
kind="action",
label="Create Wiki space",
parent_id="wiki.section.space-tree",
order=30,
),
ViewSurface(
id="wiki.action.create-page",
module_id=MODULE_ID,
kind="action",
label="Create Wiki page",
parent_id="wiki.section.space-tree",
order=40,
),
ViewSurface(
id="wiki.action.edit",
module_id=MODULE_ID,
kind="action",
label="Edit Wiki page",
parent_id="wiki.section.page",
order=50,
),
ViewSurface(
id="wiki.action.publish",
module_id=MODULE_ID,
kind="action",
label="Publish Wiki page",
parent_id="wiki.section.page",
order=60,
),
ViewSurface(
id="wiki.action.comment",
module_id=MODULE_ID,
kind="action",
label="Comment on Wiki page",
parent_id="wiki.section.page",
order=70,
),
ViewSurface(
id="wiki.section.revisions",
module_id=MODULE_ID,
kind="section",
label="Wiki page revisions",
parent_id="wiki.section.page",
order=80,
),
ViewSurface(
id="wiki.action.archive",
module_id=MODULE_ID,
kind="action",
label="Archive Wiki content",
parent_id="wiki.section.page",
order=90,
),
),
),
tenant_summary_providers=(_tenant_summary,),
capability_factories={
CAPABILITY_WIKI_REGISTRY: _registry,
WIKI_DSAR_CAPABILITY: _dsar,
},
capability_documentation={
CAPABILITY_WIKI_REGISTRY: CapabilityDocumentation(
label="Wiki registry",
summary="Resolves an accessible Wiki page to a stable cross-module reference without exposing implementation internals.",
contract_version="1.0.0",
),
WIKI_DSAR_CAPABILITY: CapabilityDocumentation(
label="Wiki data-subject request provider",
summary="Exports bounded exact-identifier Wiki participation and produces manual-review erasure actions.",
contract_version="0.1.0",
),
},
search_sources=(
SearchSourceProviderRegistration(
id="wiki.pages", factory=create_wiki_search_source
),
),
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
wiki_models.WikiComment,
wiki_models.WikiPageRevision,
wiki_models.WikiPage,
wiki_models.WikiSpaceHistory,
wiki_models.WikiSpace,
label="Wiki",
),
retirement_notes="Destructive retirement removes Wiki spaces, pages, immutable revisions, and comments only after an explicit database snapshot and retention review; referenced resources remain module-owned.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
wiki_models.WikiSpace,
wiki_models.WikiPage,
wiki_models.WikiPageRevision,
wiki_models.WikiComment,
label="Wiki",
),
),
documentation=DOCUMENTATION,
architecture=ARCHITECTURE,
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
) )
@@ -0,0 +1 @@
"""Wiki Alembic migrations."""
@@ -0,0 +1 @@
"""Wiki migration revisions."""
@@ -0,0 +1,261 @@
"""v0.1.20 governed Wiki vertical slice.
Revision ID: a7c2e9f4b1d6
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a7c2e9f4b1d6"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"wiki_spaces",
sa.Column("id", sa.String(length=255), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("space_key", sa.String(length=120), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("visibility", sa.String(length=40), nullable=False),
sa.Column("acl_tokens", sa.JSON(), nullable=False),
sa.Column("publish_mode", sa.String(length=40), nullable=False),
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_wiki_spaces")),
sa.UniqueConstraint("tenant_id", "space_key", name="uq_wiki_space_key"),
)
for column in (
"tenant_id",
"space_key",
"visibility",
"archived_at",
"created_by",
"updated_by",
):
op.create_index(
op.f(f"ix_wiki_spaces_{column}"), "wiki_spaces", [column], unique=False
)
op.create_index(
"ix_wiki_space_catalog",
"wiki_spaces",
["tenant_id", "archived_at", "title"],
unique=False,
)
op.create_table(
"wiki_space_history",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("space_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=80), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("change_reason", sa.String(length=1000), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("snapshot", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["space_id"],
["wiki_spaces.id"],
name=op.f("fk_wiki_space_history_space_id_wiki_spaces"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_wiki_space_history")),
sa.UniqueConstraint(
"tenant_id", "space_id", "revision", name="uq_wiki_space_history_revision"
),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_wiki_space_history_idempotency"
),
)
for column in ("tenant_id", "space_id", "event_type", "recorded_at", "actor_id"):
op.create_index(
op.f(f"ix_wiki_space_history_{column}"),
"wiki_space_history",
[column],
unique=False,
)
op.create_index(
"ix_wiki_space_history_timeline",
"wiki_space_history",
["tenant_id", "space_id", "recorded_at"],
unique=False,
)
op.create_table(
"wiki_pages",
sa.Column("id", sa.String(length=255), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("space_id", sa.String(length=255), nullable=False),
sa.Column("parent_page_id", sa.String(length=255), nullable=True),
sa.Column("slug", sa.String(length=160), nullable=False),
sa.Column("path", sa.String(length=1000), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("published_revision", sa.Integer(), nullable=True),
sa.Column("state", sa.String(length=40), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("visibility", sa.String(length=40), nullable=False),
sa.Column("inherits_access", sa.Boolean(), nullable=False),
sa.Column("acl_tokens", sa.JSON(), nullable=False),
sa.Column("labels", sa.JSON(), nullable=False),
sa.Column("links", sa.JSON(), nullable=False),
sa.Column("redirect_page_id", sa.String(length=255), nullable=True),
sa.Column("search_text", sa.Text(), nullable=False),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["space_id"],
["wiki_spaces.id"],
name=op.f("fk_wiki_pages_space_id_wiki_spaces"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["parent_page_id"],
["wiki_pages.id"],
name=op.f("fk_wiki_pages_parent_page_id_wiki_pages"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["redirect_page_id"],
["wiki_pages.id"],
name=op.f("fk_wiki_pages_redirect_page_id_wiki_pages"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_wiki_pages")),
sa.UniqueConstraint("tenant_id", "space_id", "path", name="uq_wiki_page_path"),
)
for column in (
"tenant_id",
"space_id",
"parent_page_id",
"state",
"visibility",
"published_at",
"archived_at",
"created_by",
"updated_by",
):
op.create_index(
op.f(f"ix_wiki_pages_{column}"), "wiki_pages", [column], unique=False
)
op.create_index(
"ix_wiki_page_tree",
"wiki_pages",
["tenant_id", "space_id", "parent_page_id", "state"],
unique=False,
)
op.create_index(
"ix_wiki_page_catalog",
"wiki_pages",
["tenant_id", "state", "updated_at"],
unique=False,
)
op.create_table(
"wiki_page_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("page_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=80), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("change_reason", sa.String(length=1000), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("snapshot", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["page_id"],
["wiki_pages.id"],
name=op.f("fk_wiki_page_revisions_page_id_wiki_pages"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_wiki_page_revisions")),
sa.UniqueConstraint(
"tenant_id", "page_id", "revision", name="uq_wiki_page_revision"
),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_wiki_page_idempotency"
),
)
for column in ("tenant_id", "page_id", "event_type", "recorded_at", "actor_id"):
op.create_index(
op.f(f"ix_wiki_page_revisions_{column}"),
"wiki_page_revisions",
[column],
unique=False,
)
op.create_index(
"ix_wiki_page_revision_timeline",
"wiki_page_revisions",
["tenant_id", "page_id", "recorded_at"],
unique=False,
)
op.create_table(
"wiki_comments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("page_id", sa.String(length=255), nullable=False),
sa.Column("comment_id", sa.String(length=255), nullable=False),
sa.Column("page_revision", sa.Integer(), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["page_id"],
["wiki_pages.id"],
name=op.f("fk_wiki_comments_page_id_wiki_pages"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_wiki_comments")),
sa.UniqueConstraint("tenant_id", "comment_id", name="uq_wiki_comment"),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_wiki_comment_idempotency"
),
)
for column in ("tenant_id", "page_id", "comment_id", "created_by", "recorded_at"):
op.create_index(
op.f(f"ix_wiki_comments_{column}"), "wiki_comments", [column], unique=False
)
op.create_index(
"ix_wiki_comment_timeline",
"wiki_comments",
["tenant_id", "page_id", "recorded_at"],
unique=False,
)
def downgrade() -> None:
op.drop_table("wiki_comments")
op.drop_table("wiki_page_revisions")
op.drop_table("wiki_pages")
op.drop_table("wiki_space_history")
op.drop_table("wiki_spaces")
+361
View File
@@ -0,0 +1,361 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.concurrency import strong_resource_etag
from govoplan_core.db.session import get_session
from govoplan_wiki.backend.schemas import (
CommentRequest,
MutationRequest,
PageCreateRequest,
PageUpdateRequest,
SpaceCreateRequest,
SpaceUpdateRequest,
)
from govoplan_wiki.backend.service import (
WikiConflictError,
WikiNotFoundError,
WikiStoreError,
add_comment,
archive_page,
archive_space,
compare_revisions,
create_page,
create_space,
get_page,
get_space,
integration_availability,
list_comments,
list_pages,
list_spaces,
page_revisions,
publish_page,
update_page,
update_space,
)
def create_router(registry: object | None) -> APIRouter:
router = APIRouter(prefix="/wiki", tags=["wiki"])
@router.get("/availability")
def api_availability(
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
del principal
return integration_availability(registry)
@router.get("/spaces")
def api_list_spaces(
include_archived: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return {
"spaces": list(
list_spaces(session, principal, include_archived=include_archived)
)
}
@router.post("/spaces", status_code=status.HTTP_201_CREATED)
def api_create_space(
payload: SpaceCreateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return _write(
session,
response,
"wiki_space",
lambda: create_space(
session, principal, registry=registry, **payload.model_dump()
),
)
@router.get("/spaces/{space_id}")
def api_get_space(
space_id: str,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
item = get_space(session, principal, space_id=space_id)
if item is None:
raise HTTPException(status_code=404, detail="Wiki space not found")
_etag(response, "wiki_space", item)
return item
@router.patch("/spaces/{space_id}")
def api_update_space(
space_id: str,
payload: SpaceUpdateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return _write(
session,
response,
"wiki_space",
lambda: update_space(
session,
principal,
space_id=space_id,
registry=registry,
**payload.model_dump(),
),
)
@router.post("/spaces/{space_id}/archive")
def api_archive_space(
space_id: str,
payload: MutationRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
values = payload.model_dump()
values.pop("changes", None)
return _write(
session,
response,
"wiki_space",
lambda: archive_space(
session, principal, space_id=space_id, registry=registry, **values
),
)
@router.get("/pages")
def api_list_pages(
space_id: str | None = None,
parent_page_id: str | None = None,
page_state: list[str] | None = Query(default=None, alias="state"),
query: str = Query(default="", max_length=500),
include_archived: bool = False,
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
pages, total = list_pages(
session,
principal,
space_id=space_id,
parent_page_id=parent_page_id,
states=page_state or (),
query=query,
include_archived=include_archived,
offset=offset,
limit=limit,
)
return {"pages": list(pages), "total": total, "offset": offset, "limit": limit}
@router.post("/pages", status_code=status.HTTP_201_CREATED)
def api_create_page(
payload: PageCreateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return _write(
session,
response,
"wiki_page",
lambda: create_page(
session, principal, registry=registry, **payload.model_dump()
),
)
@router.get("/pages/{page_id}")
def api_get_page(
page_id: str,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
item = get_page(session, principal, page_id=page_id)
if item is None:
raise HTTPException(status_code=404, detail="Wiki page not found")
_etag(response, "wiki_page", item)
return item
@router.patch("/pages/{page_id}")
def api_update_page(
page_id: str,
payload: PageUpdateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return _write(
session,
response,
"wiki_page",
lambda: update_page(
session,
principal,
page_id=page_id,
registry=registry,
**payload.model_dump(),
),
)
@router.post("/pages/{page_id}/publish")
def api_publish_page(
page_id: str,
payload: MutationRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return _write(
session,
response,
"wiki_page",
lambda: publish_page(
session,
principal,
page_id=page_id,
registry=registry,
**payload.model_dump(),
),
)
@router.post("/pages/{page_id}/archive")
def api_archive_page(
page_id: str,
payload: MutationRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return _write(
session,
response,
"wiki_page",
lambda: archive_page(
session,
principal,
page_id=page_id,
registry=registry,
**payload.model_dump(),
),
)
@router.get("/pages/{page_id}/revisions")
def api_page_revisions(
page_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
try:
return {
"revisions": list(page_revisions(session, principal, page_id=page_id))
}
except Exception as exc:
raise _error(exc) from exc
@router.get("/pages/{page_id}/compare")
def api_compare_revisions(
page_id: str,
from_revision: int = Query(ge=1),
to_revision: int = Query(ge=1),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
try:
return compare_revisions(
session,
principal,
page_id=page_id,
from_revision=from_revision,
to_revision=to_revision,
)
except Exception as exc:
raise _error(exc) from exc
@router.get("/pages/{page_id}/comments")
def api_list_comments(
page_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
try:
return {
"comments": list(list_comments(session, principal, page_id=page_id))
}
except Exception as exc:
raise _error(exc) from exc
@router.post("/pages/{page_id}/comments")
def api_add_comment(
page_id: str,
payload: CommentRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
try:
comment = add_comment(
session,
principal,
page_id=page_id,
registry=registry,
**payload.model_dump(),
)
session.commit()
response.headers["ETag"] = strong_resource_etag(
"wiki_page", page_id, int(comment["page_revision"])
)
return comment
except Exception as exc:
session.rollback()
raise _error(exc) from exc
return router
def _write(
session: Session, response: Response, resource: str, operation
) -> dict[str, object]:
try:
item = operation()
session.commit()
except Exception as exc:
session.rollback()
raise _error(exc) from exc
_etag(response, resource, item)
return item
def _etag(response: Response, resource: str, item: dict[str, object]) -> None:
resource_id = str(item["page_id"] if resource == "wiki_page" else item["space_id"])
response.headers["ETag"] = strong_resource_etag(
resource, resource_id, int(item["revision"])
)
def _error(exc: Exception) -> HTTPException:
if isinstance(exc, HTTPException):
return exc
if isinstance(exc, WikiNotFoundError):
code = 404
elif isinstance(exc, PermissionError):
code = 403
elif (
isinstance(exc, (WikiConflictError, IntegrityError))
or "conflict" in str(exc).casefold()
):
code = 409
elif isinstance(exc, WikiStoreError):
code = 400
else:
code = 500
return HTTPException(status_code=code, detail=str(exc))
__all__ = ["create_router"]
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class MutationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
class SpaceCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
space_id: str = Field(min_length=1, max_length=255)
space_key: str = Field(min_length=1, max_length=120)
title: str = Field(min_length=1, max_length=500)
description: str = Field(default="", max_length=20_000)
visibility: str = Field(default="tenant", max_length=40)
acl_tokens: list[str] = Field(default_factory=list, max_length=500)
publish_mode: str = Field(default="publishers", max_length=40)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
class SpaceUpdateRequest(MutationRequest):
changes: dict[str, Any]
class PageCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
page_id: str = Field(min_length=1, max_length=255)
space_id: str = Field(min_length=1, max_length=255)
parent_page_id: str | None = Field(default=None, max_length=255)
slug: str = Field(min_length=1, max_length=160)
title: str = Field(min_length=1, max_length=500)
body: str = Field(default="", max_length=500_000)
summary: str = Field(default="", max_length=10_000)
inherits_access: bool = True
visibility: str = Field(default="tenant", max_length=40)
acl_tokens: list[str] = Field(default_factory=list, max_length=500)
labels: list[str] = Field(default_factory=list, max_length=50)
links: list[dict[str, Any]] = Field(default_factory=list, max_length=200)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
class PageUpdateRequest(MutationRequest):
changes: dict[str, Any]
class CommentRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
comment_id: str = Field(min_length=1, max_length=255)
body: str = Field(min_length=1, max_length=20_000)
recorded_at: datetime
idempotency_key: str = Field(min_length=1, max_length=255)
__all__ = [
"CommentRequest",
"MutationRequest",
"PageCreateRequest",
"PageUpdateRequest",
"SpaceCreateRequest",
"SpaceUpdateRequest",
]
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchIndexChange,
SearchResourceReference,
SearchResourceType,
)
from govoplan_wiki.backend.db.models import WikiPage, WikiPageRevision, WikiSpace
from govoplan_wiki.backend.service import can_read_page
PROVIDER_ID = "wiki.pages"
RESOURCE_TYPE = "wiki_page"
class WikiSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="wiki",
resource_type=RESOURCE_TYPE,
label="Wiki pages",
requires_authorization_recheck=True,
),
)
def backfill(
self, session: object, *, request: SearchBackfillRequest
) -> SearchBackfillPage:
_assert_source(request.provider_id, request.resource_type)
db = _session(session)
query = select(WikiPage).where(
WikiPage.tenant_id == request.tenant_id,
WikiPage.published_revision.is_not(None),
WikiPage.state != "archived",
)
if request.cursor:
query = query.where(WikiPage.id > request.cursor)
rows = tuple(
db.scalars(query.order_by(WikiPage.id.asc()).limit(request.limit + 1))
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
spaces = _spaces(db, request.tenant_id, selected)
documents = tuple(
item
for row in selected
if (item := _document(db, row, spaces.get(row.space_id))) is not None
)
watermark = db.scalar(
select(func.max(WikiPage.updated_at)).where(
WikiPage.tenant_id == request.tenant_id,
WikiPage.published_revision.is_not(None),
WikiPage.state != "archived",
)
)
return SearchBackfillPage(
documents=documents,
next_cursor=selected[-1].id if has_more and selected else None,
complete=not has_more,
high_watermark=watermark.isoformat() if watermark else None,
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
db = _session(session)
tenant_id = str(getattr(principal, "tenant_id", "") or "")
decisions = {item.reference.key: False for item in requests}
for request in requests:
ref = request.reference
if (
ref.tenant_id != tenant_id
or ref.module_id != "wiki"
or ref.resource_type != RESOURCE_TYPE
):
continue
row = db.scalar(
select(WikiPage).where(
WikiPage.tenant_id == tenant_id, WikiPage.id == ref.resource_id
)
)
decisions[ref.key] = bool(
row is not None
and row.published_revision is not None
and row.state != "archived"
and can_read_page(db, principal, row)
)
return decisions
def index_changes_for_event(
self, session: object, *, event: PlatformEvent, delivery_key: str
) -> Sequence[SearchIndexChange]:
if (
event.module_id != "wiki"
or event.tenant is None
or event.resource is None
or event.resource.type != RESOURCE_TYPE
or event.resource.id is None
):
return ()
db = _session(session)
row = db.scalar(
select(WikiPage).where(
WikiPage.tenant_id == event.tenant.id, WikiPage.id == event.resource.id
)
)
space = (
db.scalar(
select(WikiSpace).where(
WikiSpace.tenant_id == event.tenant.id, WikiSpace.id == row.space_id
)
)
if row
else None
)
document = (
None
if row is None or row.published_revision is None or row.state == "archived"
else _document(db, row, space, change_cursor=event.event_id)
)
reference = SearchResourceReference(
tenant_id=event.tenant.id,
module_id="wiki",
resource_type=RESOURCE_TYPE,
resource_id=event.resource.id,
)
return (
SearchIndexChange(
change_id=f"{delivery_key}:{PROVIDER_ID}",
provider_id=PROVIDER_ID,
kind="upsert" if document else "delete",
reference=reference,
source_revision=document.source_revision
if document
else event.event_id,
cursor=event.event_id,
document=document,
occurred_at=event.occurred_at,
),
)
def create_wiki_search_source(_context: ModuleContext) -> WikiSearchSource:
return WikiSearchSource()
def _document(
db: Session,
row: WikiPage,
space: WikiSpace | None,
*,
change_cursor: str | None = None,
) -> SearchDocument | None:
if space is None or row.published_revision is None:
return None
revision = db.scalar(
select(WikiPageRevision).where(
WikiPageRevision.tenant_id == row.tenant_id,
WikiPageRevision.page_id == row.id,
WikiPageRevision.revision == row.published_revision,
)
)
if revision is None:
return None
snapshot = revision.snapshot
inherited = bool(snapshot.get("inherits_access"))
visibility = (
space.visibility
if inherited
else str(snapshot.get("effective_visibility") or space.visibility)
)
effective_tokens = (
space.acl_tokens if inherited else snapshot.get("effective_acl_tokens") or ()
)
acl_tokens = (
tuple(str(item) for item in effective_tokens)
if visibility == "restricted"
else ()
)
labels = tuple(str(item)[:200] for item in snapshot.get("labels") or ())
links = snapshot.get("links") or ()
link_labels = tuple(
str(item.get("label") or item.get("resource_id") or "")[:200]
for item in links
if isinstance(item, Mapping)
)
return SearchDocument(
tenant_id=row.tenant_id,
module_id="wiki",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=row.id,
title=str(snapshot.get("title") or row.title),
url=f"/wiki?pageId={quote(row.id, safe='')}",
summary=str(snapshot.get("summary") or "")[:2_000],
body=str(snapshot.get("body") or "")[:200_000],
keywords=tuple(dict.fromkeys((*labels, *link_labels, row.path)))[:200],
visibility=visibility,
acl_tokens=acl_tokens,
metadata={
"space_id": row.space_id,
"path": snapshot.get("path"),
"labels": list(labels),
"published_revision": row.published_revision,
"redirect_page_id": snapshot.get("redirect_page_id"),
},
source_revision=str(row.published_revision),
change_cursor=change_cursor,
source_updated_at=revision.recorded_at,
requires_authorization_recheck=True,
)
def _spaces(
db: Session, tenant_id: str, rows: Sequence[WikiPage]
) -> dict[str, WikiSpace]:
ids = tuple(dict.fromkeys(row.space_id for row in rows))
if not ids:
return {}
return {
row.id: row
for row in db.scalars(
select(WikiSpace).where(
WikiSpace.tenant_id == tenant_id, WikiSpace.id.in_(ids)
)
)
}
def _assert_source(provider_id: str, resource_type: str) -> None:
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
raise ValueError("Unsupported Wiki search source.")
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Wiki search requires a SQLAlchemy session.")
return value
__all__ = [
"PROVIDER_ID",
"RESOURCE_TYPE",
"WikiSearchSource",
"create_wiki_search_source",
]
File diff suppressed because it is too large Load Diff
+25 -17
View File
@@ -2,37 +2,45 @@ from __future__ import annotations
import unittest import unittest
from govoplan_wiki.backend.manifest import ( from govoplan_wiki.backend.dsar_provider import WIKI_DSAR_CAPABILITY
from govoplan_wiki.backend.manifest import get_manifest
from govoplan_wiki.backend.service import (
ADMIN_SCOPE, ADMIN_SCOPE,
CAPABILITY_WIKI_REGISTRY,
COMMENT_SCOPE,
PUBLISH_SCOPE,
READ_SCOPE, READ_SCOPE,
WRITE_SCOPE, WRITE_SCOPE,
get_manifest,
) )
class WikiManifestTests(unittest.TestCase): class WikiManifestTests(unittest.TestCase):
def test_manifest_registers_domain_seed(self) -> None: def test_manifest_registers_complete_vertical_slice(self) -> None:
manifest = get_manifest() manifest = get_manifest()
self.assertEqual("wiki", manifest.id) self.assertEqual("wiki", manifest.id)
self.assertEqual("0.1.22", manifest.version)
self.assertEqual(("access",), manifest.dependencies) self.assertEqual(("access",), manifest.dependencies)
self.assertEqual( self.assertEqual(
{READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}, {READ_SCOPE, WRITE_SCOPE, COMMENT_SCOPE, PUBLISH_SCOPE, ADMIN_SCOPE},
{permission.scope for permission in manifest.permissions}, {item.scope for item in manifest.permissions},
) )
self.assertIn("search", manifest.optional_dependencies) self.assertEqual(
self.assertTrue(manifest.documentation) {CAPABILITY_WIKI_REGISTRY, WIKI_DSAR_CAPABILITY},
topic = manifest.documentation[0] {item.name for item in manifest.provides_interfaces},
self.assertEqual("reference", topic.metadata["kind"])
self.assertIn("seed_boundary", topic.metadata["consequence_classes"])
self.assertTrue(
all(
topic.translations.get("de", {}).get(field)
for field in ("title", "summary", "body")
) )
self.assertIsNotNone(manifest.route_factory)
self.assertEqual("@govoplan/wiki-webui", manifest.frontend.package_name)
self.assertEqual(
("wiki.pages",), tuple(item.id for item in manifest.search_sources)
) )
self.assertIsNone(manifest.route_factory) self.assertIsNotNone(manifest.migration_spec)
self.assertIsNone(manifest.frontend) self.assertTrue(manifest.migration_spec.retirement_supported)
self.assertTrue(manifest.uninstall_guard_providers)
self.assertEqual("vertical_slice", manifest.architecture.maturity)
self.assertGreaterEqual(len(manifest.documentation), 5)
for topic in manifest.documentation:
self.assertIn("de", topic.translations)
self.assertTrue(topic.metadata.get("kind"))
if __name__ == "__main__": if __name__ == "__main__":
+390
View File
@@ -0,0 +1,390 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.dsar import DsarSubjectRef
from govoplan_core.core.search import SearchBackfillRequest
from govoplan_core.db.base import Base
from govoplan_wiki.backend.db.models import (
WikiComment,
WikiPageRevision,
WikiSpaceHistory,
)
from govoplan_wiki.backend.dsar_provider import WikiDsarProvider
from govoplan_wiki.backend.search_source import (
PROVIDER_ID,
RESOURCE_TYPE,
WikiSearchSource,
)
from govoplan_wiki.backend.service import (
ADMIN_SCOPE,
COMMENT_SCOPE,
PUBLISH_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
WikiConflictError,
add_comment,
archive_page,
compare_revisions,
create_page,
create_space,
get_page,
list_comments,
list_pages,
page_revisions,
publish_page,
update_page,
)
NOW = datetime(2026, 8, 22, 10, 0, tzinfo=UTC)
class _Principal:
def __init__(
self,
account_id: str,
scopes: set[str],
tenant_id: str = "tenant-1",
groups: tuple[str, ...] = (),
) -> None:
self.account_id = account_id
self.identity_id = None
self.membership_id = f"membership-{account_id}"
self.tenant_id = tenant_id
self.scopes = frozenset(scopes)
self.group_ids = frozenset(groups)
self.role_ids = frozenset()
self.function_assignment_ids = frozenset()
self.acting_assignment_id = None
self.user = SimpleNamespace(id=account_id)
def has(self, scope: str) -> bool:
return scope in self.scopes
class WikiServiceTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.Session = sessionmaker(bind=engine, expire_on_commit=False)
all_scopes = {
READ_SCOPE,
WRITE_SCOPE,
COMMENT_SCOPE,
PUBLISH_SCOPE,
ADMIN_SCOPE,
}
self.admin = _Principal("admin-1", all_scopes)
self.editor = _Principal(
"editor-1", {READ_SCOPE, WRITE_SCOPE, COMMENT_SCOPE}, groups=("knowledge",)
)
self.publisher = _Principal(
"publisher-1",
{READ_SCOPE, PUBLISH_SCOPE},
groups=("knowledge",),
)
self.reader = _Principal("reader-1", {READ_SCOPE}, groups=("knowledge",))
self.outsider = _Principal("outsider-1", {READ_SCOPE})
def test_publication_keeps_last_published_revision_visible_during_new_draft(
self,
) -> None:
with self.Session() as session:
self._space(session)
created = self._page(session)
published = publish_page(
session,
self.publisher,
page_id="page-1",
expected_revision=created["revision"],
recorded_at=NOW + timedelta(minutes=1),
change_reason="Reviewed for publication.",
idempotency_key="publish-1",
)
publish_replay = publish_page(
session,
self.publisher,
page_id="page-1",
expected_revision=created["revision"],
recorded_at=NOW + timedelta(minutes=1),
change_reason="Reviewed for publication.",
idempotency_key="publish-1",
)
session.commit()
self.assertEqual(published, publish_replay)
self.assertEqual(2, published["published_revision"])
self.assertEqual(
"Published body",
get_page(session, self.reader, page_id="page-1")["body"],
)
draft = update_page(
session,
self.editor,
page_id="page-1",
expected_revision=2,
changes={"body": "Next draft body", "labels": ["Guide", "Updated"]},
recorded_at=NOW + timedelta(minutes=2),
change_reason="Prepare the next edition.",
idempotency_key="edit-1",
)
draft_replay = update_page(
session,
self.editor,
page_id="page-1",
expected_revision=2,
changes={"body": "Next draft body", "labels": ["Guide", "Updated"]},
recorded_at=NOW + timedelta(minutes=2),
change_reason="Prepare the next edition.",
idempotency_key="edit-1",
)
session.commit()
self.assertEqual(draft, draft_replay)
self.assertEqual("draft", draft["state"])
self.assertEqual(
"Next draft body",
get_page(session, self.editor, page_id="page-1")["body"],
)
reader_view = get_page(session, self.reader, page_id="page-1")
self.assertEqual("Published body", reader_view["body"])
self.assertEqual(2, reader_view["revision"])
source = WikiSearchSource()
page = source.backfill(
session,
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
rebuild_id="wiki-test",
limit=50,
),
)
self.assertEqual(1, len(page.documents))
self.assertEqual("Published body", page.documents[0].body)
self.assertEqual("2", page.documents[0].source_revision)
diff = compare_revisions(
session, self.editor, page_id="page-1", from_revision=2, to_revision=3
)
self.assertIn("Next draft body", diff["diff"])
self.assertEqual(
3, len(page_revisions(session, self.editor, page_id="page-1"))
)
def test_hierarchy_acl_occ_replay_links_comments_and_archive_are_governed(
self,
) -> None:
with self.Session() as session:
self._space(session)
created = self._page(session)
replay = self._page(session)
self.assertEqual(created, replay)
self.assertEqual(1, session.query(WikiPageRevision).count())
self.assertIsNone(get_page(session, self.outsider, page_id="page-1"))
self.assertEqual(0, list_pages(session, self.reader, space_id="space-1")[1])
other_tenant = _Principal(
"reader-1", {READ_SCOPE}, tenant_id="tenant-2", groups=("knowledge",)
)
self.assertIsNone(get_page(session, other_tenant, page_id="page-1"))
session.commit()
with self.assertRaises(WikiConflictError):
update_page(
session,
self.editor,
page_id="page-1",
expected_revision=99,
changes={"title": "Stale"},
recorded_at=NOW + timedelta(minutes=1),
change_reason="Stale edit.",
idempotency_key="stale-1",
)
session.rollback()
comment = add_comment(
session,
self.editor,
page_id="page-1",
expected_revision=1,
comment_id="comment-1",
body="Please verify the source.",
recorded_at=NOW + timedelta(minutes=2),
idempotency_key="comment-1",
)
replay_comment = add_comment(
session,
self.editor,
page_id="page-1",
expected_revision=1,
comment_id="comment-1",
body="Please verify the source.",
recorded_at=NOW + timedelta(minutes=2),
idempotency_key="comment-1",
)
self.assertEqual(comment, replay_comment)
self.assertEqual(
1, len(list_comments(session, self.editor, page_id="page-1"))
)
self.assertEqual(1, session.query(WikiComment).count())
published = publish_page(
session,
self.publisher,
page_id="page-1",
expected_revision=1,
recorded_at=NOW + timedelta(minutes=3),
change_reason="Approved.",
idempotency_key="publish-archive",
)
replay_after_revision = add_comment(
session,
self.editor,
page_id="page-1",
expected_revision=1,
comment_id="comment-1",
body="Please verify the source.",
recorded_at=NOW + timedelta(minutes=2),
idempotency_key="comment-1",
)
self.assertEqual(comment, replay_after_revision)
archived = archive_page(
session,
self.publisher,
page_id="page-1",
expected_revision=published["revision"],
recorded_at=NOW + timedelta(minutes=4),
change_reason="Superseded.",
idempotency_key="archive-1",
)
session.commit()
self.assertEqual("archived", archived["state"])
self.assertIsNone(get_page(session, self.reader, page_id="page-1"))
self.assertEqual(
0,
len(
WikiSearchSource()
.backfill(
session,
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
rebuild_id="wiki-archive-test",
limit=10,
),
)
.documents
),
)
def test_dsar_exports_only_exact_subject_participation_and_manual_review(
self,
) -> None:
with self.Session() as session:
self._space(session)
self._page(session)
add_comment(
session,
self.editor,
page_id="page-1",
expected_revision=1,
comment_id="comment-dsar",
body="My attributable contribution.",
recorded_at=NOW + timedelta(minutes=1),
idempotency_key="comment-dsar",
)
session.commit()
provider = WikiDsarProvider()
subject = DsarSubjectRef(account_id="editor-1")
records = provider.search_subject(
session, tenant_id="tenant-1", subject=subject
)
self.assertEqual(1, len(records))
self.assertEqual(
"My attributable contribution.",
records[0].data["subject_comments"][0]["body"],
)
self.assertNotIn("body", records[0].data)
self.assertEqual(
(),
provider.search_subject(
session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="editor-1",
external_references={"wiki.account": "different-account"},
),
),
)
actions = provider.plan_erasure(
session, tenant_id="tenant-1", subject=subject, records=records
)
self.assertEqual("manual_review", actions[0].kind)
self.assertFalse(actions[0].executable)
result = provider.execute_erasure(
session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
self.assertEqual("blocked", result[0].status)
def _space(self, session):
item = create_space(
session,
self.admin,
space_id="space-1",
space_key="knowledge",
title="Knowledge",
description="Governed knowledge",
visibility="restricted",
acl_tokens=["group:knowledge"],
publish_mode="publishers",
recorded_at=NOW,
change_reason="Create governed space.",
idempotency_key="space-1",
)
self.assertEqual(1, session.query(WikiSpaceHistory).count())
return item
def _page(self, session):
return create_page(
session,
self.editor,
page_id="page-1",
space_id="space-1",
parent_page_id=None,
slug="service-guide",
title="Service guide",
body="Published body",
summary="How to deliver the service.",
inherits_access=True,
visibility="tenant",
acl_tokens=[],
labels=["Guide"],
links=[
{
"link_id": "file-1",
"kind": "attachment",
"owner_module": "files",
"resource_type": "file",
"resource_id": "file-1",
"label": "Source",
"url": "/files/file-1",
}
],
recorded_at=NOW,
change_reason="Create the guide.",
idempotency_key="page-1",
)
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@govoplan/wiki-webui",
"version": "0.1.22",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/wiki.css": "./src/styles/wiki.css"
},
"scripts": {
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.31",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const page = fs.readFileSync("src/features/wiki/WikiPage.tsx", "utf8");
const styles = fs.readFileSync("src/styles/wiki.css", "utf8");
assert.ok(page.includes("WorkspaceActionBar"), "Wiki uses the semantic shared action bar");
assert.ok(page.includes("refreshable"), "The refreshable Wiki workspace exposes Reload");
assert.ok(page.includes("DocumentationHelpLink"), "Wiki exposes configured-system help");
assert.ok(page.includes("PageScrollViewport"), "Wiki owns bounded tree and detail scrolling");
assert.ok(page.includes("SelectionList"), "Wiki uses the shared semantic selection list");
assert.ok(page.includes('<Dialog open={open} title={record ? "Edit Wiki page"'), "Wiki editing uses the shared focus-contained dialog");
assert.ok(page.includes('size="large"'), "Complex Wiki forms use the approved large dialog size");
assert.ok(page.includes("FieldLabel"), "Wiki fields use the shared label and help contract");
assert.ok(page.includes("StatusBadge"), "Wiki lifecycle state is not conveyed by color alone");
assert.ok(page.includes("<ConfirmDialog"), "Wiki archive operations are separately confirmed");
assert.ok(page.includes("wiki-destructive-actions"), "Page archival is visually separated from ordinary actions");
assert.ok(page.includes("listWikiRevisions"), "The workspace projects immutable revision history");
assert.ok(page.includes("compareWikiRevisions"), "The workspace offers revision comparison");
assert.ok(!page.includes("window.alert("), "Wiki must not use browser alerts");
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Wiki uses semantic interactive elements");
assert.ok(styles.includes("@media (max-width: 760px)"), "Wiki retains a responsive tree-detail layout");
assert.ok(styles.includes(":focus-visible"), "Wiki retains visible keyboard focus");
console.log("Wiki interface pattern contract passed.");
+147
View File
@@ -0,0 +1,147 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type WikiLink = {
link_id: string;
kind: "attachment" | "related" | "reference" | "external";
owner_module: string;
resource_type: string;
resource_id: string;
label?: string | null;
url?: string | null;
external_system?: string | null;
external_id?: string | null;
provenance: Record<string, unknown>;
};
export type WikiSpace = {
space_id: string;
tenant_id: string;
space_key: string;
revision: number;
title: string;
description: string;
visibility: "tenant" | "restricted";
acl_tokens: string[];
publish_mode: "publishers" | "editors";
archived_at?: string | null;
};
export type WikiPageRecord = {
page_id: string;
tenant_id: string;
space_id: string;
parent_page_id?: string | null;
slug: string;
path: string;
revision: number;
published_revision?: number | null;
state: "draft" | "published" | "redirected" | "archived";
title: string;
body: string;
summary: string;
inherits_access: boolean;
visibility: "inherit" | "tenant" | "restricted";
effective_visibility: "tenant" | "restricted";
acl_tokens: string[];
effective_acl_tokens: string[];
labels: string[];
links: WikiLink[];
redirect_page_id?: string | null;
published_at?: string | null;
archived_at?: string | null;
};
export type WikiRevision = {
revision: number;
event_type: string;
recorded_at: string;
actor_id?: string | null;
change_reason: string;
snapshot: WikiPageRecord;
};
export type WikiComment = {
comment_id: string;
page_id: string;
page_revision: number;
body: string;
created_by?: string | null;
recorded_at: string;
};
export type WikiAvailability = {
files: boolean;
dms: boolean;
search: boolean;
templates: boolean;
workflow_engine: boolean;
projects: boolean;
cases: boolean;
notifications: boolean;
consequences: Record<string, string>;
};
export function getWikiAvailability(settings: ApiSettings, signal?: AbortSignal): Promise<WikiAvailability> {
return apiFetch(settings, "/api/v1/wiki/availability", { signal });
}
export function listWikiSpaces(settings: ApiSettings, signal?: AbortSignal): Promise<{ spaces: WikiSpace[] }> {
return apiFetch(settings, "/api/v1/wiki/spaces", { signal });
}
export function createWikiSpace(settings: ApiSettings, values: Omit<WikiSpace, "tenant_id" | "revision" | "archived_at" | "description">, description: string): Promise<WikiSpace> {
return apiFetch(settings, "/api/v1/wiki/spaces", { method: "POST", body: JSON.stringify({ ...values, description, recorded_at: new Date().toISOString(), change_reason: "Created governed Wiki space.", idempotency_key: crypto.randomUUID() }) });
}
export function updateWikiSpace(settings: ApiSettings, record: WikiSpace, changes: Record<string, unknown>, reason: string): Promise<WikiSpace> {
return apiFetch(settings, `/api/v1/wiki/spaces/${encodeURIComponent(record.space_id)}`, { method: "PATCH", body: JSON.stringify(mutation(record.revision, reason, changes)) });
}
export function archiveWikiSpace(settings: ApiSettings, record: WikiSpace): Promise<WikiSpace> {
return apiFetch(settings, `/api/v1/wiki/spaces/${encodeURIComponent(record.space_id)}/archive`, { method: "POST", body: JSON.stringify(mutation(record.revision, "Archived Wiki space.")) });
}
export function listWikiPages(settings: ApiSettings, options: { spaceId?: string; query?: string; includeArchived?: boolean } = {}, signal?: AbortSignal): Promise<{ pages: WikiPageRecord[]; total: number }> {
return apiFetch(settings, apiPath("/api/v1/wiki/pages", { space_id: options.spaceId, query: options.query, include_archived: options.includeArchived, limit: 200 }), { signal });
}
export function getWikiPage(settings: ApiSettings, pageId: string, signal?: AbortSignal): Promise<WikiPageRecord> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(pageId)}`, { signal });
}
export function createWikiPage(settings: ApiSettings, values: Omit<WikiPageRecord, "tenant_id" | "revision" | "published_revision" | "state" | "path" | "effective_visibility" | "effective_acl_tokens" | "published_at" | "archived_at">, reason: string): Promise<WikiPageRecord> {
return apiFetch(settings, "/api/v1/wiki/pages", { method: "POST", body: JSON.stringify({ ...values, recorded_at: new Date().toISOString(), change_reason: reason, idempotency_key: crypto.randomUUID() }) });
}
export function updateWikiPage(settings: ApiSettings, record: WikiPageRecord, changes: Record<string, unknown>, reason: string): Promise<WikiPageRecord> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(record.page_id)}`, { method: "PATCH", body: JSON.stringify(mutation(record.revision, reason, changes)) });
}
export function publishWikiPage(settings: ApiSettings, record: WikiPageRecord): Promise<WikiPageRecord> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(record.page_id)}/publish`, { method: "POST", body: JSON.stringify(mutation(record.revision, "Published reviewed Wiki revision.")) });
}
export function archiveWikiPage(settings: ApiSettings, record: WikiPageRecord): Promise<WikiPageRecord> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(record.page_id)}/archive`, { method: "POST", body: JSON.stringify(mutation(record.revision, "Archived superseded Wiki page.")) });
}
export function listWikiRevisions(settings: ApiSettings, pageId: string, signal?: AbortSignal): Promise<{ revisions: WikiRevision[] }> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(pageId)}/revisions`, { signal });
}
export function compareWikiRevisions(settings: ApiSettings, pageId: string, fromRevision: number, toRevision: number): Promise<{ diff: string; truncated: boolean }> {
return apiFetch(settings, apiPath(`/api/v1/wiki/pages/${encodeURIComponent(pageId)}/compare`, { from_revision: fromRevision, to_revision: toRevision }));
}
export function listWikiComments(settings: ApiSettings, pageId: string, signal?: AbortSignal): Promise<{ comments: WikiComment[] }> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(pageId)}/comments`, { signal });
}
export function addWikiComment(settings: ApiSettings, record: WikiPageRecord, body: string): Promise<WikiComment> {
return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(record.page_id)}/comments`, { method: "POST", body: JSON.stringify({ expected_revision: record.revision, comment_id: crypto.randomUUID(), body, recorded_at: new Date().toISOString(), idempotency_key: crypto.randomUUID() }) });
}
function mutation(revision: number, reason: string, changes?: Record<string, unknown>) {
return { expected_revision: revision, recorded_at: new Date().toISOString(), change_reason: reason, idempotency_key: crypto.randomUUID(), ...(changes ? { changes } : {}) };
}
+282
View File
@@ -0,0 +1,282 @@
import { Archive, BookOpen, FilePlus2, History, Link2, MessageSquarePlus, Pencil, Plus, Search, Send, Settings2, Upload } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Button, ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, FieldLabel, FilterBar, FormLayout, LoadingIndicator, PageScrollViewport, SelectionList, SelectionListItem, SelectionListItemContent, StatePanel, StatusBadge, WorkspaceActionBar, WorkspaceFrame, hasScope, type PlatformRouteContext } from "@govoplan/core-webui";
import { addWikiComment, archiveWikiPage, archiveWikiSpace, compareWikiRevisions, createWikiPage, createWikiSpace, getWikiAvailability, getWikiPage, listWikiComments, listWikiPages, listWikiRevisions, listWikiSpaces, publishWikiPage, updateWikiPage, updateWikiSpace, type WikiAvailability, type WikiComment, type WikiLink, type WikiPageRecord, type WikiRevision, type WikiSpace } from "../../api/wiki";
type ArchiveTarget = { kind: "page"; record: WikiPageRecord } | { kind: "space"; record: WikiSpace } | null;
export default function WikiPage({ settings, auth }: PlatformRouteContext) {
const [spaces, setSpaces] = useState<WikiSpace[]>([]);
const [pages, setPages] = useState<WikiPageRecord[]>([]);
const [selectedSpaceId, setSelectedSpaceId] = useState("");
const [selectedPageId, setSelectedPageId] = useState("");
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [revisions, setRevisions] = useState<WikiRevision[]>([]);
const [comments, setComments] = useState<WikiComment[]>([]);
const [availability, setAvailability] = useState<WikiAvailability | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [dialogError, setDialogError] = useState("");
const [spaceDialog, setSpaceDialog] = useState<WikiSpace | "new" | null>(null);
const [pageDialog, setPageDialog] = useState<WikiPageRecord | "new" | null>(null);
const [linkOpen, setLinkOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [compareText, setCompareText] = useState("");
const [archiveTarget, setArchiveTarget] = useState<ArchiveTarget>(null);
const canWrite = hasScope(auth, "wiki:page:write");
const canComment = canWrite || hasScope(auth, "wiki:page:comment");
const canPublish = hasScope(auth, "wiki:page:publish") || hasScope(auth, "wiki:space:admin");
const canAdmin = hasScope(auth, "wiki:space:admin");
async function reload(signal?: AbortSignal, preferredSpaceId?: string) {
setLoading(true); setError("");
try {
const [spaceResult, integrationState] = await Promise.all([listWikiSpaces(settings, signal), getWikiAvailability(settings, signal)]);
const activeSpace = preferredSpaceId && spaceResult.spaces.some((item) => item.space_id === preferredSpaceId)
? preferredSpaceId
: selectedSpaceId && spaceResult.spaces.some((item) => item.space_id === selectedSpaceId) ? selectedSpaceId : spaceResult.spaces[0]?.space_id ?? "";
const pageResult = activeSpace ? await listWikiPages(settings, { spaceId: activeSpace, query: submittedQuery }, signal) : { pages: [], total: 0 };
setSpaces(spaceResult.spaces); setAvailability(integrationState); setSelectedSpaceId(activeSpace); setPages(pageResult.pages);
setSelectedPageId((current) => pageResult.pages.some((item) => item.page_id === current) ? current : pageResult.pages[0]?.page_id ?? "");
} catch (reason) {
if ((reason as Error).name !== "AbortError") setError(message(reason, "Wiki content could not be loaded."));
} finally { setLoading(false); }
}
useEffect(() => {
const controller = new AbortController(); void reload(controller.signal);
return () => controller.abort();
}, [settings, submittedQuery]);
useEffect(() => {
if (!selectedSpaceId) return;
const controller = new AbortController();
setLoading(true);
void listWikiPages(settings, { spaceId: selectedSpaceId, query: submittedQuery }, controller.signal).then((result) => {
setPages(result.pages); setSelectedPageId((current) => result.pages.some((item) => item.page_id === current) ? current : result.pages[0]?.page_id ?? "");
}).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(message(reason, "Wiki pages could not be loaded.")); }).finally(() => setLoading(false));
return () => controller.abort();
}, [settings, selectedSpaceId]);
const selectedSpace = useMemo(() => spaces.find((item) => item.space_id === selectedSpaceId) ?? null, [spaces, selectedSpaceId]);
const selectedPage = useMemo(() => pages.find((item) => item.page_id === selectedPageId) ?? null, [pages, selectedPageId]);
useEffect(() => {
if (!selectedPageId) { setRevisions([]); setComments([]); return; }
const controller = new AbortController();
const requests: Promise<unknown>[] = [getWikiPage(settings, selectedPageId, controller.signal).then((record) => setPages((current) => current.map((item) => item.page_id === record.page_id ? record : item))), listWikiComments(settings, selectedPageId, controller.signal).then((result) => setComments(result.comments))];
if (canWrite || canPublish) requests.push(listWikiRevisions(settings, selectedPageId, controller.signal).then((result) => setRevisions(result.revisions)));
void Promise.all(requests).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(message(reason, "Wiki page details could not be loaded.")); });
return () => controller.abort();
}, [settings, selectedPageId, canWrite, canPublish]);
function submitSearch(event: FormEvent) { event.preventDefault(); setSubmittedQuery(query.trim()); }
async function runAction(action: () => Promise<WikiPageRecord>, close?: () => void) {
setSaving(true); setDialogError("");
try {
const saved = await action(); close?.(); await reload(undefined, saved.space_id); setSelectedPageId(saved.page_id);
} catch (reason) { setDialogError(message(reason, "The Wiki action could not be completed.")); }
finally { setSaving(false); }
}
async function saveSpace(values: SpaceValues) {
setSaving(true); setDialogError("");
try {
const saved = spaceDialog !== "new" && spaceDialog
? await updateWikiSpace(settings, spaceDialog, { title: values.title, description: values.description, visibility: values.visibility, acl_tokens: tokens(values.aclTokens), publish_mode: values.publishMode }, values.changeReason)
: await createWikiSpace(settings, { space_id: crypto.randomUUID(), space_key: values.spaceKey, title: values.title, visibility: values.visibility, acl_tokens: tokens(values.aclTokens), publish_mode: values.publishMode }, values.description);
setSpaceDialog(null); await reload(undefined, saved.space_id);
} catch (reason) { setDialogError(message(reason, "The Wiki space could not be saved.")); }
finally { setSaving(false); }
}
async function savePage(values: PageValues) {
if (!selectedSpace) return;
if (pageDialog !== "new" && pageDialog) {
await runAction(() => updateWikiPage(settings, pageDialog, { title: values.title, body: values.body, summary: values.summary, slug: values.slug, parent_page_id: values.parentPageId || null, inherits_access: values.inheritsAccess, visibility: values.visibility, acl_tokens: tokens(values.aclTokens), labels: tokens(values.labels), redirect_page_id: values.redirectPageId || null }, values.changeReason), () => setPageDialog(null));
return;
}
await runAction(() => createWikiPage(settings, { page_id: crypto.randomUUID(), space_id: selectedSpace.space_id, parent_page_id: values.parentPageId || null, slug: values.slug, title: values.title, body: values.body, summary: values.summary, inherits_access: values.inheritsAccess, visibility: values.inheritsAccess ? "inherit" : values.visibility, acl_tokens: values.inheritsAccess ? [] : tokens(values.aclTokens), labels: tokens(values.labels), links: [], redirect_page_id: values.redirectPageId || null }, values.changeReason), () => setPageDialog(null));
}
async function addReference(link: WikiLink, reason: string) {
if (!selectedPage) return;
await runAction(() => updateWikiPage(settings, selectedPage, { links: [...selectedPage.links, link] }, reason), () => setLinkOpen(false));
}
async function removeReference(linkId: string) {
if (!selectedPage) return;
await runAction(() => updateWikiPage(settings, selectedPage, { links: selectedPage.links.filter((item) => item.link_id !== linkId) }, "Removed a stale Wiki reference."));
}
async function addComment(body: string) {
if (!selectedPage) return;
setSaving(true); setDialogError("");
try { await addWikiComment(settings, selectedPage, body); setComments((await listWikiComments(settings, selectedPage.page_id)).comments); }
catch (reason) { setDialogError(message(reason, "The comment could not be added.")); }
finally { setSaving(false); }
}
async function showComparison(from: number, to: number) {
if (!selectedPage) return;
setCompareText("Loading comparison...");
try { setCompareText((await compareWikiRevisions(settings, selectedPage.page_id, from, to)).diff || "The selected revisions have identical text."); }
catch (reason) { setCompareText(message(reason, "Revisions could not be compared.")); }
}
async function confirmArchive() {
const target = archiveTarget; if (!target) return;
setSaving(true); setDialogError("");
try {
if (target.kind === "page") { const saved = await archiveWikiPage(settings, target.record); setSelectedPageId(saved.page_id); }
else await archiveWikiSpace(settings, target.record);
setArchiveTarget(null); await reload();
} catch (reason) { setDialogError(message(reason, "Wiki content could not be archived.")); }
finally { setSaving(false); }
}
return <main className="wiki-page">
<WorkspaceFrame className="wiki-shell" label="Wiki workspace" interfaceId="wiki.route.workspace" helpContextId="wiki.workspace" helpModuleId="wiki">
<WorkspaceActionBar scope="workspace" variant="collection" refreshable reloadAction={{ onReload: () => void reload(), loading }} contextActions={<>
<FilterBar as="form" surface="control" wrap="never" width="default" className="wiki-search" onSubmit={submitSearch}><Search size={17} aria-hidden="true" /><input value={query} onChange={(event) => setQuery(event.target.value)} aria-label="Search Wiki pages" placeholder="Search pages in this space" /><Button type="submit" variant="primary">Search</Button></FilterBar>
<label className="wiki-space-picker"><span>Space</span><select value={selectedSpaceId} onChange={(event) => setSelectedSpaceId(event.target.value)}>{spaces.map((space) => <option key={space.space_id} value={space.space_id}>{space.title}</option>)}</select></label>
<span className="wiki-count">{pages.length} pages</span>
</>} helpAction={<DocumentationHelpLink reference={{ topicId: "wiki.workflow.author-publish", documentationType: "user" }} label="Open Wiki documentation" />} createAction={<div className="wiki-create-actions">
{canAdmin && <Button type="button" onClick={() => { setDialogError(""); setSpaceDialog("new"); }} interfaceId="wiki.action.create-space"><Plus size={16} /> New space</Button>}
{canWrite && selectedSpace && <Button type="button" variant="primary" onClick={() => { setDialogError(""); setPageDialog("new"); }} interfaceId="wiki.action.create-page"><FilePlus2 size={16} /> New page</Button>}
</div>} />
{error && <DismissibleAlert tone="danger" onDismiss={() => setError("")}>{error}</DismissibleAlert>}
{dialogError && <DismissibleAlert tone="danger" resetKey={dialogError} onDismiss={() => setDialogError("")}>{dialogError}</DismissibleAlert>}
{availability && (!availability.search || !availability.files && !availability.dms) && <div className="wiki-availability" role="status">
{!availability.search && <span>{availability.consequences.search}</span>}
{!availability.files && !availability.dms && <span>{availability.consequences.files}</span>}
</div>}
<div className="wiki-workspace">
<PageScrollViewport className="wiki-tree-viewport">
{selectedSpace && <header className="wiki-space-header"><div><span>Space</span><h2>{selectedSpace.title}</h2><p>{selectedSpace.description}</p></div>{canAdmin && <Button type="button" variant="ghost" onClick={() => setSpaceDialog(selectedSpace)}><Settings2 size={16} /> Configure</Button>}</header>}
{loading && <LoadingIndicator label="Loading Wiki pages" />}
{!loading && pages.length === 0 && <StatePanel size="compact" description="No matching Wiki pages." />}
<SelectionList variant="navigation" label="Wiki page tree">{pages.map((item) => <SelectionListItem key={item.page_id} selected={item.page_id === selectedPageId} onClick={() => setSelectedPageId(item.page_id)}><SelectionListItemContent leading={<BookOpen size={17} aria-hidden="true" />} title={item.title} description={`${item.path} · revision ${item.revision}`} /><StatusBadge status={stateTone(item.state)} label={humanize(item.state)} /></SelectionListItem>)}</SelectionList>
{canAdmin && selectedSpace && <div className="wiki-space-danger"><Button type="button" variant="danger" onClick={() => setArchiveTarget({ kind: "space", record: selectedSpace })}><Archive size={16} /> Archive space</Button></div>}
</PageScrollViewport>
<PageScrollViewport className="wiki-detail-viewport">
{selectedPage ? <WikiDetail record={selectedPage} revisions={revisions} comments={comments} canWrite={canWrite} canPublish={canPublish || selectedSpace?.publish_mode === "editors" && canWrite} canComment={canComment} saving={saving} onEdit={() => setPageDialog(selectedPage)} onPublish={() => void runAction(() => publishWikiPage(settings, selectedPage))} onAddLink={() => setLinkOpen(true)} onRemoveLink={removeReference} onHistory={() => { setCompareText(""); setHistoryOpen(true); }} onComment={addComment} onArchive={() => setArchiveTarget({ kind: "page", record: selectedPage })} /> : <StatePanel size="fill" title="Wiki" description="Select a page to read its published knowledge or continue its draft." />}
</PageScrollViewport>
</div>
</WorkspaceFrame>
<SpaceDialog open={spaceDialog !== null} record={spaceDialog === "new" ? null : spaceDialog} saving={saving} error={dialogError} onClose={() => setSpaceDialog(null)} onSave={saveSpace} />
<PageEditorDialog open={pageDialog !== null} record={pageDialog === "new" ? null : pageDialog} pages={pages} saving={saving} error={dialogError} onClose={() => setPageDialog(null)} onSave={savePage} />
<ReferenceDialog open={linkOpen} saving={saving} error={dialogError} onClose={() => setLinkOpen(false)} onSave={addReference} />
<RevisionDialog open={historyOpen} revisions={revisions} comparison={compareText} onClose={() => setHistoryOpen(false)} onCompare={showComparison} />
<ConfirmDialog open={archiveTarget !== null} title={archiveTarget?.kind === "space" ? "Archive Wiki space" : "Archive Wiki page"} message={archiveTarget?.kind === "space" ? "Archive this space? New pages and revisions will be blocked, while governed history remains retained." : "Archive this page? It will leave global Search and reader navigation, while immutable revisions and comments remain retained."} confirmLabel="Archive" tone="danger" busy={saving} interfaceId="wiki.action.archive" helpContextId="wiki.admin.data-subject-requests" helpModuleId="wiki" onCancel={() => setArchiveTarget(null)} onConfirm={() => void confirmArchive()} />
</main>;
}
function WikiDetail({ record, revisions, comments, canWrite, canPublish, canComment, saving, onEdit, onPublish, onAddLink, onRemoveLink, onHistory, onComment, onArchive }: { record: WikiPageRecord; revisions: WikiRevision[]; comments: WikiComment[]; canWrite: boolean; canPublish: boolean; canComment: boolean; saving: boolean; onEdit: () => void; onPublish: () => void; onAddLink: () => void; onRemoveLink: (id: string) => Promise<void>; onHistory: () => void; onComment: (body: string) => Promise<void>; onArchive: () => void }) {
const [comment, setComment] = useState("");
return <article className="wiki-detail" data-interface-id="wiki.section.page">
<header className="wiki-detail-header"><div><span className="wiki-eyebrow">{record.path} · revision {record.revision}</span><h1>{record.title}</h1><p>{record.summary}</p></div><div className="wiki-badges"><StatusBadge status={stateTone(record.state)} label={humanize(record.state)} /><StatusBadge status={record.effective_visibility === "restricted" ? "attention" : "neutral"} label={humanize(record.effective_visibility)} /></div></header>
<div className="wiki-detail-actions" aria-label="Wiki page actions">
{canWrite && record.state !== "archived" && <Button type="button" onClick={onEdit} interfaceId="wiki.action.edit"><Pencil size={16} /> Edit</Button>}
{canWrite && record.state !== "archived" && <Button type="button" onClick={onAddLink}><Link2 size={16} /> Add reference</Button>}
{(canWrite || canPublish) && <Button type="button" onClick={onHistory} interfaceId="wiki.section.revisions"><History size={16} /> Revisions ({revisions.length})</Button>}
{canPublish && record.state === "draft" && <Button type="button" variant="primary" helpContextId="wiki.editor" helpModuleId="wiki" disabled={saving} onClick={onPublish} interfaceId="wiki.action.publish"><Upload size={16} /> Publish</Button>}
</div>
{record.published_revision && record.state === "draft" && <div className="wiki-publication-note" role="status">Readers and Search still receive published revision {record.published_revision} until this draft is published.</div>}
<div className="wiki-body">{record.body || <span className="wiki-muted">This page has no body text yet.</span>}</div>
{record.labels.length > 0 && <ul className="wiki-labels" aria-label="Page labels">{record.labels.map((label) => <li key={label}>{label}</li>)}</ul>}
<section className="wiki-section"><h2>References and attachments <span>{record.links.length}</span></h2>{record.links.length === 0 ? <p className="wiki-muted">No typed references are attached.</p> : <ul className="wiki-links">{record.links.map((link) => <li key={link.link_id}><div>{link.url ? <a href={link.url}>{link.label || link.resource_id}</a> : <strong>{link.label || link.resource_id}</strong>}<small>{humanize(link.kind)} · {link.owner_module} · {link.resource_type}</small></div>{canWrite && <Button type="button" variant="ghost" disabled={saving} onClick={() => void onRemoveLink(link.link_id)}>Remove</Button>}</li>)}</ul>}</section>
<section className="wiki-section" data-interface-id="wiki.action.comment"><h2>Comments <span>{comments.length}</span></h2>{comments.length === 0 ? <p className="wiki-muted">No comments have been recorded.</p> : <ol className="wiki-comments">{comments.map((item) => <li key={item.comment_id}><p>{item.body}</p><small>{item.created_by || "System actor"} · revision {item.page_revision} · {formatDate(item.recorded_at)}</small></li>)}</ol>}{canComment && record.state !== "archived" && <form className="wiki-comment-form" onSubmit={(event) => { event.preventDefault(); const body = comment.trim(); if (!body) return; void onComment(body).then(() => setComment("")); }}><textarea value={comment} rows={3} maxLength={20_000} aria-label="Wiki page comment" placeholder="Add factual review context" onChange={(event) => setComment(event.target.value)} /><Button type="submit" disabled={saving || !comment.trim()}><MessageSquarePlus size={16} /> Add comment</Button></form>}</section>
{canPublish && record.state !== "archived" && <section className="wiki-destructive-actions" aria-label="Destructive Wiki page actions"><h2>Archive page</h2><p>Archiving removes the page from reader navigation and Search but retains revisions, links, and comments.</p><Button type="button" variant="danger" onClick={onArchive}><Archive size={16} /> Archive page</Button></section>}
</article>;
}
type SpaceValues = { spaceKey: string; title: string; description: string; visibility: "tenant" | "restricted"; aclTokens: string; publishMode: "publishers" | "editors"; changeReason: string };
function SpaceDialog({ open, record, saving, error, onClose, onSave }: { open: boolean; record: WikiSpace | null; saving: boolean; error: string; onClose: () => void; onSave: (values: SpaceValues) => Promise<void> }) {
const [values, setValues] = useState<SpaceValues>(() => spaceValues(record));
useEffect(() => { if (open) setValues(spaceValues(record)); }, [open, record]);
function set<K extends keyof SpaceValues>(key: K, value: SpaceValues[K]) { setValues((current) => ({ ...current, [key]: value })); }
return <Dialog open={open} title={record ? "Configure Wiki space" : "Create Wiki space"} size="large" onClose={onClose} closeDisabled={saving} footer={<><Button type="button" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="wiki-space-form" variant="primary" disabled={saving}>{saving ? "Saving..." : "Save"}</Button></>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormLayout id="wiki-space-form" columns={2} gap="compact" collapseAt="narrow" onSubmit={(event) => { event.preventDefault(); void onSave(values); }}>
<label><FieldLabel help="Stable lower-case key; it cannot be renamed.">Space key</FieldLabel><input value={values.spaceKey} required disabled={Boolean(record)} pattern="[a-z0-9]+(?:-[a-z0-9]+)*" maxLength={120} onChange={(event) => set("spaceKey", event.target.value)} /></label>
<label><FieldLabel>Publishing authority</FieldLabel><select value={values.publishMode} onChange={(event) => set("publishMode", event.target.value as SpaceValues["publishMode"])}><option value="publishers">Publish permission required</option><option value="editors">Editors may publish</option></select></label>
<label className="wide"><FieldLabel>Title</FieldLabel><input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} /></label>
<label className="wide"><FieldLabel>Description</FieldLabel><textarea value={values.description} rows={4} maxLength={20_000} onChange={(event) => set("description", event.target.value)} /></label>
<label><FieldLabel>Visibility</FieldLabel><select value={values.visibility} onChange={(event) => set("visibility", event.target.value as SpaceValues["visibility"])}><option value="tenant">Entire tenant</option><option value="restricted">ACL subjects only</option></select></label>
<label><FieldLabel help="Comma-separated account:, identity:, membership:, group:, role:, or function_assignment: tokens.">ACL subjects</FieldLabel><input value={values.aclTokens} required={values.visibility === "restricted"} onChange={(event) => set("aclTokens", event.target.value)} /></label>
{record && <label className="wide"><FieldLabel help="Stored with immutable administration history.">Change reason</FieldLabel><input value={values.changeReason} required maxLength={1_000} onChange={(event) => set("changeReason", event.target.value)} /></label>}
</FormLayout>
</Dialog>;
}
type PageValues = { slug: string; title: string; summary: string; body: string; parentPageId: string; redirectPageId: string; inheritsAccess: boolean; visibility: "tenant" | "restricted"; aclTokens: string; labels: string; changeReason: string };
function PageEditorDialog({ open, record, pages, saving, error, onClose, onSave }: { open: boolean; record: WikiPageRecord | null; pages: WikiPageRecord[]; saving: boolean; error: string; onClose: () => void; onSave: (values: PageValues) => Promise<void> }) {
const [values, setValues] = useState<PageValues>(() => pageValues(record));
useEffect(() => { if (open) setValues(pageValues(record)); }, [open, record]);
function set<K extends keyof PageValues>(key: K, value: PageValues[K]) { setValues((current) => ({ ...current, [key]: value })); }
const candidates = pages.filter((item) => item.page_id !== record?.page_id && item.state !== "archived");
return <Dialog open={open} title={record ? "Edit Wiki page" : "Create Wiki page"} size="large" onClose={onClose} closeDisabled={saving} footer={<><Button type="button" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="wiki-page-form" variant="primary" disabled={saving}>{saving ? "Saving..." : "Save draft"}</Button></>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormLayout id="wiki-page-form" columns={2} gap="compact" collapseAt="narrow" onSubmit={(event) => { event.preventDefault(); void onSave(values); }}>
<label><FieldLabel>Slug</FieldLabel><input value={values.slug} required pattern="[a-z0-9]+(?:-[a-z0-9]+)*" maxLength={160} onChange={(event) => set("slug", event.target.value)} /></label>
<label><FieldLabel>Parent page</FieldLabel><select value={values.parentPageId} onChange={(event) => set("parentPageId", event.target.value)}><option value="">Space root</option>{candidates.map((item) => <option key={item.page_id} value={item.page_id}>{item.path}</option>)}</select></label>
<label className="wide"><FieldLabel>Title</FieldLabel><input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} /></label>
<label className="wide"><FieldLabel>Summary</FieldLabel><textarea value={values.summary} rows={2} maxLength={10_000} onChange={(event) => set("summary", event.target.value)} /></label>
<label className="wide"><FieldLabel help="This first editor stores governed plain text. Every save appends an immutable revision.">Page body</FieldLabel><textarea value={values.body} rows={14} maxLength={500_000} onChange={(event) => set("body", event.target.value)} /></label>
<label><FieldLabel>Labels</FieldLabel><input value={values.labels} placeholder="guide, citizen-service" onChange={(event) => set("labels", event.target.value)} /></label>
<label><FieldLabel help="Publishing turns this page into a governed redirect.">Redirect target</FieldLabel><select value={values.redirectPageId} onChange={(event) => set("redirectPageId", event.target.value)}><option value="">No redirect</option>{candidates.map((item) => <option key={item.page_id} value={item.page_id}>{item.path}</option>)}</select></label>
<label className="wiki-checkbox"><input type="checkbox" checked={values.inheritsAccess} onChange={(event) => set("inheritsAccess", event.target.checked)} /><span>Inherit access from the space</span></label>
{!values.inheritsAccess && <label><FieldLabel>Page visibility</FieldLabel><select value={values.visibility} onChange={(event) => set("visibility", event.target.value as PageValues["visibility"])}><option value="tenant">Entire tenant</option><option value="restricted">ACL subjects only</option></select></label>}
{!values.inheritsAccess && <label className="wide"><FieldLabel>Page ACL subjects</FieldLabel><input value={values.aclTokens} required={values.visibility === "restricted"} onChange={(event) => set("aclTokens", event.target.value)} /></label>}
<label className="wide"><FieldLabel help="Stored with immutable revision evidence.">Change reason</FieldLabel><input value={values.changeReason} required maxLength={1_000} onChange={(event) => set("changeReason", event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function ReferenceDialog({ open, saving, error, onClose, onSave }: { open: boolean; saving: boolean; error: string; onClose: () => void; onSave: (link: WikiLink, reason: string) => Promise<void> }) {
const [kind, setKind] = useState<WikiLink["kind"]>("related"); const [owner, setOwner] = useState(""); const [resourceType, setResourceType] = useState(""); const [resourceId, setResourceId] = useState(""); const [label, setLabel] = useState(""); const [url, setUrl] = useState(""); const [externalSystem, setExternalSystem] = useState(""); const [externalId, setExternalId] = useState(""); const [reason, setReason] = useState("Added a governed Wiki reference.");
useEffect(() => { if (open) { setKind("related"); setOwner(""); setResourceType(""); setResourceId(""); setLabel(""); setUrl(""); setExternalSystem(""); setExternalId(""); setReason("Added a governed Wiki reference."); } }, [open]);
return <Dialog open={open} title="Add reference or attachment" size="large" onClose={onClose} closeDisabled={saving} footer={<><Button type="button" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="wiki-reference-form" variant="primary" disabled={saving}>Add reference</Button></>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormLayout id="wiki-reference-form" columns={2} gap="compact" collapseAt="narrow" onSubmit={(event) => { event.preventDefault(); void onSave({ link_id: crypto.randomUUID(), kind, owner_module: owner, resource_type: resourceType, resource_id: resourceId, label: label || null, url: url || null, external_system: externalSystem || null, external_id: externalId || null, provenance: {} }, reason); }}>
<label><FieldLabel>Reference kind</FieldLabel><select value={kind} onChange={(event) => setKind(event.target.value as WikiLink["kind"])}><option value="related">Related resource</option><option value="reference">Reference</option><option value="attachment">Files/DMS attachment</option><option value="external">External Wiki page</option></select></label>
<label><FieldLabel help="Attachment references must use files or dms.">Owning module</FieldLabel><input value={owner} required maxLength={120} onChange={(event) => setOwner(event.target.value)} /></label>
<label><FieldLabel>Resource type</FieldLabel><input value={resourceType} required maxLength={120} onChange={(event) => setResourceType(event.target.value)} /></label>
<label><FieldLabel>Resource identifier</FieldLabel><input value={resourceId} required maxLength={255} onChange={(event) => setResourceId(event.target.value)} /></label>
<label><FieldLabel>Display label</FieldLabel><input value={label} maxLength={500} onChange={(event) => setLabel(event.target.value)} /></label>
<label><FieldLabel>Local or HTTP(S) link</FieldLabel><input value={url} maxLength={1_500} onChange={(event) => setUrl(event.target.value)} /></label>
{kind === "external" && <><label><FieldLabel>External system</FieldLabel><input value={externalSystem} maxLength={120} onChange={(event) => setExternalSystem(event.target.value)} /></label><label><FieldLabel>External identifier</FieldLabel><input value={externalId} maxLength={255} onChange={(event) => setExternalId(event.target.value)} /></label></>}
<label className="wide"><FieldLabel>Change reason</FieldLabel><input value={reason} required maxLength={1_000} onChange={(event) => setReason(event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function RevisionDialog({ open, revisions, comparison, onClose, onCompare }: { open: boolean; revisions: WikiRevision[]; comparison: string; onClose: () => void; onCompare: (from: number, to: number) => Promise<void> }) {
const [from, setFrom] = useState(0); const [to, setTo] = useState(0);
useEffect(() => { if (open) { setTo(revisions[0]?.revision ?? 0); setFrom(revisions[1]?.revision ?? revisions[0]?.revision ?? 0); } }, [open, revisions]);
return <Dialog open={open} title="Immutable page revisions" size="large" onClose={onClose} footer={<Button type="button" onClick={onClose}>Close</Button>}>
<div className="wiki-revision-controls"><label><FieldLabel>From revision</FieldLabel><select value={from} onChange={(event) => setFrom(Number(event.target.value))}>{revisions.map((item) => <option key={item.revision} value={item.revision}>{item.revision} · {humanize(item.event_type)}</option>)}</select></label><label><FieldLabel>To revision</FieldLabel><select value={to} onChange={(event) => setTo(Number(event.target.value))}>{revisions.map((item) => <option key={item.revision} value={item.revision}>{item.revision} · {humanize(item.event_type)}</option>)}</select></label><Button type="button" disabled={!from || !to || from === to} onClick={() => void onCompare(from, to)}>Compare</Button></div>
<ol className="wiki-revisions">{revisions.map((item) => <li key={item.revision}><strong>Revision {item.revision} · {humanize(item.event_type)}</strong><span>{formatDate(item.recorded_at)} · {item.actor_id || "System actor"}</span><p>{item.change_reason}</p></li>)}</ol>
{comparison && <pre className="wiki-diff" aria-label="Revision text comparison">{comparison}</pre>}
</Dialog>;
}
function spaceValues(record: WikiSpace | null): SpaceValues { return { spaceKey: record?.space_key ?? "", title: record?.title ?? "", description: record?.description ?? "", visibility: record?.visibility ?? "tenant", aclTokens: (record?.acl_tokens ?? []).join(", "), publishMode: record?.publish_mode ?? "publishers", changeReason: record ? "Updated Wiki space governance." : "Created governed Wiki space." }; }
function pageValues(record: WikiPageRecord | null): PageValues { return { slug: record?.slug ?? "", title: record?.title ?? "", summary: record?.summary ?? "", body: record?.body ?? "", parentPageId: record?.parent_page_id ?? "", redirectPageId: record?.redirect_page_id ?? "", inheritsAccess: record?.inherits_access ?? true, visibility: record?.visibility === "restricted" ? "restricted" : "tenant", aclTokens: (record?.acl_tokens ?? []).join(", "), labels: (record?.labels ?? []).join(", "), changeReason: record ? "Revised Wiki page content." : "Created Wiki page draft." }; }
function tokens(value: string): string[] { return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))]; }
function humanize(value: string): string { return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); }
function stateTone(value: string): "positive" | "attention" | "neutral" | "negative" { if (value === "published") return "positive"; if (value === "draft" || value === "redirected") return "attention"; if (value === "archived") return "negative"; return "neutral"; }
function formatDate(value?: string | null): string { if (!value) return "Not recorded"; const parsed = new Date(value); return Number.isNaN(parsed.valueOf()) ? value : parsed.toLocaleString(); }
function message(reason: unknown, fallback: string): string { return reason instanceof Error ? reason.message : fallback; }
+2
View File
@@ -0,0 +1,2 @@
export { default, wikiModule } from "./module";
export * from "./api/wiki";
+30
View File
@@ -0,0 +1,30 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/wiki.css";
const WikiPage = lazy(() => import("./features/wiki/WikiPage"));
export const wikiModule: PlatformWebModule = {
id: "wiki",
label: "Wiki",
version: "0.1.20",
optionalDependencies: ["files", "dms", "records", "search", "workflow_engine", "tasks", "projects", "cases", "templates", "notifications", "connectors", "policy"],
routes: [{ path: "/wiki", anyOf: ["wiki:page:read"], order: 54, surfaceId: "wiki.route.workspace", render: (context) => createElement(WikiPage, context) }],
navItems: [{ to: "/wiki", label: "Wiki", iconName: "book-open", anyOf: ["wiki:page:read"], order: 54, surfaceId: "wiki.navigation" }],
viewSurfaces: [
{ id: "wiki.navigation", moduleId: "wiki", kind: "navigation", label: "Wiki navigation", order: 5 },
{ id: "wiki.route.workspace", moduleId: "wiki", kind: "route", label: "Wiki workspace", order: 10 },
{ id: "wiki.section.space-tree", moduleId: "wiki", kind: "section", label: "Wiki spaces and page tree", parentId: "wiki.route.workspace", order: 20 },
{ id: "wiki.section.page", moduleId: "wiki", kind: "section", label: "Wiki page", parentId: "wiki.route.workspace", order: 30 },
{ id: "wiki.action.create-space", moduleId: "wiki", kind: "action", label: "Create Wiki space", parentId: "wiki.section.space-tree", order: 40 },
{ id: "wiki.action.create-page", moduleId: "wiki", kind: "action", label: "Create Wiki page", parentId: "wiki.section.space-tree", order: 50 },
{ id: "wiki.action.edit", moduleId: "wiki", kind: "action", label: "Edit Wiki page", parentId: "wiki.section.page", order: 60 },
{ id: "wiki.action.publish", moduleId: "wiki", kind: "action", label: "Publish Wiki page", parentId: "wiki.section.page", order: 70 },
{ id: "wiki.action.comment", moduleId: "wiki", kind: "action", label: "Comment on Wiki page", parentId: "wiki.section.page", order: 80 },
{ id: "wiki.section.revisions", moduleId: "wiki", kind: "section", label: "Wiki page revisions", parentId: "wiki.section.page", order: 90 },
{ id: "wiki.action.archive", moduleId: "wiki", kind: "action", label: "Archive Wiki content", parentId: "wiki.section.page", order: 100 }
]
};
export default wikiModule;
+338
View File
@@ -0,0 +1,338 @@
.wiki-page,
.wiki-shell {
block-size: 100%;
min-block-size: 0;
}
.wiki-shell {
display: flex;
flex-direction: column;
}
.wiki-search input {
min-inline-size: min(22rem, 34vw);
}
.wiki-space-picker {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.wiki-space-picker select {
min-inline-size: 12rem;
}
.wiki-count {
color: var(--text-muted);
white-space: nowrap;
}
.wiki-availability {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
padding: var(--space-2) var(--space-4);
border-block-start: 1px solid var(--border-subtle);
color: var(--text-muted);
background: var(--surface-subtle);
font-size: var(--font-size-sm);
}
.wiki-create-actions,
.wiki-detail-actions,
.wiki-badges,
.wiki-revision-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.wiki-workspace {
display: grid;
grid-template-columns: minmax(17rem, 0.75fr) minmax(0, 2fr);
flex: 1;
min-block-size: 0;
border-block-start: 1px solid var(--border-subtle);
}
.wiki-tree-viewport,
.wiki-detail-viewport {
min-block-size: 0;
}
.wiki-tree-viewport {
border-inline-end: 1px solid var(--border-subtle);
background: var(--surface-subtle);
}
.wiki-space-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4);
border-block-end: 1px solid var(--border-subtle);
}
.wiki-space-header span,
.wiki-eyebrow {
color: var(--text-muted);
font-size: var(--font-size-xs);
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.wiki-space-header h2,
.wiki-space-header p,
.wiki-detail-header h1,
.wiki-detail-header p,
.wiki-section h2,
.wiki-section p,
.wiki-destructive-actions h2,
.wiki-destructive-actions p {
margin: 0;
}
.wiki-space-header p {
margin-block-start: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.wiki-space-danger {
margin: var(--space-4);
padding-block-start: var(--space-4);
border-block-start: 1px solid var(--border-subtle);
}
.wiki-detail {
max-inline-size: 78rem;
margin-inline: auto;
padding: var(--space-6);
}
.wiki-detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding-block-end: var(--space-4);
border-block-end: 1px solid var(--border-subtle);
}
.wiki-detail-header h1 {
margin-block-start: var(--space-1);
font-size: var(--font-size-2xl);
}
.wiki-detail-header p {
margin-block-start: var(--space-2);
color: var(--text-muted);
}
.wiki-detail-actions {
padding-block: var(--space-4);
}
.wiki-publication-note {
margin-block-end: var(--space-4);
padding: var(--space-3);
border-inline-start: 0.25rem solid var(--warning);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--warning) 10%, var(--surface));
}
.wiki-body {
min-block-size: 8rem;
padding-block: var(--space-5);
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.65;
}
.wiki-muted {
color: var(--text-muted);
}
.wiki-labels,
.wiki-links,
.wiki-comments,
.wiki-revisions {
margin: 0;
padding: 0;
list-style: none;
}
.wiki-labels {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
padding-block-end: var(--space-4);
}
.wiki-labels li {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-full);
background: var(--surface-subtle);
font-size: var(--font-size-xs);
}
.wiki-section {
padding-block: var(--space-5);
border-block-start: 1px solid var(--border-subtle);
}
.wiki-section h2,
.wiki-destructive-actions h2 {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-lg);
}
.wiki-section h2 span {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.wiki-links li,
.wiki-comments li,
.wiki-revisions li {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding-block: var(--space-3);
border-block-end: 1px solid var(--border-subtle);
}
.wiki-links li div,
.wiki-links small,
.wiki-comments small,
.wiki-revisions span {
display: block;
}
.wiki-links small,
.wiki-comments small,
.wiki-revisions span {
margin-block-start: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-xs);
}
.wiki-comments li,
.wiki-revisions li {
display: block;
}
.wiki-comments p,
.wiki-revisions p {
margin: 0;
white-space: pre-wrap;
}
.wiki-comment-form {
display: grid;
gap: var(--space-2);
margin-block-start: var(--space-4);
}
.wiki-comment-form button {
justify-self: end;
}
.wiki-destructive-actions {
margin-block-start: var(--space-6);
padding: var(--space-4);
border: 1px solid var(--danger);
border-radius: var(--radius-lg);
}
.wiki-destructive-actions p {
margin-block: var(--space-2) var(--space-4);
color: var(--text-muted);
}
.wiki-checkbox {
display: flex;
align-items: center;
gap: var(--space-2);
}
.wiki-checkbox input {
inline-size: auto;
}
.wiki-revision-controls {
align-items: end;
margin-block-end: var(--space-4);
}
.wiki-revision-controls label {
flex: 1 1 12rem;
}
.wiki-revisions {
max-block-size: 18rem;
overflow: auto;
}
.wiki-diff {
max-block-size: 24rem;
margin-block-start: var(--space-4);
padding: var(--space-3);
overflow: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-subtle);
white-space: pre-wrap;
}
.wiki-page :is(button, a, input, select, textarea):focus-visible {
outline: 0.1875rem solid var(--focus-ring);
outline-offset: 0.125rem;
}
@media (max-width: 760px) {
.wiki-workspace {
grid-template-columns: 1fr;
overflow: auto;
}
.wiki-tree-viewport,
.wiki-detail-viewport {
overflow: visible;
}
.wiki-tree-viewport {
border-inline-end: 0;
border-block-end: 1px solid var(--border-subtle);
}
.wiki-detail,
.wiki-space-header {
padding: var(--space-4);
}
.wiki-detail-header {
flex-direction: column;
}
.wiki-search input {
min-inline-size: 0;
}
}
@media (max-width: 560px) {
.wiki-create-actions,
.wiki-create-actions button {
inline-size: 100%;
}
}