Compare commits

23 Commits

Author SHA1 Message Date
b1cf001452 Bound continuous calendar event loading 2026-07-30 05:22:09 +02:00
56b387a784 refactor: split calendar page components 2026-07-29 19:53:11 +02:00
4011e61a63 refactor: split calendar source setup orchestration 2026-07-29 19:14:06 +02:00
2cb3aca27c refactor: harden calendar sync and add dashboard widget 2026-07-29 14:16:28 +02:00
77072d057a Declare calendar route View surface 2026-07-28 21:04:55 +02:00
202eb78ae4 Integrate calendar sources with credential envelopes 2026-07-28 19:33:04 +02:00
bf59cd830c test(calendar): commit retirement before engine inspection 2026-07-22 03:21:20 +02:00
041e2159e7 docs: define CalDAV secret retirement 2026-07-21 15:54:47 +02:00
d64de50802 fix(secrets): redact provider failure details 2026-07-21 15:47:50 +02:00
7843fe88e5 fix(installer): delete provider secrets before retirement 2026-07-21 15:46:24 +02:00
39b199d7e6 fix(secrets): fail closed on connector credential deletion 2026-07-21 15:45:43 +02:00
74495e56cb fix(webui): require Core 0.1.9 for table actions 2026-07-21 13:46:20 +02:00
6ca1816c95 refactor(webui): centralize Calendar icon actions 2026-07-21 12:36:13 +02:00
2d7d105ba3 Use explicit outbox source validation 2026-07-21 12:34:34 +02:00
41b9426670 Refactor calendar event update flow 2026-07-21 12:33:43 +02:00
baf624c7a6 Refactor calendar sync source updates 2026-07-21 12:33:34 +02:00
d10570fc0c Refactor synchronized calendar deletion 2026-07-21 12:33:07 +02:00
30f6d79f9e Refactor calendar outbox delivery paths 2026-07-21 12:30:03 +02:00
760a87ab99 Align Calendar runtime version metadata 2026-07-21 12:13:12 +02:00
7098751f7f Remove caller-managed Calendar secret references 2026-07-21 12:12:35 +02:00
1df2a1a730 Harden Calendar connector trust boundaries 2026-07-21 12:11:58 +02:00
a52e35b52b Make calendar outbox invariants explicit 2026-07-21 03:16:45 +02:00
a36a9e8c19 Confine calendar connectors to configured origins 2026-07-21 03:16:45 +02:00
29 changed files with 7202 additions and 2985 deletions

View File

@@ -40,9 +40,21 @@ The first backend implementation stores VEVENT data in two layers:
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
records the remote collection URL, sync token, ETag/ctag state, username, records the remote collection URL, sync token, ETag/ctag state, username,
credential reference, sync interval, sync direction, and conflict policy. credential reference, sync interval, sync direction, and conflict policy.
Credentials can be supplied transiently for manual sync, referenced from Credentials can be supplied transiently for manual sync. Persisted API-managed
environment variables with `env:NAME`, stored through a platform secret provider sources accept only a password or token: Calendar stores an opaque, tenant- and
when one is available, or stored as encrypted calendar-owned credentials. source-bound local reference, backed by the platform secret provider when one is
available or by an encrypted calendar-owned credential otherwise. Caller-selected
environment and external-provider references are rejected. Trusted deployment
code may resolve an `env:NAME` reference only through the separate deployment
configuration helper.
Deleting a source or its calendar immediately scrubs Calendar-owned ciphertext
and provider references and emits non-secret audit evidence. If an external
secret provider cannot confirm deletion, the operation fails closed without
retiring the source or cancelling its queued work; retry is idempotent after a
database rollback. Destructive module retirement first deletes and audits all
active and legacy retained provider secrets and stops before table removal on
provider failure.
Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection` Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection`
when a sync token exists. It imports all VEVENT components in a resource and when a sync token exists. It imports all VEVENT components in a resource and

View File

@@ -69,6 +69,12 @@ bindings or unresolved delivery. Credential rotation does not discard committed
desired state. Public event mutation cannot set sync-owned source hrefs, kinds, desired state. Public event mutation cannot set sync-owned source hrefs, kinds,
or ETags. Deleting a synchronized collection or retiring its source is a local or ETags. Deleting a synchronized collection or retiring its source is a local
unlink: it never deletes the remote collection or its remaining remote events. unlink: it never deletes the remote collection or its remaining remote events.
The unlink immediately scrubs Calendar-owned credential ciphertext and external
provider references and audits the deletion. External provider failure blocks
retirement before queued work is changed; a later retry tolerates a provider
secret already removed by an earlier attempt whose database transaction rolled
back. Provider errors and audit details never contain credential values or
secret references.
The current singular ownership model permits one active sync source per The current singular ownership model permits one active sync source per
calendar; multi-source fan-in will require per-event source routing. Celery beat calendar; multi-source fan-in will require per-event source routing. Celery beat
triggers recovery every minute, while root-transaction after-commit dispatch triggers recovery every minute, while root-transaction after-commit dispatch

View File

@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -11,7 +11,7 @@ requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.9",
"govoplan-access>=0.1.8", "govoplan-access>=0.1.8",
"defusedxml>=0.7,<1", "defusedxml>=0.7,<1",
"icalendar>=7.2", "icalendar>=7.2",

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.4" __version__ = "0.1.8"

View File

@@ -9,6 +9,12 @@ from dataclasses import dataclass, field
from typing import Any, Mapping, Protocol from typing import Any, Mapping, Protocol
from defusedxml import ElementTree as SafeElementTree from defusedxml import ElementTree as SafeElementTree
from govoplan_core.security.outbound_http import (
OutboundHttpError,
bounded_response_bytes,
build_outbound_http_opener,
validate_outbound_http_url,
)
class CalDAVError(RuntimeError): class CalDAVError(RuntimeError):
@@ -295,24 +301,13 @@ class CalDAVClient:
return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status) return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
def object_url(self, href: str) -> str: def object_url(self, href: str) -> str:
candidate = urllib.parse.urljoin(self.collection_url, href) candidate = same_origin_dav_url(
self.collection_url,
href,
label="CalDAV object href",
)
collection_parts = urllib.parse.urlparse(self.collection_url) collection_parts = urllib.parse.urlparse(self.collection_url)
candidate_parts = urllib.parse.urlparse(candidate) candidate_parts = urllib.parse.urlparse(candidate)
if (
candidate_parts.username
or candidate_parts.password
or (
candidate_parts.scheme.lower(),
candidate_parts.hostname,
candidate_parts.port,
)
!= (
collection_parts.scheme.lower(),
collection_parts.hostname,
collection_parts.port,
)
):
raise CalDAVError("CalDAV object href must use the configured collection origin")
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path)) collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path)) candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
collection_prefix = collection_path.rstrip("/") + "/" collection_prefix = collection_path.rstrip("/") + "/"
@@ -364,14 +359,31 @@ class CalDAVClient:
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]: def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
url = validate_http_url(url) url = validate_http_url(url)
try: try:
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method) url = validate_outbound_http_url(url, label="CalDAV URL")
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV HTTP(S) URL. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
return response.status, dict(response.headers.items()), response.read() url,
data=body,
headers=dict(headers),
method=method,
)
opener = build_outbound_http_opener(_SameOriginRedirectHandler(url))
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV URL; redirects remain on origin. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
response_headers = dict(response.headers.items())
return response.status, response_headers, bounded_response_bytes(
response,
headers=response_headers,
label="CalDAV response",
)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
return exc.code, dict(exc.headers.items()), exc.read() response_headers = dict(exc.headers.items())
try:
payload = bounded_response_bytes(exc, headers=response_headers, label="CalDAV error response")
except OutboundHttpError as policy_exc:
raise CalDAVError(f"{method} {url} failed: {policy_exc}") from policy_exc
return exc.code, response_headers, payload
except urllib.error.URLError as exc: except urllib.error.URLError as exc:
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
except ValueError as exc: except (OutboundHttpError, ValueError) as exc:
raise CalDAVError(f"{method} {url} failed: {exc}") from exc raise CalDAVError(f"{method} {url} failed: {exc}") from exc
@@ -504,16 +516,73 @@ def ensure_collection_url(value: str) -> str:
def validate_http_url(value: str) -> str: def validate_http_url(value: str) -> str:
parsed = urllib.parse.urlparse(value) parsed = urllib.parse.urlparse(value.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc: if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
raise CalDAVError("CalDAV URL must be an absolute HTTP(S) URL") raise CalDAVError("CalDAV URL must be an absolute HTTP(S) URL")
if parsed.username or parsed.password: if parsed.username or parsed.password:
raise CalDAVError("CalDAV URL must not include embedded credentials") raise CalDAVError("CalDAV URL must not include embedded credentials")
if parsed.query or parsed.fragment:
raise CalDAVError("CalDAV URL must not include a query or fragment")
_url_origin(parsed)
return urllib.parse.urlunparse(parsed) return urllib.parse.urlunparse(parsed)
def absolute_dav_url(base_url: str, href: str) -> str: def absolute_dav_url(base_url: str, href: str) -> str:
return urllib.parse.urljoin(ensure_collection_url(base_url), href) return same_origin_dav_url(base_url, href, label="CalDAV discovery href")
def same_origin_dav_url(base_url: str, href: str, *, label: str) -> str:
base = ensure_collection_url(base_url)
candidate = validate_http_url(urllib.parse.urljoin(base, href))
if _url_origin(urllib.parse.urlparse(candidate)) != _url_origin(urllib.parse.urlparse(base)):
raise CalDAVError(f"{label} must use the configured collection origin")
return candidate
def _url_origin(parsed: urllib.parse.ParseResult) -> tuple[str, str, int]:
try:
port = parsed.port
except ValueError as exc:
raise CalDAVError("CalDAV URL has an invalid port") from exc
scheme = parsed.scheme.lower()
if port is None:
port = 443 if scheme == "https" else 80
return scheme, (parsed.hostname or "").lower(), port
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, source_url: str) -> None:
super().__init__()
self._source_origin = _url_origin(urllib.parse.urlparse(validate_http_url(source_url)))
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
del fp, msg, headers
try:
candidate = validate_http_url(newurl)
candidate = validate_outbound_http_url(candidate, label="CalDAV redirect URL")
except (CalDAVError, OutboundHttpError):
return None
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
return None
method = req.get_method()
data = req.data
if code == 303 and method != "HEAD":
method, data = "GET", None
elif code in {301, 302} and method == "POST":
method, data = "GET", None
forwarded_headers = {
key: value
for key, value in req.header_items()
if key.casefold() not in {"host", "content-length"}
}
return urllib.request.Request( # noqa: S310 - candidate is validated and same-origin.
candidate,
data=data,
headers=forwarded_headers,
origin_req_host=req.origin_req_host,
unverifiable=True,
method=method,
)
def strip_weak_etag(value: str | None) -> str | None: def strip_weak_etag(value: str | None) -> str | None:

View File

@@ -1,7 +1,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
from pathlib import Path from pathlib import Path
from sqlalchemy import inspect
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.calendar import ( from govoplan_core.core.calendar import (
@@ -13,6 +16,7 @@ from govoplan_core.core.calendar import (
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard 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 (
FrontendModule, FrontendModule,
FrontendRoute,
MigrationSpec, MigrationSpec,
ModuleContext, ModuleContext,
ModuleInterfaceProvider, ModuleInterfaceProvider,
@@ -21,9 +25,45 @@ from govoplan_core.core.modules import (
PermissionDefinition, PermissionDefinition,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
_calendar_table_retirement_provider = drop_table_retirement_provider(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",
)
def _calendar_retirement_provider(session: object | None, module_id: str):
plan = _calendar_table_retirement_provider(session, module_id)
base_executor = plan.destroy_data_executor
if base_executor is None:
return plan
def executor(execute_session: object, execute_module_id: str) -> None:
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
raise RuntimeError("No database session is available for Calendar credential retirement.")
if inspect(execute_session.get_bind()).has_table(calendar_models.CalendarSyncCredential.__tablename__):
from govoplan_calendar.backend.service import delete_calendar_credentials_for_retirement
delete_calendar_credentials_for_retirement(execute_session)
base_executor(execute_session, execute_module_id)
return replace(
plan,
destroy_data_warnings=(
*plan.destroy_data_warnings,
"Calendar-owned credentials are deleted immediately before tables are dropped; retirement fails if an external secret provider is unavailable.",
),
destroy_data_executor=executor,
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: 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(
@@ -156,21 +196,31 @@ manifest = ModuleManifest(
frontend=FrontendModule( frontend=FrontendModule(
module_id="calendar", module_id="calendar",
package_name="@govoplan/calendar-webui", package_name="@govoplan/calendar-webui",
routes=(
FrontendRoute(
path="/calendar",
component="CalendarPage",
required_any=("calendar:event:read",),
order=55,
),
),
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),), nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
view_surfaces=(
ViewSurface(
id="calendar.widget.upcoming",
module_id="calendar",
kind="section",
label="Upcoming events widget",
order=40,
),
),
), ),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id="calendar", module_id="calendar",
metadata=Base.metadata, metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"), script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True, retirement_supported=True,
retirement_provider=drop_table_retirement_provider( retirement_provider=_calendar_retirement_provider,
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",
),
retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.", retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.",
), ),
uninstall_guard_providers=( uninstall_guard_providers=(

View File

@@ -858,15 +858,18 @@ def _fail_operation(
session.flush() session.flush()
def execute_calendar_outbox_operation( def _lock_leased_outbox_operation(
session: Session, session: Session,
*, *,
operation_id: str, operation_id: str,
lease_token: str, lease_token: str,
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None, ) -> tuple[CalendarOutboxOperation, CalendarSyncSource | None]:
) -> CalendarOutboxOperation:
operation = session.get(CalendarOutboxOperation, operation_id) operation = session.get(CalendarOutboxOperation, operation_id)
if operation is None or operation.status != "in_progress" or operation.lease_token != lease_token: if (
operation is None
or operation.status != "in_progress"
or operation.lease_token != lease_token
):
raise ValueError("Calendar outbox lease is no longer valid") raise ValueError("Calendar outbox lease is no longer valid")
source = ( source = (
session.query(CalendarSyncSource) session.query(CalendarSyncSource)
@@ -882,149 +885,283 @@ def execute_calendar_outbox_operation(
) )
if operation.status != "in_progress" or operation.lease_token != lease_token: if operation.status != "in_progress" or operation.lease_token != lease_token:
raise ValueError("Calendar outbox lease is no longer valid") raise ValueError("Calendar outbox lease is no longer valid")
operation_metadata = operation.metadata_ or {} return operation, source
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason:
if source is not None and source.tenant_id == operation.tenant_id:
_fail_operation(
session,
operation=operation,
source=source,
error=unavailable_reason,
terminal_status="cancelled",
)
else:
operation.status = "cancelled"
operation.completed_at = utcnow()
operation.last_error = unavailable_reason
operation.lease_token = None
operation.lease_expires_at = None
session.flush()
return operation
overwrite = bool(operation_metadata.get("overwrite")) if isinstance(operation_metadata, dict) else False
client: object | None = None
try:
if client_factory is None:
from govoplan_calendar.backend.service import caldav_client_for_source
client = caldav_client_for_source(session, source) def _cancel_undeliverable_operation(
else: session: Session,
client = client_factory(session, source) *,
if operation.operation_kind == "put": operation: CalendarOutboxOperation,
result = client.put_object( source: CalendarSyncSource | None,
operation.resource_href, reason: str,
operation.payload_ics or "", ) -> None:
etag=operation.expected_etag, if source is not None and source.tenant_id == operation.tenant_id:
create=not bool(operation.expected_etag), _fail_operation(
overwrite=overwrite, session,
) operation=operation,
remote_etag = result.etag source=source,
reconciled = False error=reason,
if not remote_etag: terminal_status="cancelled",
try:
matched, remote_etag = _remote_matches_operation(client, operation)
except Exception:
matched, remote_etag = False, None
if not matched or not remote_etag:
_fail_operation(
session,
operation=operation,
source=source,
error="CalDAV PUT succeeded without an ETag and could not be reconciled safely",
)
return operation
reconciled = True
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=reconciled,
)
return operation
if not operation.expected_etag and not overwrite:
matched, remote_etag = _remote_matches_operation(client, operation)
if matched:
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=True,
)
return operation
_fail_operation(
session,
operation=operation,
source=source,
error=(
"CalDAV DELETE has no known ETag and the remote resource exists; "
"refusing to delete an unknown remote version"
),
terminal_status="conflict",
)
return operation
result = client.delete_object(
operation.resource_href,
etag=operation.expected_etag,
overwrite=overwrite,
) )
return
operation.status = "cancelled"
operation.completed_at = utcnow()
operation.last_error = reason
operation.lease_token = None
operation.lease_expires_at = None
session.flush()
def _calendar_outbox_client(
session: Session,
*,
source: CalendarSyncSource,
client_factory: Callable[[Session, CalendarSyncSource], object] | None,
) -> object:
if client_factory is not None:
return client_factory(session, source)
from govoplan_calendar.backend.service import caldav_client_for_source
return caldav_client_for_source(session, source)
def _try_remote_match(
client: object,
operation: CalendarOutboxOperation,
) -> tuple[bool, str | None] | None:
try:
return _remote_matches_operation(client, operation)
except Exception:
return None
def _complete_if_remote_match_is_usable(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
remote_match: tuple[bool, str | None] | None,
) -> bool:
if remote_match is None:
return False
matched, remote_etag = remote_match
if not matched or (operation.operation_kind != "delete" and not remote_etag):
return False
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=True,
)
return True
def _execute_calendar_outbox_put(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object,
overwrite: bool,
) -> None:
result = client.put_object(
operation.resource_href,
operation.payload_ics or "",
etag=operation.expected_etag,
create=not bool(operation.expected_etag),
overwrite=overwrite,
)
remote_etag = result.etag
if remote_etag:
_complete_operation( _complete_operation(
session, session,
operation=operation, operation=operation,
source=source, source=source,
remote_etag=result.etag, remote_etag=remote_etag,
reconciled=result.status == 404, reconciled=False,
)
return
remote_match = _try_remote_match(client, operation)
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
_fail_operation(
session,
operation=operation,
source=source,
error="CalDAV PUT succeeded without an ETag and could not be reconciled safely",
)
def _execute_calendar_outbox_delete(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object,
overwrite: bool,
) -> None:
if not operation.expected_etag and not overwrite:
remote_match = _remote_matches_operation(client, operation)
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
_fail_operation(
session,
operation=operation,
source=source,
error=(
"CalDAV DELETE has no known ETag and the remote resource exists; "
"refusing to delete an unknown remote version"
),
terminal_status="conflict",
)
return
result = client.delete_object(
operation.resource_href,
etag=operation.expected_etag,
overwrite=overwrite,
)
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=result.etag,
reconciled=result.status == 404,
)
def _handle_outbox_precondition_failure(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object | None,
error: CalDAVPreconditionFailed,
) -> None:
remote_match = _try_remote_match(client, operation) if client is not None else None
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
if remote_match is None:
_fail_operation(session, operation=operation, source=source, error=error)
return
matched, _remote_etag = remote_match
_fail_operation(
session,
operation=operation,
source=source,
error=(
"Matching CalDAV resource has no usable ETag; retrying reconciliation"
if matched
else "CalDAV resource changed remotely; reconcile or sync before retrying"
),
terminal_status=None if matched else "conflict",
)
def _handle_outbox_delivery_failure(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object | None,
error: Exception,
) -> None:
remote_match = _try_remote_match(client, operation) if client is not None else None
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
_fail_operation(session, operation=operation, source=source, error=error)
def execute_calendar_outbox_operation(
session: Session,
*,
operation_id: str,
lease_token: str,
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None,
) -> CalendarOutboxOperation:
operation, source = _lock_leased_outbox_operation(
session,
operation_id=operation_id,
lease_token=lease_token,
)
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason:
_cancel_undeliverable_operation(
session,
operation=operation,
source=source,
reason=unavailable_reason,
) )
return operation return operation
except CalDAVPreconditionFailed as exc:
assert client is not None if source is None:
try: raise ValueError("Calendar outbox source is no longer available")
matched, remote_etag = _remote_matches_operation(client, operation) operation_metadata = operation.metadata_ or {}
except Exception: overwrite = (
_fail_operation(session, operation=operation, source=source, error=exc) bool(operation_metadata.get("overwrite"))
else: if isinstance(operation_metadata, dict)
if matched and (operation.operation_kind == "delete" or remote_etag): else False
_complete_operation( )
session, client: object | None = None
operation=operation, try:
source=source, client = _calendar_outbox_client(
remote_etag=remote_etag, session,
reconciled=True, source=source,
) client_factory=client_factory,
else: )
_fail_operation( if operation.operation_kind == "put":
session, _execute_calendar_outbox_put(
operation=operation,
source=source,
error=(
"Matching CalDAV resource has no usable ETag; retrying reconciliation"
if matched
else "CalDAV resource changed remotely; reconcile or sync before retrying"
),
terminal_status=None if matched else "conflict",
)
return operation
except Exception as exc:
matched, remote_etag = False, None
if client is not None:
try:
matched, remote_etag = _remote_matches_operation(client, operation)
except Exception:
matched, remote_etag = False, None
if matched and (operation.operation_kind == "delete" or remote_etag):
_complete_operation(
session, session,
operation=operation, operation=operation,
source=source, source=source,
remote_etag=remote_etag, client=client,
reconciled=True, overwrite=overwrite,
) )
else: else:
_fail_operation(session, operation=operation, source=source, error=exc) _execute_calendar_outbox_delete(
return operation session,
operation=operation,
source=source,
client=client,
overwrite=overwrite,
)
except CalDAVPreconditionFailed as exc:
_handle_outbox_precondition_failure(
session,
operation=operation,
source=source,
client=client,
error=exc,
)
except Exception as exc:
_handle_outbox_delivery_failure(
session,
operation=operation,
source=source,
client=client,
error=exc,
)
return operation
def dispatch_calendar_outbox( def dispatch_calendar_outbox(
@@ -1142,7 +1279,8 @@ def retry_calendar_outbox_operation(
unavailable_reason = _operation_source_unavailable_reason(operation, source) unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason: if unavailable_reason:
raise ValueError(unavailable_reason) raise ValueError(unavailable_reason)
assert source is not None if source is None:
raise RuntimeError("Calendar outbox source availability invariant was violated")
operation.status = "pending" operation.status = "pending"
operation.attempt_count = 0 operation.attempt_count = 0
operation.available_at = utcnow() operation.available_at = utcnow()
@@ -1186,7 +1324,8 @@ def reconcile_calendar_outbox_operation(
unavailable_reason = _operation_source_unavailable_reason(operation, source) unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason: if unavailable_reason:
raise ValueError(unavailable_reason) raise ValueError(unavailable_reason)
assert source is not None if source is None:
raise RuntimeError("Calendar outbox source availability invariant was violated")
if client_factory is None: if client_factory is None:
from govoplan_calendar.backend.service import caldav_client_for_source from govoplan_calendar.backend.service import caldav_client_for_source
@@ -1248,7 +1387,8 @@ def discard_calendar_outbox_operation(
source is None or source.tenant_id != operation.tenant_id source is None or source.tenant_id != operation.tenant_id
): ):
raise ValueError(unavailable_reason) raise ValueError(unavailable_reason)
assert source is not None if source is None:
raise RuntimeError("Calendar outbox source availability invariant was violated")
now = utcnow() now = utcnow()
unresolved_chain = ( unresolved_chain = (
session.query(CalendarOutboxOperation) session.query(CalendarOutboxOperation)

View File

@@ -28,6 +28,8 @@ from govoplan_calendar.backend.schemas import (
CalendarCollectionListResponse, CalendarCollectionListResponse,
CalendarCollectionResponse, CalendarCollectionResponse,
CalendarCollectionUpdateRequest, CalendarCollectionUpdateRequest,
CalendarCredentialEnvelopeListResponse,
CalendarCredentialEnvelopeResponse,
CalendarEventCreateRequest, CalendarEventCreateRequest,
CalendarEventDeltaResponse, CalendarEventDeltaResponse,
CalendarEventListResponse, CalendarEventListResponse,
@@ -61,6 +63,7 @@ from govoplan_calendar.backend.service import (
CALENDAR_EVENT_RESOURCE, CALENDAR_EVENT_RESOURCE,
CALENDAR_MODULE_ID, CALENDAR_MODULE_ID,
CalendarError, CalendarError,
available_calendar_credentials,
caldav_source_response, caldav_source_response,
caldav_sync_response, caldav_sync_response,
calendar_response, calendar_response,
@@ -96,6 +99,15 @@ from govoplan_core.db.session import get_session
router = APIRouter(prefix="/calendar", tags=["calendar"]) router = APIRouter(prefix="/calendar", tags=["calendar"])
def _caldav_discovery_http_error(
exc: CalendarError | CalDAVError,
) -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
)
def _require_scope(principal: ApiPrincipal, scope: str) -> None: def _require_scope(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope): if not has_scope(principal, scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
@@ -129,6 +141,25 @@ def _parse_payload_datetime(value) -> datetime | None:
return None return None
@router.get("/credentials", response_model=CalendarCredentialEnvelopeListResponse)
def api_list_calendar_credentials(
source_id: str | None = None,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
return CalendarCredentialEnvelopeListResponse(
credentials=[
CalendarCredentialEnvelopeResponse.model_validate(item)
for item in available_calendar_credentials(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
)
]
)
def _event_interval_overlaps(payload: dict, *, prefix: str, start_at: datetime | None, end_at: datetime | None) -> bool: def _event_interval_overlaps(payload: dict, *, prefix: str, start_at: datetime | None, end_at: datetime | None) -> bool:
event_start = _parse_payload_datetime(payload.get(f"{prefix}start_at")) event_start = _parse_payload_datetime(payload.get(f"{prefix}start_at"))
event_end = _parse_payload_datetime(payload.get(f"{prefix}end_at")) or event_start event_end = _parse_payload_datetime(payload.get(f"{prefix}end_at")) or event_start
@@ -298,7 +329,14 @@ def api_delete_calendar(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, payload=payload) delete_calendar(
session,
tenant_id=principal.tenant_id,
calendar_id=calendar_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc: except CalendarError as exc:
@@ -344,7 +382,14 @@ def api_update_sync_source(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
source = update_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload) source = update_sync_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
session.refresh(source) session.refresh(source)
return _sync_source_response(source) return _sync_source_response(source)
@@ -361,7 +406,13 @@ def api_delete_sync_source(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
delete_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id) delete_sync_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc: except CalendarError as exc:
@@ -450,7 +501,7 @@ def api_discover_caldav_calendars(
calendars = discover_caldav_calendars(session, tenant_id=principal.tenant_id, payload=payload) calendars = discover_caldav_calendars(session, tenant_id=principal.tenant_id, payload=payload)
return CalendarCalDavDiscoveryResponse(calendars=calendars) return CalendarCalDavDiscoveryResponse(calendars=calendars)
except (CalendarError, CalDAVError) as exc: except (CalendarError, CalDAVError) as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc raise _caldav_discovery_http_error(exc) from exc
@router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED) @router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED)
@@ -479,7 +530,14 @@ def api_update_caldav_source(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
source = update_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload) source = update_caldav_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
session.refresh(source) session.refresh(source)
return _caldav_source_response(source) return _caldav_source_response(source)
@@ -496,7 +554,13 @@ def api_delete_caldav_source(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
delete_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id) delete_caldav_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc: except CalendarError as exc:

View File

@@ -136,6 +136,7 @@ class CalendarSyncSourceResponse(BaseModel):
auth_type: str auth_type: str
username: str | None = None username: str | None = None
credential_ref: str | None = None credential_ref: str | None = None
credential_envelope_id: str | None = None
has_credential: bool = False has_credential: bool = False
sync_enabled: bool sync_enabled: bool
sync_interval_seconds: int sync_interval_seconds: int
@@ -165,6 +166,26 @@ class CalendarCalDavSourceListResponse(BaseModel):
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list) sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
class CalendarCredentialEnvelopeResponse(BaseModel):
id: str
scope_type: str
scope_id: str | None = None
name: str
description: str | None = None
credential_kind: str
public_data: dict[str, Any] = Field(default_factory=dict)
secret_keys: list[str] = Field(default_factory=list)
secret_configured: bool = False
allowed_modules: list[str] = Field(default_factory=list)
inherit_to_lower_scopes: bool = False
is_active: bool = True
revision: str
class CalendarCredentialEnvelopeListResponse(BaseModel):
credentials: list[CalendarCredentialEnvelopeResponse] = Field(default_factory=list)
class CalendarCalDavDiscoveryRequest(BaseModel): class CalendarCalDavDiscoveryRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

File diff suppressed because it is too large Load Diff

View File

@@ -4,30 +4,61 @@ import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from unittest.mock import patch from unittest.mock import patch
from sqlalchemy import create_engine from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus from govoplan_calendar.backend.caldav import (
CalDAVClient,
CalDAVDiscoveryCalendar,
CalDAVError,
CalDAVNotFound,
CalDAVObject,
CalDAVPreconditionFailed,
CalDAVReportResult,
CalDAVWriteResult,
parse_discovery_multistatus,
parse_multistatus,
)
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
from govoplan_calendar.backend.manifest import manifest
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest from govoplan_calendar.backend.schemas import (
CalendarCalDavDiscoveryRequest,
CalendarCalDavSourceCreateRequest,
CalendarCalDavSourceUpdateRequest,
CalendarCollectionCreateRequest,
CalendarEventCreateRequest,
CalendarEventUpdateRequest,
)
from govoplan_calendar.backend.service import ( from govoplan_calendar.backend.service import (
CALDAV_INTERNAL_CREDENTIAL_PREFIX, CALDAV_INTERNAL_CREDENTIAL_PREFIX,
CalendarError, CalendarError,
_CalDAVDiscoveryAuth,
_caldav_discovery_client,
_caldav_discovery_response,
_plan_sync_source_creation,
caldav_client_for_source, caldav_client_for_source,
caldav_source_response,
create_calendar, create_calendar,
create_caldav_source, create_caldav_source,
create_event, create_event,
delete_caldav_source,
delete_calendar, delete_calendar,
delete_event, delete_event,
discover_caldav_calendars,
list_caldav_sources, list_caldav_sources,
list_freebusy, list_freebusy,
resolve_caldav_credential_ref,
resolve_trusted_deployment_caldav_credential_ref,
sync_caldav_source, sync_caldav_source,
sync_due_caldav_sources, sync_due_caldav_sources,
update_caldav_source,
update_event, update_event,
) )
from govoplan_calendar.backend.router import _caldav_discovery_http_error
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.security.credential_envelopes import create_credential_envelope
from govoplan_core.tenancy.scope import create_scope_tables from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_tenancy.backend.db.models import Tenant from govoplan_tenancy.backend.db.models import Tenant
@@ -93,6 +124,84 @@ class FakeNotificationProvider:
class CalDAVParsingTests(unittest.TestCase): class CalDAVParsingTests(unittest.TestCase):
def test_discovery_parser_is_independent_from_transport(self) -> None:
result = parse_discovery_multistatus(
b"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>/calendars/ada/work/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Work</D:displayname>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
)
self.assertEqual(1, len(result))
self.assertEqual("Work", result[0].display_name)
self.assertTrue(result[0].is_calendar)
self.assertEqual(("VEVENT",), result[0].supported_components)
def test_discovery_auth_client_and_response_do_not_expose_secret(self) -> None:
auth = _CalDAVDiscoveryAuth(
auth_type="basic",
username="ada",
credential_ref=None,
secret="calendar-secret",
)
client = _caldav_discovery_client(
"https://dav.example.test/cal",
auth,
)
response = _caldav_discovery_response(
(
CalDAVDiscoveryCalendar(
collection_url="https://dav.example.test/cal/work/",
href="/cal/work/",
display_name="Work",
),
)
)
self.assertNotIn("calendar-secret", repr(auth))
self.assertEqual("calendar-secret", client.password)
self.assertEqual("Work", response[0]["display_name"])
self.assertNotIn("password", response[0])
self.assertNotIn("credential_ref", response[0])
def test_discovery_error_translation_is_stable(self) -> None:
error = _caldav_discovery_http_error(
CalDAVError("CalDAV endpoint rejected PROPFIND")
)
self.assertEqual(422, error.status_code)
self.assertEqual(
"CalDAV endpoint rejected PROPFIND",
error.detail,
)
def test_sync_source_creation_plan_is_secret_free(self) -> None:
plan = _plan_sync_source_creation(
CalendarCalDavSourceCreateRequest(
calendar_id="calendar-1",
collection_url="dav.example.test/cal",
auth_type="basic",
username="ada",
password="calendar-secret",
)
)
self.assertEqual("https://dav.example.test/cal/", plan.collection_url)
self.assertTrue(plan.has_inline_secret)
self.assertNotIn("calendar-secret", repr(plan))
def test_parse_multistatus_extracts_objects_sync_token_and_deletions(self) -> None: def test_parse_multistatus_extracts_objects_sync_token_and_deletions(self) -> None:
result = parse_multistatus( result = parse_multistatus(
b"""<?xml version="1.0" encoding="utf-8"?> b"""<?xml version="1.0" encoding="utf-8"?>
@@ -246,12 +355,21 @@ class CalDAVSyncTests(unittest.TestCase):
create_scope_tables(self.engine) create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine) Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine) self.Session = sessionmaker(bind=self.engine)
self.sessions = []
def tearDown(self) -> None: def tearDown(self) -> None:
for session in reversed(self.sessions):
session.close()
Base.metadata.drop_all(bind=self.engine) Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def session(self):
session = self.Session()
self.sessions.append(session)
return session
def test_source_creation_encrypts_credential_and_resolves_client_secret(self) -> None: def test_source_creation_encrypts_credential_and_resolves_client_secret(self) -> None:
session = self.Session() session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
@@ -277,8 +395,261 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(client.username, "ada") self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret") self.assertEqual(client.password, "secret")
def test_source_can_resolve_reusable_core_credential(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
credential = create_credential_envelope(
session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Shared DAV login",
credential_kind="username_password",
public_data={"username": "ada"},
secret_data={"password": "secret"},
allowed_modules=["calendar"],
inherit_to_lower_scopes=True,
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
auth_type="basic",
credential_ref=f"credential-envelope:{credential.id}",
),
)
session.commit()
client = caldav_client_for_source(session, source)
response = caldav_source_response(source)
self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret")
self.assertEqual(response["credential_envelope_id"], credential.id)
self.assertEqual(session.query(CalendarSyncCredential).count(), 0)
def test_api_source_and_discovery_reject_caller_selected_credential_references(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://attacker.example.test/cal",
auth_type="bearer",
credential_ref="env:MASTER_KEY_B64",
),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
discover_caldav_calendars(
session,
tenant_id="tenant-1",
payload=CalendarCalDavDiscoveryRequest(
url="https://attacker.example.test/cal",
auth_type="bearer",
credential_ref="vault:another-tenant",
),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(
credential_ref="env:MASTER_KEY_B64",
),
)
def test_provider_credentials_are_wrapped_in_tenant_and_source_owned_rows(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deleted: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
auth_type="basic",
username="ada",
password="secret",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
self.assertTrue(source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX))
self.assertNotEqual(source.credential_ref, provider_ref)
self.assertEqual(caldav_client_for_source(session, source).password, "secret")
self.assertIsNone(caldav_source_response(source)["credential_ref"])
self.assertTrue(caldav_source_response(source)["has_credential"])
self.assertIsNone(
resolve_caldav_credential_ref(
session,
tenant_id="another-tenant",
source_id=source.id,
credential_ref=source.credential_ref,
)
)
self.assertIsNone(
resolve_caldav_credential_ref(
session,
tenant_id="tenant-1",
source_id="another-source",
credential_ref=source.credential_ref,
)
)
other_calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Other remote"),
)
other_source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=other_calendar.id,
collection_url="https://dav.example.test/other-cal",
),
)
other_source.auth_type = "basic"
other_source.username = "mallory"
other_source.credential_ref = source.credential_ref
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
caldav_client_for_source(session, other_source)
delete_calendar(session, tenant_id="tenant-1", calendar_id=other_calendar.id)
self.assertEqual(provider.deleted, [])
with patch("govoplan_calendar.backend.service.audit_event") as audit:
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.deleted, [provider_ref])
self.assertNotIn(provider_ref, provider.values)
self.assertIsNone(source.credential_ref)
self.assertIsNone(credential.secret_encrypted)
self.assertNotIn("provider_ref", credential.metadata_ or {})
audit.assert_called_once()
audit_call = audit.call_args.kwargs
self.assertEqual(audit_call["action"], "calendar.sync_credential_deleted")
self.assertEqual(audit_call["object_id"], credential.id)
self.assertEqual(audit_call["details"]["storage_backend"], "external_secret_provider")
self.assertEqual(audit_call["details"]["deletion_reason"], "calendar_deleted")
self.assertNotIn("credential_ref", audit_call["details"])
self.assertNotIn("provider_ref", audit_call["details"])
self.assertNotIn(provider_ref, repr(audit_call["details"]))
def test_legacy_unowned_refs_are_neither_read_nor_deleted(self) -> None:
class Provider:
def __init__(self) -> None:
self.read: list[str] = []
self.deleted: list[str] = []
def read_secret(self, secret_ref: str) -> str | None:
self.read.append(secret_ref)
return "other-tenant-secret"
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
),
)
source.auth_type = "bearer"
source.credential_ref = "vault:another-tenant"
session.commit()
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
caldav_client_for_source(session, source)
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.read, [])
self.assertEqual(provider.deleted, [])
def test_trusted_deployment_env_resolution_is_explicit_and_separate(self) -> None:
with patch.dict("os.environ", {"CALDAV_DEPLOYMENT_TOKEN": "trusted-token"}):
self.assertEqual(
resolve_trusted_deployment_caldav_credential_ref("env:CALDAV_DEPLOYMENT_TOKEN"),
"trusted-token",
)
with self.assertRaisesRegex(CalendarError, "must use the env: prefix"):
resolve_trusted_deployment_caldav_credential_ref("vault:token")
def test_delete_calendar_retires_caldav_source_and_credential(self) -> None: def test_delete_calendar_retires_caldav_source_and_credential(self) -> None:
session = self.Session() session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
source = create_caldav_source( source = create_caldav_source(
@@ -300,11 +671,319 @@ class CalDAVSyncTests(unittest.TestCase):
credential = session.query(CalendarSyncCredential).one() credential = session.query(CalendarSyncCredential).one()
self.assertIsNotNone(source.deleted_at) self.assertIsNotNone(source.deleted_at)
self.assertIsNone(source.credential_ref)
self.assertIsNotNone(credential.deleted_at) self.assertIsNotNone(credential.deleted_at)
self.assertIsNone(credential.secret_encrypted)
self.assertEqual(credential.metadata_, {"source_id": source.id})
self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), []) self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), [])
def test_external_credential_deletion_fails_closed_when_provider_is_unavailable(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/provider-down",
auth_type="bearer",
bearer_token="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.secret_provider", return_value=None), self.assertRaisesRegex(
CalendarError,
"secret provider is unavailable",
):
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
session.rollback()
self.assertIsNone(source.deleted_at)
self.assertEqual(source.credential_ref, f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{credential.id}")
self.assertIsNone(credential.deleted_at)
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertIn(provider_ref, provider.values)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider), patch.object(
provider,
"delete_secret",
side_effect=RuntimeError(f"provider failed for {provider_ref}"),
):
with self.assertRaises(CalendarError) as raised:
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
self.assertNotIn(provider_ref, str(raised.exception))
session.rollback()
self.assertIsNone(source.deleted_at)
self.assertIsNone(credential.deleted_at)
def test_disabling_auth_immediately_scrubs_and_audits_database_credential(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/disable-auth",
auth_type="basic",
username="ada",
password="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
self.assertIsNotNone(credential.secret_encrypted)
with patch("govoplan_calendar.backend.service.audit_event") as audit:
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(auth_type="none"),
)
session.commit()
self.assertIsNone(source.credential_ref)
self.assertIsNone(credential.secret_encrypted)
self.assertIsNotNone(credential.deleted_at)
audit.assert_called_once()
self.assertEqual(
audit.call_args.kwargs["details"]["deletion_reason"],
"authentication_disabled",
)
self.assertNotIn("do-not-log-this", repr(audit.call_args))
def test_external_credential_replacement_fails_closed_when_provider_is_unavailable(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/replacement-provider-down",
auth_type="bearer",
bearer_token="old-secret",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.secret_provider", return_value=None), self.assertRaisesRegex(
CalendarError,
"cannot be replaced.*provider is unavailable",
):
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(bearer_token="new-secret"),
)
session.rollback()
self.assertEqual(source.credential_ref, f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{credential.id}")
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertEqual(provider.values[provider_ref], "old-secret")
def test_external_credential_delete_is_retryable_after_database_rollback(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deletes: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deletes.append(secret_ref)
if secret_ref not in self.values:
raise KeyError("already deleted")
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/retry-delete",
auth_type="basic",
username="ada",
password="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.audit_event"):
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
self.assertNotIn(provider_ref, provider.values)
self.assertIsNotNone(credential.deleted_at)
self.assertIsNone(source.credential_ref)
session.rollback()
self.assertIsNone(source.deleted_at)
session.refresh(credential)
self.assertIsNone(credential.deleted_at)
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertIsNone(resolve_caldav_credential_ref(
session,
tenant_id="tenant-1",
source_id=source.id,
credential_ref=source.credential_ref,
))
with patch("govoplan_calendar.backend.service.audit_event") as audit:
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
session.commit()
self.assertEqual(provider.deletes, [provider_ref, provider_ref])
self.assertIsNotNone(source.deleted_at)
self.assertIsNone(source.credential_ref)
self.assertIsNotNone(credential.deleted_at)
self.assertEqual(audit.call_count, 1)
def test_destructive_module_retirement_deletes_external_credentials_before_tables(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deleted: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/module-retirement",
auth_type="bearer",
bearer_token="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
retirement_provider = manifest.migration_spec.retirement_provider
assert retirement_provider is not None
plan = retirement_provider(session, "calendar")
assert plan.destroy_data_executor is not None
with patch("govoplan_calendar.backend.service.audit_event") as audit:
plan.destroy_data_executor(session, "calendar")
# Retirement intentionally participates in the caller's database
# transaction. Commit before inspecting through a new Engine
# connection; otherwise SQLite rolls back the uncommitted DDL when
# the temporary inspector connection is returned to the pool.
session.commit()
self.assertEqual(provider.deleted, [provider_ref])
self.assertEqual(provider.values, {})
self.assertEqual(audit.call_count, 1)
self.assertEqual(
audit.call_args.kwargs["details"]["deletion_reason"],
"module_data_retired",
)
self.assertFalse(inspect(self.engine).has_table("calendar_sync_credentials"))
self.assertFalse(inspect(self.engine).has_table("calendar_sync_sources"))
def test_create_source_retires_orphaned_source_for_deleted_calendar(self) -> None: def test_create_source_retires_orphaned_source_for_deleted_calendar(self) -> None:
session = self.Session() session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
old_calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Old")) old_calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Old"))
old_source = create_caldav_source( old_source = create_caldav_source(
@@ -330,7 +1009,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual([source.id for source in list_caldav_sources(session, tenant_id="tenant-1")], [new_source.id]) self.assertEqual([source.id for source in list_caldav_sources(session, tenant_id="tenant-1")], [new_source.id])
def test_create_source_reports_active_duplicate_as_calendar_error(self) -> None: def test_create_source_reports_active_duplicate_as_calendar_error(self) -> None:
session = self.Session() session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
first = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="First")) first = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="First"))
second = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Second")) second = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Second"))
@@ -351,7 +1030,7 @@ class CalDAVSyncTests(unittest.TestCase):
) )
def test_due_sync_runs_due_sources_and_reschedules(self) -> None: def test_due_sync_runs_due_sources_and_reschedules(self) -> None:
session = self.Session() session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
source = create_caldav_source( source = create_caldav_source(
@@ -378,7 +1057,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertGreater(next_sync_at, datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc)) self.assertGreater(next_sync_at, datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc))
def test_due_sync_emits_recovery_notification_after_previous_error(self) -> None: def test_due_sync_emits_recovery_notification_after_previous_error(self) -> None:
session = self.Session() session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
source = create_caldav_source( source = create_caldav_source(
@@ -406,7 +1085,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(provider.requests[0].event_kind, "calendar.sync.ok") self.assertEqual(provider.requests[0].event_kind, "calendar.sync.ok")
def test_create_and_update_event_coalesce_to_committed_outbox_state(self) -> None: def test_create_and_update_event_coalesce_to_committed_outbox_state(self) -> None:
session = self.Session() session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal")) create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
@@ -441,7 +1120,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertIn("SUMMARY:Updated", event.raw_ics or "") self.assertIn("SUMMARY:Updated", event.raw_ics or "")
def test_update_event_reports_remote_etag_conflict(self) -> None: def test_update_event_reports_remote_etag_conflict(self) -> None:
session = self.Session() session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal")) create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
@@ -467,7 +1146,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertIn("changed remotely", operation.last_error or "") self.assertIn("changed remotely", operation.last_error or "")
def test_delete_one_component_puts_remaining_resource_instead_of_deleting_object(self) -> None: def test_delete_one_component_puts_remaining_resource_instead_of_deleting_object(self) -> None:
session = self.Session() session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal")) create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
@@ -490,7 +1169,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertIsNotNone(override.deleted_at) self.assertIsNotNone(override.deleted_at)
def test_freebusy_expands_recurring_events_and_skips_transparent_items(self) -> None: def test_freebusy_expands_recurring_events_and_skips_transparent_items(self) -> None:
session = self.Session() session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Local")) calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Local"))
create_event( create_event(
@@ -532,7 +1211,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(busy[0]["start_at"], datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc)) self.assertEqual(busy[0]["start_at"], datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc))
def test_full_sync_imports_multi_vevent_resource_and_fetches_missing_calendar_data(self) -> None: def test_full_sync_imports_multi_vevent_resource_and_fetches_missing_calendar_data(self) -> None:
session = self.Session() session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant") tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant) session.add(tenant)
calendar = create_calendar( calendar = create_calendar(
@@ -573,7 +1252,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(calendar.metadata_["source_kind"], "caldav") self.assertEqual(calendar.metadata_["source_kind"], "caldav")
def test_sync_token_deletion_soft_deletes_remote_resource_events(self) -> None: def test_sync_token_deletion_soft_deletes_remote_resource_events(self) -> None:
session = self.Session() session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant") tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant) session.add(tenant)
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
@@ -622,7 +1301,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertIsNotNone(event.deleted_at) self.assertIsNotNone(event.deleted_at)
def test_missing_resource_during_fetch_is_treated_as_remote_delete(self) -> None: def test_missing_resource_during_fetch_is_treated_as_remote_delete(self) -> None:
session = self.Session() session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant") tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant) session.add(tenant)
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))

View File

@@ -0,0 +1,142 @@
from __future__ import annotations
import contextlib
import threading
import unittest
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import patch
from govoplan_calendar.backend.caldav import (
CalDAVClient,
CalDAVError,
absolute_dav_url,
urllib_transport,
)
@contextlib.contextmanager
def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
host, port = server.server_address
yield f"http://{host}:{port}"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
class CalDAVUrlSecurityTests(unittest.TestCase):
def test_transport_revalidates_dns_at_connection_time(self) -> None:
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
CalDAVError,
"non-public network",
):
urllib_transport("GET", "https://dav.example.test/event.ics", {}, None, 2)
socket_factory.assert_not_called()
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
base_url = "https://dav.example.test/calendars/ada/"
self.assertEqual(
absolute_dav_url(base_url, "/principals/users/ada/"),
"https://dav.example.test/principals/users/ada/",
)
with self.assertRaisesRegex(CalDAVError, "configured collection origin"):
absolute_dav_url(base_url, "https://evil.example.test/steal/")
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
absolute_dav_url(base_url, "/principals/users/ada/?token=secret")
def test_object_href_rejects_userinfo_query_and_fragment(self) -> None:
client = CalDAVClient(collection_url="https://dav.example.test/calendars/ada")
with self.assertRaisesRegex(CalDAVError, "embedded credentials"):
client.object_url("https://user:secret@dav.example.test/calendars/ada/event.ics")
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
client.object_url("/calendars/ada/event.ics?download=1")
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
client.object_url("/calendars/ada/event.ics#fragment")
self.assertEqual(
client.object_url("/calendars/ada/event.ics"),
"https://dav.example.test/calendars/ada/event.ics",
)
def test_transport_refuses_redirect_before_forwarding_authorization(self) -> None:
forwarded_authorization: list[str | None] = []
class TargetHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(TargetHandler) as target_url:
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
self.send_response(302)
self.send_header("Location", f"{target_url}/stolen.ics")
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as redirect_url:
status, _headers, _body = urllib_transport(
"GET",
f"{redirect_url}/event.ics",
{"Authorization": "Bearer top-secret"},
None,
2,
)
self.assertEqual(status, 302)
self.assertEqual(forwarded_authorization, [])
def test_transport_preserves_same_origin_redirects(self) -> None:
forwarded_authorization: list[str | None] = []
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path == "/event.ics":
self.send_response(302)
self.send_header("Location", "/redirected.ics")
self.end_headers()
return
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
self.wfile.write(b"calendar")
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as source_url:
status, _headers, body = urllib_transport(
"GET",
f"{source_url}/event.ics",
{"Authorization": "Bearer expected"},
None,
2,
)
self.assertEqual(status, 200)
self.assertEqual(body, b"calendar")
self.assertEqual(forwarded_authorization, ["Bearer expected"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
import contextlib
import threading
import unittest
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from govoplan_calendar.backend.service import CalendarError, http_request
@contextlib.contextmanager
def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
host, port = server.server_address
yield f"http://{host}:{port}"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
class CalendarHttpSecurityTests(unittest.TestCase):
def test_cross_origin_redirect_does_not_forward_authorization(self) -> None:
forwarded_authorization: list[str | None] = []
class TargetHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(TargetHandler) as target_url:
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
self.send_response(302)
self.send_header("Location", f"{target_url}/stolen")
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as source_url:
with self.assertRaisesRegex(CalendarError, "HTTP 302"):
http_request(
f"{source_url}/feed",
headers={"Authorization": "Bearer top-secret"},
timeout=2,
)
self.assertEqual(forwarded_authorization, [])
def test_same_origin_redirect_remains_supported(self) -> None:
forwarded_authorization: list[str | None] = []
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path == "/feed":
self.send_response(302)
self.send_header("Location", "/calendar.ics")
self.end_headers()
return
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
self.wfile.write(b"calendar")
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as source_url:
status, _headers, body = http_request(
f"{source_url}/feed",
headers={"Authorization": "Bearer expected"},
timeout=2,
)
self.assertEqual(status, 200)
self.assertEqual(body, "calendar")
self.assertEqual(forwarded_authorization, ["Bearer expected"])
if __name__ == "__main__":
unittest.main()

View File

@@ -23,12 +23,17 @@ class CalendarSyncSourceTests(unittest.TestCase):
create_scope_tables(self.engine) create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine) Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine) self.Session = sessionmaker(bind=self.engine)
self.sessions = []
def tearDown(self) -> None: def tearDown(self) -> None:
for session in reversed(self.sessions):
session.close()
Base.metadata.drop_all(bind=self.engine) Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def session_with_calendar(self): def session_with_calendar(self):
session = self.Session() session = self.Session()
self.sessions.append(session)
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
session.flush() session.flush()
@@ -166,6 +171,74 @@ END:VCALENDAR
self.assertEqual(event.summary, "Graph item") self.assertEqual(event.summary, "Graph item")
self.assertIn("Bearer graph-token", request.call_args.kwargs["headers"]["Authorization"]) self.assertIn("Bearer graph-token", request.call_args.kwargs["headers"]["Authorization"])
def test_graph_sync_rejects_cross_origin_continuation_url(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="graph",
calendar_id=calendar.id,
collection_url="me/calendar",
auth_type="bearer",
bearer_token="graph-token",
),
)
payload = {
"value": [],
"@odata.nextLink": "https://attacker.example.test/collect?token=secret",
}
with patch(
"govoplan_calendar.backend.service.http_request",
return_value=(200, {}, json.dumps(payload)),
) as request:
with self.assertRaisesRegex(CalendarError, "configured source origin"):
sync_source(
session,
tenant_id="tenant-1",
user_id=None,
source_id=source.id,
)
self.assertEqual(request.call_count, 1)
self.assertEqual(source.last_status, "error")
def test_graph_sync_rejects_cross_origin_delta_url(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="graph",
calendar_id=calendar.id,
collection_url="me/calendar",
auth_type="bearer",
bearer_token="graph-token",
),
)
payload = {
"value": [],
"@odata.deltaLink": "https://attacker.example.test/collect?token=secret",
}
with patch(
"govoplan_calendar.backend.service.http_request",
return_value=(200, {}, json.dumps(payload)),
) as request:
with self.assertRaisesRegex(CalendarError, "configured source origin"):
sync_source(
session,
tenant_id="tenant-1",
user_id=None,
source_id=source.id,
)
self.assertEqual(request.call_count, 1)
self.assertEqual(source.last_status, "error")
def test_ews_sync_imports_calendar_view_items(self) -> None: def test_ews_sync_imports_calendar_view_items(self) -> None:
session, calendar = self.session_with_calendar() session, calendar = self.session_with_calendar()
source = create_sync_source( source = create_sync_source(

View File

@@ -14,14 +14,15 @@
"./styles/calendar.css": "./src/styles/calendar.css" "./styles/calendar.css": "./src/styles/calendar.css"
}, },
"scripts": { "scripts": {
"test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs" "test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs",
"test:calendar-page": "tsc -p tsconfig.calendar-page-tests.json && node --experimental-strip-types tests/calendar-view-model.test.ts && node tests/calendar-page-structure.test.mjs"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"

View File

@@ -78,7 +78,7 @@ export type CalendarSyncSource = {
display_name?: string | null; display_name?: string | null;
auth_type: CalendarCalDavAuthType; auth_type: CalendarCalDavAuthType;
username?: string | null; username?: string | null;
credential_ref?: string | null; credential_envelope_id?: string | null;
has_credential: boolean; has_credential: boolean;
sync_enabled: boolean; sync_enabled: boolean;
sync_interval_seconds: number; sync_interval_seconds: number;
@@ -143,6 +143,26 @@ export type CalendarCalDavSourceCreatePayload = CalendarSyncSourceCreatePayload;
export type CalendarCalDavSourceUpdatePayload = Partial<CalendarCalDavSourceCreatePayload>; export type CalendarCalDavSourceUpdatePayload = Partial<CalendarCalDavSourceCreatePayload>;
export type CalendarSyncSourceUpdatePayload = Partial<CalendarSyncSourceCreatePayload>; export type CalendarSyncSourceUpdatePayload = Partial<CalendarSyncSourceCreatePayload>;
export type CalendarCredentialEnvelope = {
id: string;
scope_type: string;
scope_id?: string | null;
name: string;
description?: string | null;
credential_kind: string;
public_data: Record<string, unknown>;
secret_keys: string[];
secret_configured: boolean;
allowed_modules: string[];
inherit_to_lower_scopes: boolean;
is_active: boolean;
revision: string;
};
export type CalendarCredentialEnvelopeListResponse = {
credentials: CalendarCredentialEnvelope[];
};
export type CalendarSyncSourceSyncPayload = { export type CalendarSyncSourceSyncPayload = {
password?: string | null; password?: string | null;
bearer_token?: string | null; bearer_token?: string | null;
@@ -255,6 +275,13 @@ export function listCalDavSources(settings: ApiSettings, params: { calendar_id?:
return apiFetch<CalendarCalDavSourceListResponse>(settings, `/api/v1/calendar/caldav/sources${suffix}`); return apiFetch<CalendarCalDavSourceListResponse>(settings, `/api/v1/calendar/caldav/sources${suffix}`);
} }
export function listCalendarCredentials(settings: ApiSettings, sourceId?: string | null): Promise<CalendarCredentialEnvelopeListResponse> {
const search = new URLSearchParams();
if (sourceId) search.set("source_id", sourceId);
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarCredentialEnvelopeListResponse>(settings, `/api/v1/calendar/credentials${suffix}`);
}
export function discoverCalDavCalendars(settings: ApiSettings, payload: CalendarCalDavDiscoveryPayload): Promise<CalendarCalDavDiscoveryResponse> { export function discoverCalDavCalendars(settings: ApiSettings, payload: CalendarCalDavDiscoveryPayload): Promise<CalendarCalDavDiscoveryResponse> {
return apiFetch<CalendarCalDavDiscoveryResponse>(settings, "/api/v1/calendar/caldav/discover", { method: "POST", body: JSON.stringify(payload) }); return apiFetch<CalendarCalDavDiscoveryResponse>(settings, "/api/v1/calendar/caldav/discover", { method: "POST", body: JSON.stringify(payload) });
} }

View File

@@ -0,0 +1,827 @@
import {
useEffect,
useMemo,
useState,
type ChangeEvent,
type FormEvent,
} from "react";
import { RefreshCw, Trash2 } from "lucide-react";
import {
Button,
ColorPickerField,
Dialog,
PasswordField,
SegmentedControl,
ToggleSwitch,
i18nMessage,
useUnsavedDraftGuard,
type ApiSettings,
} from "@govoplan/core-webui";
import {
listCalendarCredentials,
type CalendarBulkMoveExternalAction,
type CalendarCalDavAuthType,
type CalendarCalDavConflictPolicy,
type CalendarCalDavDiscoveryCandidate,
type CalendarCalDavDiscoveryPayload,
type CalendarCalDavSyncDirection,
type CalendarCollection,
type CalendarCollectionDeletePayload,
type CalendarCredentialEnvelope,
type CalendarDeleteEventAction,
type CalendarSyncSource,
type CalendarSyncSourceCreatePayload,
type CalendarSyncSourceKind,
type CalendarSyncSourceUpdatePayload,
} from "../../api/calendar";
import {
DEFAULT_CALENDAR_COLOR,
calendarDraftKey,
dateTimeLabel,
errorText,
normalizeHexColor,
} from "./calendarViewModel";
export type CalendarSourceMode = "local" | CalendarSyncSourceKind;
type CalendarSourceSwitchMode =
| "local"
| "caldav"
| "ics"
| "graph"
| "ews";
export type CalendarCollectionDialogState =
| { kind: "create" }
| {
kind: "edit";
calendar: CalendarCollection;
eventCount: number | null;
loadingEventCount: boolean;
};
export type CalendarDeleteDialogState = {
calendar: CalendarCollection;
eventCount: number | null;
loadingEventCount: boolean;
};
export type CalendarCalDavFormPayload = {
collection_url: string;
dav_url: string;
display_name: string;
auth_type: CalendarCalDavAuthType;
username: string;
credential_envelope_id: string;
password: string;
bearer_token: string;
sync_enabled: boolean;
sync_interval_seconds: number;
sync_direction: CalendarCalDavSyncDirection;
conflict_policy: CalendarCalDavConflictPolicy;
};
export type CalendarCollectionFormPayload = {
sourceMode: CalendarSourceMode;
name: string;
color: string;
caldav: CalendarCalDavFormPayload;
};
export function CalendarCollectionDialog({
state,
settings,
source,
saving,
syncingSourceId,
canWrite,
canDelete,
canManageSources,
canSyncSources,
onCancel,
onSave,
onRequestDelete,
onSync,
onDiscover
}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
const calendar = state.kind === "edit" ? state.calendar : null;
const isEdit = Boolean(calendar);
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? source.source_kind : "local");
const [name, setName] = useState(calendar?.name ?? "");
const [color, setColor] = useState(normalizeHexColor(calendar?.color) || DEFAULT_CALENDAR_COLOR);
const [davUrl, setDavUrl] = useState(source?.collection_url ?? "");
const [collectionUrl, setCollectionUrl] = useState(source?.collection_url ?? "");
const [displayName, setDisplayName] = useState(source?.display_name ?? "");
const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
const [username, setUsername] = useState(source?.username ?? "");
const [credentialEnvelopeId, setCredentialEnvelopeId] = useState(source?.credential_envelope_id ?? "");
const [availableCredentials, setAvailableCredentials] = useState<CalendarCredentialEnvelope[]>([]);
const [credentialsError, setCredentialsError] = useState("");
const [password, setPassword] = useState("");
const [bearerToken, setBearerToken] = useState("");
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
const [syncIntervalMinutes, setSyncIntervalMinutes] = useState(Math.max(1, Math.round((source?.sync_interval_seconds ?? 900) / 60)));
const [syncDirection, setSyncDirection] = useState<CalendarCalDavSyncDirection>(source?.sync_direction ?? "two_way");
const [conflictPolicy, setConflictPolicy] = useState<CalendarCalDavConflictPolicy>(source?.conflict_policy ?? "etag");
const [discoveredCalendars, setDiscoveredCalendars] = useState<CalendarCalDavDiscoveryCandidate[]>([]);
const [selectedDiscoveredUrl, setSelectedDiscoveredUrl] = useState(source?.collection_url ?? "");
const [discovering, setDiscovering] = useState(false);
const [discoveryError, setDiscoveryError] = useState("");
const formId = "calendar-collection-form";
const isExistingSyncSource = isEdit && Boolean(source);
const canEditSource = canManageSources;
const canEditMutableSourceSettings = canEditSource && !isExistingSyncSource;
const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
const needsSourceSecret = sourceMode !== "local" && (
effectiveAuthType === "basic" && !source?.has_credential && !credentialEnvelopeId && !password.trim() ||
effectiveAuthType === "bearer" && !source?.has_credential && !credentialEnvelopeId && !bearerToken.trim());
const sourceDetailsInvalid = sourceMode !== "local" && (
!isExistingSyncSource && !canEditSource ||
canEditSource && (!effectiveCollectionUrl || effectiveAuthType === "basic" && !username.trim() || needsSourceSecret));
const saveDisabled =
saving ||
!canWrite ||
!name.trim() ||
sourceDetailsInvalid;
const syncing = source ? syncingSourceId === source.id : false;
const collectionDraft = {
sourceMode,
name,
color,
davUrl,
collectionUrl,
displayName,
authType,
username,
credentialEnvelopeId,
password,
bearerToken,
syncEnabled,
syncIntervalMinutes,
syncDirection,
conflictPolicy
};
const initialCollectionDraftKey = useMemo(() => calendarDraftKey(collectionDraft), []);
const collectionDirty = calendarDraftKey(collectionDraft) !== initialCollectionDraftKey;
useUnsavedDraftGuard({
dirty: collectionDirty,
onSave: saveCurrent,
onDiscard: onCancel
});
useEffect(() => {
if (sourceMode === "local" || !canManageSources) {
setAvailableCredentials([]);
setCredentialsError("");
return;
}
let active = true;
listCalendarCredentials(settings, source?.id)
.then((response) => {
if (active) setAvailableCredentials(response.credentials);
})
.catch((err) => {
if (active) {
setAvailableCredentials([]);
setCredentialsError(errorText(err));
}
});
return () => {
active = false;
};
}, [canManageSources, settings.accessToken, settings.apiBaseUrl, settings.apiKey, source?.id, sourceMode]);
function currentPayload(): CalendarCollectionFormPayload {
return {
sourceMode,
name,
color,
caldav: {
collection_url: effectiveCollectionUrl,
dav_url: davUrl,
display_name: displayName,
auth_type: effectiveAuthType,
username,
credential_envelope_id: credentialEnvelopeId,
password,
bearer_token: bearerToken,
sync_enabled: syncEnabled,
sync_interval_seconds: syncIntervalMinutes * 60,
sync_direction: syncDirection,
conflict_policy: conflictPolicy
}
};
}
async function saveCurrent(): Promise<boolean> {
if (saveDisabled) return false;
return onSave(currentPayload());
}
function submit(formEvent: FormEvent<HTMLFormElement>) {
formEvent.preventDefault();
void saveCurrent();
}
function selectSourceMode(next: CalendarSourceMode) {
setSourceMode(next);
setDiscoveryError("");
setDiscoveredCalendars([]);
if (next === "graph") {
setAuthType("bearer");
setSyncDirection("inbound");
if (!davUrl.trim()) handleDavUrlChange("me/calendar");
return;
}
if (next === "ews") {
setAuthType("basic");
setSyncDirection("inbound");
if (!davUrl.trim()) handleDavUrlChange("https://exchange.example.org/EWS/Exchange.asmx");
return;
}
if (next === "ics" || next === "webcal") {
setAuthType("none");
setSyncDirection("inbound");
return;
}
if (next === "caldav") {
setSyncDirection("two_way");
}
}
function handleDavUrlChange(next: string) {
setDavUrl(next);
setCollectionUrl(next);
setSelectedDiscoveredUrl("");
setDiscoveredCalendars([]);
setDiscoveryError("");
}
function applyDiscoveredCalendar(candidate: CalendarCalDavDiscoveryCandidate) {
setSelectedDiscoveredUrl(candidate.collection_url);
setCollectionUrl(candidate.collection_url);
if (candidate.display_name) {
if (!isExistingSyncSource) setDisplayName(candidate.display_name);
if (!isEdit && !name.trim()) setName(candidate.display_name);
}
const discoveredColor = normalizeHexColor(candidate.color);
if (!isEdit && discoveredColor) setColor(discoveredColor);
}
function handleDiscoveredCalendarChange(event: ChangeEvent<HTMLSelectElement>) {
const candidate = discoveredCalendars.find((item) => item.collection_url === event.target.value);
if (candidate) applyDiscoveredCalendar(candidate);
}
async function handleDiscover() {
const url = davUrl.trim();
if (!url) return;
setDiscovering(true);
setDiscoveryError("");
try {
const payload: CalendarCalDavDiscoveryPayload = {
url,
source_id: source?.id ?? null,
auth_type: authType,
username: authType === "basic" ? username.trim() || null : null
};
if (credentialEnvelopeId) payload.credential_ref = credentialEnvelopeRef(credentialEnvelopeId);
if (!credentialEnvelopeId && authType === "basic" && password.trim()) payload.password = password;
if (!credentialEnvelopeId && authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
const response = await onDiscover(payload);
setDiscoveredCalendars(response.calendars);
if (response.calendars.length > 0) {
applyDiscoveredCalendar(response.calendars[0]);
} else {
setDiscoveryError("i18n:govoplan-calendar.no_calendar_collections_found.6453624a");
}
} catch (err) {
setDiscoveryError(errorText(err));
} finally {
setDiscovering(false);
}
}
return (
<Dialog
open
title={isEdit ? "i18n:govoplan-calendar.edit_calendar.a47a2a7a" : "i18n:govoplan-calendar.add_calendar.8fadb5bc"}
className="calendar-event-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={
<>
<div>
{calendar && canDelete &&
<Button type="button" variant="danger" onClick={() => onRequestDelete(calendar, state.kind === "edit" ? state.eventCount : null, state.kind === "edit" ? state.loadingEventCount : true)} disabled={saving}>
<Trash2 size={16} /> {calendarDeleteActionLabel(calendar)}
</Button>
}
</div>
<div className="calendar-dialog-actions">
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="submit" form={formId} variant="primary" disabled={saveDisabled}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : isEdit ? "i18n:govoplan-calendar.save.efc007a3" : "i18n:govoplan-calendar.add.61cc55aa"}</Button>
</div>
</>
}>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
{sourceMode !== "local" && !canManageSources && <p className="calendar-form-note">i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa</p>}
{!isEdit &&
<SegmentedControl
className="calendar-source-switch"
size="equal"
width="fill"
ariaLabel="i18n:govoplan-calendar.calendar_source_type.92cdb42f"
value={sourceSwitchMode(sourceMode)}
disabled={saving}
onChange={selectSourceMode}
options={[
{ id: "local", label: "i18n:govoplan-calendar.local.dc99d54d" },
{ id: "caldav", label: "i18n:govoplan-calendar.caldav.64f9720e", disabled: !canManageSources },
{ id: "ics", label: "i18n:govoplan-calendar.ics_webcal.9c55b570", disabled: !canManageSources },
{ id: "graph", label: "i18n:govoplan-calendar.graph.9a7405eb", disabled: !canManageSources },
{ id: "ews", label: "i18n:govoplan-calendar.exchange.5b13eac7", disabled: !canManageSources }
]}
/>
}
<div className="calendar-dialog-name-color-row">
<label>
<span>i18n:govoplan-calendar.name.709a2322</span>
<input value={name} onChange={(item) => setName(item.target.value)} required maxLength={255} autoFocus disabled={saving || !canWrite} />
</label>
<label className="calendar-dialog-color-label">
<span>i18n:govoplan-calendar.color.1d0c8304</span>
<ColorPickerField value={color} onChange={setColor} disabled={saving || !canWrite} />
</label>
</div>
{sourceMode === "local" ?
<section className="calendar-source-pane">
<h3>i18n:govoplan-calendar.local_calendar.ed3f72f8</h3>
<p className="calendar-form-note">i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504</p>
</section> :
<section className="calendar-source-pane">
<div className="calendar-source-pane-heading">
<h3>{calendarSourcePaneTitle(sourceMode)}</h3>
{source &&
<span className={[
"calendar-sync-status",
syncing ? "is-syncing" : "",
source.last_status === "error" && !syncing ? "is-error" : ""].
filter(Boolean).join(" ")}>
{syncing ? "i18n:govoplan-calendar.syncing.4ae6fa22" : source.last_status || "i18n:govoplan-calendar.not_synced.4c205136"}
</span>
}
</div>
<div className="calendar-caldav-setup">
<label className="calendar-dialog-wide">
<span>{calendarSourceUrlLabel(sourceMode)}</span>
<input
value={davUrl}
onChange={(item) => handleDavUrlChange(item.target.value)}
placeholder={calendarSourceUrlPlaceholder(sourceMode)}
required
maxLength={1000}
disabled={saving || !canEditSource} />
</label>
<label>
<span>i18n:govoplan-calendar.authentication.ee1acfa5</span>
<select value={effectiveAuthType} onChange={(item) => setAuthType(item.target.value as CalendarCalDavAuthType)} disabled={saving || !canEditSource || sourceMode === "graph"}>
{sourceMode !== "graph" && <option value="basic">i18n:govoplan-calendar.basic.aa2c96da</option>}
{sourceMode !== "graph" && sourceMode !== "ews" && <option value="none">i18n:govoplan-calendar.none.6eef6648</option>}
<option value="bearer">i18n:govoplan-calendar.bearer_token.ffa64bcf</option>
</select>
</label>
{effectiveAuthType !== "none" &&
<label>
<span>Reusable credential</span>
<select
value={credentialEnvelopeId}
disabled={saving || !canEditSource}
onChange={(event) => {
const credentialId = event.target.value;
setCredentialEnvelopeId(credentialId);
setPassword("");
setBearerToken("");
const credential = availableCredentials.find((item) => item.id === credentialId);
const credentialUsername = credential?.public_data?.username;
if (credentialUsername) setUsername(String(credentialUsername));
}}>
<option value="">{source?.has_credential && !source.credential_envelope_id ? "Keep source-specific credential" : "Enter credentials below"}</option>
{availableCredentials.map((credential) =>
<option key={credential.id} value={credential.id}>
{credential.name}{credential.public_data?.username ? ` (${String(credential.public_data.username)})` : ""}
</option>
)}
</select>
</label>
}
{credentialsError && <p className="calendar-form-error calendar-dialog-wide">{credentialsError}</p>}
{effectiveAuthType === "basic" &&
<>
<label>
<span>i18n:govoplan-calendar.username.84c29015</span>
<input value={username} onChange={(item) => setUsername(item.target.value)} required maxLength={255} disabled={saving || !canEditSource || Boolean(credentialEnvelopeId)} />
</label>
{!credentialEnvelopeId &&
<label>
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
<PasswordField value={password} onValueChange={setPassword} disabled={saving || !canEditSource} autoComplete="new-password" />
</label>
}
</>
}
{effectiveAuthType === "bearer" && !credentialEnvelopeId &&
<label>
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
<PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
</label>
}
<div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
{sourceMode === "caldav" &&
<Button type="button" onClick={() => void handleDiscover()} disabled={saving || discovering || !canEditSource || !davUrl.trim() || effectiveAuthType === "basic" && !username.trim()}>
<RefreshCw size={16} /> {discovering ? "i18n:govoplan-calendar.discovering.1884f689" : "i18n:govoplan-calendar.discover.4827ea22"}
</Button>
}
{effectiveCollectionUrl && <span>{effectiveCollectionUrl}</span>}
</div>
</div>
{discoveryError && <p className="calendar-form-error">{discoveryError}</p>}
{sourceMode === "caldav" && discoveredCalendars.length > 0 &&
<label>
<span>i18n:govoplan-calendar.calendar.adab5090</span>
<select value={selectedDiscoveredUrl} onChange={handleDiscoveredCalendarChange} disabled={saving || !canEditSource}>
{discoveredCalendars.map((candidate) =>
<option key={candidate.collection_url} value={candidate.collection_url}>
{candidate.display_name || candidate.collection_url}
</option>
)}
</select>
</label>
}
<details className="calendar-advanced-settings">
<summary>i18n:govoplan-calendar.advanced.4d064726</summary>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.display_name.c7874aaa</span>
<input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} />
</label>
</div>
<div className="calendar-sync-settings">
<ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} />
<label>
<span>i18n:govoplan-calendar.interval.011efcd5</span>
<input type="number" min={1} step={1} value={syncIntervalMinutes} onChange={(item) => setSyncIntervalMinutes(Math.max(1, Number(item.target.value) || 1))} disabled={saving || !canEditMutableSourceSettings} />
</label>
<label>
<span>i18n:govoplan-calendar.direction.fd8e45ba</span>
<select value={sourceMode === "caldav" ? syncDirection : "inbound"} onChange={(item) => setSyncDirection(item.target.value as CalendarCalDavSyncDirection)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
<option value="two_way">i18n:govoplan-calendar.two_way.ee50a3e6</option>
<option value="inbound">i18n:govoplan-calendar.inbound_only.bf4269b0</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.conflict_policy.5810e150</span>
<select value={conflictPolicy} onChange={(item) => setConflictPolicy(item.target.value as CalendarCalDavConflictPolicy)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
<option value="etag">i18n:govoplan-calendar.require_matching_etag.0ab1ffe1</option>
<option value="overwrite">i18n:govoplan-calendar.overwrite_remote.39625e32</option>
</select>
</label>
</div>
</details>
{source &&
<div className="calendar-sync-status-panel">
<dl>
<div><dt>i18n:govoplan-calendar.last_attempt.82aee111</dt><dd>{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.last_sync.ef0ef267</dt><dd>{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.next_sync.88c7af72</dt><dd>{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}</dd></div>
<div><dt>i18n:govoplan-calendar.credential.8bede3ea</dt><dd>{source.has_credential ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}</dd></div>
</dl>
{source.last_error && <p className="calendar-form-error">{source.last_error}</p>}
<div className="calendar-sync-actions">
<Button
type="button"
className={syncing ? "calendar-sync-button is-syncing" : "calendar-sync-button"}
onClick={() => void onSync(source, syncTransientPayload(effectiveAuthType, password, bearerToken))}
disabled={saving || syncing || !canSyncSources}>
<RefreshCw size={16} className={syncing ? "calendar-sync-spin" : undefined} /> {syncing ? "i18n:govoplan-calendar.syncing.e5c7727a" : "i18n:govoplan-calendar.sync_now.2b7d938e"}
</Button>
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || !canSyncSources}>
i18n:govoplan-calendar.full_sync.21b89c76
</Button>
</div>
</div>
}
{needsSourceSecret && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54</p>}
</section>
}
</form>
</Dialog>);
}
export function CalendarCollectionDeleteDialog({
state,
calendars,
syncSources,
saving,
onCancel,
onDelete
}: {state: CalendarDeleteDialogState;calendars: CalendarCollection[];syncSources: CalendarSyncSource[];saving: boolean;onCancel: () => void;onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise<void>;}) {
const { calendar, eventCount, loadingEventCount } = state;
const syncSourceByCalendarId = new Map(syncSources.map((source) => [source.calendar_id, source]));
const source = syncSourceByCalendarId.get(calendar.id) ?? null;
const moveTargets = calendars.filter((item) =>
item.id !== calendar.id && calendarMoveTargetIsSupported(source, syncSourceByCalendarId.get(item.id) ?? null)
);
const firstMoveTargetId = moveTargets[0]?.id || "";
const hasEvents = (eventCount ?? 0) > 0;
const canMoveEvents = hasEvents && moveTargets.length > 0;
const [eventAction, setEventAction] = useState<CalendarDeleteEventAction>("delete");
const [targetCalendarId, setTargetCalendarId] = useState(firstMoveTargetId);
const [makeTargetDefault, setMakeTargetDefault] = useState(calendar.is_default && Boolean(firstMoveTargetId));
const effectiveEventAction: CalendarDeleteEventAction = canMoveEvents ? eventAction : "delete";
const confirmDisabled = saving || loadingEventCount || canMoveEvents && effectiveEventAction === "move" && !targetCalendarId;
const actionLabel = calendarDeleteActionLabel(calendar);
const targetSource = syncSourceByCalendarId.get(targetCalendarId) ?? null;
const externalAction = calendarBulkMoveExternalAction(source, targetSource);
function confirm() {
void onDelete(calendar, {
event_action: effectiveEventAction,
target_calendar_id: effectiveEventAction === "move" ? targetCalendarId : null,
make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault,
external_action: effectiveEventAction === "move" ? externalAction : null
});
}
return (
<Dialog
open
role="alertdialog"
title={`${actionLabel} calendar`}
className="calendar-delete-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={
<>
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="button" variant="danger" onClick={confirm} disabled={confirmDisabled}>
<Trash2 size={16} /> {saving ? "i18n:govoplan-calendar.working.049ac820" : actionLabel}
</Button>
</>
}>
<div className="calendar-delete-dialog-body">
<p className="calendar-delete-warning">
{loadingEventCount ?
"i18n:govoplan-calendar.loading_event_count.716ad3c2" :
calendarDeleteWarning(calendar, eventCount ?? 0, effectiveEventAction, targetCalendarId, moveTargets)}
</p>
{!loadingEventCount && eventCount === null && <p className="calendar-form-note">i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055</p>}
{canMoveEvents &&
<fieldset className="calendar-delete-options" disabled={saving || loadingEventCount}>
<legend>{eventCount} event{eventCount === 1 ? "" : "s"}</legend>
<label>
<input
type="radio"
name="calendar-delete-event-action"
value="delete"
checked={eventAction === "delete"}
onChange={() => setEventAction("delete")} />
<span>{calendarEventDeleteOptionLabel(calendar)}</span>
</label>
<label>
<input
type="radio"
name="calendar-delete-event-action"
value="move"
checked={eventAction === "move"}
onChange={() => setEventAction("move")} />
<span>{calendarBulkMoveOptionLabel(externalAction)}</span>
</label>
{eventAction === "move" &&
<>
<label className="calendar-delete-target-label">
<span>i18n:govoplan-calendar.target_calendar.e533fe1d</span>
<select value={targetCalendarId} onChange={(item) => setTargetCalendarId(item.target.value)} required>
{moveTargets.map((item) =>
<option key={item.id} value={item.id}>{item.name}</option>
)}
</select>
</label>
{calendar.is_default &&
<ToggleSwitch label="i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b" checked={makeTargetDefault} onChange={setMakeTargetDefault} />
}
<p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p>
</>
}
</fieldset>
}
{!loadingEventCount && hasEvents && moveTargets.length === 0 &&
<p className="calendar-form-note">{source ?
"i18n:govoplan-calendar.no_local_target_calendar_is_available_add_a_local_.fad3cb65" :
"i18n:govoplan-calendar.no_compatible_target_calendar_is_available_add_a_l.1cf6e47b"}</p>
}
</div>
</Dialog>);
}
export function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload: CalendarCalDavFormPayload): Omit<CalendarSyncSourceCreatePayload, "calendar_id"> {
const sourceKind = syncSourceKindForMode(sourceMode, payload.collection_url);
const result: Omit<CalendarSyncSourceCreatePayload, "calendar_id"> = {
source_kind: sourceKind,
collection_url: payload.collection_url.trim(),
display_name: payload.display_name.trim() || null,
auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type,
username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null,
sync_enabled: payload.sync_enabled,
sync_interval_seconds: Math.max(60, payload.sync_interval_seconds),
sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound",
conflict_policy: payload.conflict_policy,
metadata: {}
};
if (payload.credential_envelope_id) {
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
} else if (result.auth_type === "basic" && payload.password.trim()) {
result.password = payload.password;
} else if (result.auth_type === "bearer" && payload.bearer_token.trim()) {
result.bearer_token = payload.bearer_token;
}
return result;
}
export function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload): CalendarSyncSourceUpdatePayload {
const result: CalendarSyncSourceUpdatePayload = {
collection_url: payload.collection_url.trim(),
auth_type: payload.auth_type,
username: payload.auth_type === "basic" ? payload.username.trim() || null : null
};
if (payload.credential_envelope_id) {
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
} else if (payload.auth_type === "basic" && payload.password.trim()) {
result.password = payload.password;
} else if (payload.auth_type === "bearer" && payload.bearer_token.trim()) {
result.bearer_token = payload.bearer_token;
}
return result;
}
function credentialEnvelopeRef(credentialId: string): string {
return `credential-envelope:${credentialId}`;
}
function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind {
if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal";
return sourceMode === "local" ? "caldav" : sourceMode;
}
function sourceSwitchMode(sourceMode: CalendarSourceMode): CalendarSourceSwitchMode {
return sourceMode === "webcal" ? "ics" : sourceMode;
}
function syncTransientPayload(authType: CalendarCalDavAuthType, password: string, bearerToken: string) {
if (authType === "basic" && password.trim()) return { password };
if (authType === "bearer" && bearerToken.trim()) return { bearer_token: bearerToken };
return {};
}
function calendarSourcePaneTitle(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "i18n:govoplan-calendar.caldav_source.459ed16a";
if (sourceMode === "ics" || sourceMode === "webcal") return "i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63";
if (sourceMode === "graph") return "i18n:govoplan-calendar.microsoft_graph_source.ec4f1383";
if (sourceMode === "ews") return "i18n:govoplan-calendar.exchange_web_services_source.53caabf3";
return "i18n:govoplan-calendar.local_calendar.ed3f72f8";
}
function calendarSourceUrlLabel(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "i18n:govoplan-calendar.dav_url.4205e180";
if (sourceMode === "graph") return "i18n:govoplan-calendar.graph_calendar_url.e59607f3";
if (sourceMode === "ews") return "i18n:govoplan-calendar.ews_endpoint.a3273983";
return "i18n:govoplan-calendar.subscription_url.b8b6a1a0";
}
function calendarSourceUrlPlaceholder(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "https://cloud.example.org/remote.php/dav";
if (sourceMode === "graph") return "me/calendar or https://graph.microsoft.com/v1.0/me/calendar/events/delta";
if (sourceMode === "ews") return "https://exchange.example.org/EWS/Exchange.asmx";
return "webcal://example.org/calendar.ics or https://example.org/calendar.ics";
}
function calendarDeleteActionLabel(calendar: CalendarCollection): string {
return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove.e963907d" : "i18n:govoplan-calendar.delete.f6fdbe48";
}
function calendarEventDeleteOptionLabel(calendar: CalendarCollection): string {
return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove_local_events_with_this_calendar.fb8831e1" : "i18n:govoplan-calendar.delete_events_with_this_calendar.cc0e1287";
}
function calendarMoveTargetIsSupported(
source: CalendarSyncSource | null,
targetSource: CalendarSyncSource | null)
: boolean {
if (source) return targetSource === null;
return targetSource === null || calendarSyncSourceAcceptsCopies(targetSource);
}
function calendarSyncSourceAcceptsCopies(source: CalendarSyncSource): boolean {
return source.source_kind === "caldav" && source.sync_enabled && source.sync_direction === "two_way";
}
function calendarBulkMoveExternalAction(
source: CalendarSyncSource | null,
targetSource: CalendarSyncSource | null)
: CalendarBulkMoveExternalAction | undefined {
if (source && !targetSource) return "detach_keep_remote";
if (!source && targetSource && calendarSyncSourceAcceptsCopies(targetSource)) return "copy_to_remote";
return undefined;
}
function calendarBulkMoveOptionLabel(action: CalendarBulkMoveExternalAction | undefined): string {
if (action === "detach_keep_remote") return "i18n:govoplan-calendar.detach_local_events_and_keep_remote_events.c58ad4e7";
if (action === "copy_to_remote") return "i18n:govoplan-calendar.move_and_copy_events_to_the_external_calendar.23c3a004";
return "i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09";
}
function calendarBulkMoveConsequence(action: CalendarBulkMoveExternalAction | undefined): string {
if (action === "detach_keep_remote") return "i18n:govoplan-calendar.govoplan_moves_the_local_event_copies_to_the_targe.4863e46b";
if (action === "copy_to_remote") return "i18n:govoplan-calendar.govoplan_moves_the_events_locally_and_queues_durab.da075b16";
return "i18n:govoplan-calendar.events_remain_in_govoplan_no_external_calendar_is_.62cb5937";
}
function calendarDeleteWarning(
calendar: CalendarCollection,
eventCount: number,
eventAction: CalendarDeleteEventAction,
targetCalendarId: string,
moveTargets: CalendarCollection[])
: string {
const action = calendarIsExternal(calendar) ? "i18n:govoplan-calendar.removing.b7d2c38b" : "i18n:govoplan-calendar.deleting.2cda36c9";
const eventWord = eventCount === 1 ? "i18n:govoplan-calendar.event" : "i18n:govoplan-calendar.events";
if (eventAction === "move") {
const target = moveTargets.find((item) => item.id === targetCalendarId);
return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_move_value_value_to_val.6c87e1c1", { value0: action, value1: eventCount, value2: eventWord, value3: target?.name || "i18n:govoplan-calendar.the_selected_calendar.67caa12f" });
}
if (calendarIsExternal(calendar)) {
return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_remove_value_local_valu.8f21a59e", { value0: action, value1: eventCount, value2: eventWord });
}
return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_delete_value_value.7869d1f1", { value0: action, value1: eventCount, value2: eventWord });
}
function calendarIsExternal(calendar: CalendarCollection): boolean {
const metadata = calendar.metadata;
if (!metadata || typeof metadata !== "object") return false;
const sourceKind = metadataText(metadata, "source_kind") || metadataText(metadata, "sourceKind") || metadataText(metadata, "connector_kind") || metadataText(metadata, "connectorKind");
if (sourceKind && sourceKind !== "local") return true;
const connectionKind = metadataText(metadata, "connection_kind") || metadataText(metadata, "connectionKind");
if (connectionKind && connectionKind !== "local") return true;
return Boolean(
metadataFlag(metadata, "external") ||
metadataFlag(metadata, "remote") ||
metadataFlag(metadata, "sync") ||
metadataFlag(metadata, "caldav") ||
metadataText(metadata, "connector_id") ||
metadataText(metadata, "connectorId") ||
metadataText(metadata, "sync_href") ||
metadataText(metadata, "syncHref")
);
}
function metadataText(metadata: Record<string, unknown>, key: string): string {
const value = metadata[key];
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function metadataFlag(metadata: Record<string, unknown>, key: string): boolean {
return metadata[key] === true;
}

View File

@@ -0,0 +1,562 @@
import {
useMemo,
useState,
type FormEvent,
} from "react";
import { Trash2 } from "lucide-react";
import {
Button,
DateField,
Dialog,
TimeField,
ToggleSwitch,
useUnsavedDraftGuard,
} from "@govoplan/core-webui";
import type {
CalendarCollection,
CalendarEvent,
CalendarEventCreatePayload,
} from "../../api/calendar";
import {
addDays,
addHours,
addMinutesToTime,
calendarDraftKey,
errorText,
localDateTime,
startOfDay,
toInputDate,
toInputTime,
} from "./calendarViewModel";
type CalendarEventEndMode = "end" | "duration";
type CalendarEventAdvancedPayload = Pick<
CalendarEventCreatePayload,
| "organizer"
| "attendees"
| "categories"
| "rrule"
| "rdate"
| "exdate"
| "reminders"
| "attachments"
| "related_to"
| "icalendar"
| "metadata"
>;
export function CalendarEventDialog({
calendars,
defaultCalendarId,
event,
focusDate,
saving,
canWrite,
canDelete,
onCancel,
onSave,
onDelete
}: {calendars: CalendarCollection[];defaultCalendarId: string;event: CalendarEvent | null;focusDate: Date;saving: boolean;canWrite: boolean;canDelete: boolean;onCancel: () => void;onSave: (payload: CalendarEventCreatePayload, event: CalendarEvent | null) => Promise<boolean>;onDelete: (event: CalendarEvent) => Promise<void>;}) {
const initialStart = event ? new Date(event.start_at) : focusDate;
const initialEnd = event?.end_at ? new Date(event.end_at) : addHours(initialStart, 1);
const initialAllDayEnd = event?.all_day && event.end_at ? addDays(startOfDay(initialEnd), -1) : initialEnd;
const initialDurationSeconds = event?.duration_seconds ?? Math.max(3600, Math.round((initialEnd.getTime() - initialStart.getTime()) / 1000));
const [summary, setSummary] = useState(event?.summary ?? "");
const [calendarId, setCalendarId] = useState(event?.calendar_id ?? defaultCalendarId);
const [description, setDescription] = useState(event?.description ?? "");
const [location, setLocation] = useState(event?.location ?? "");
const [startDate, setStartDate] = useState(toInputDate(initialStart));
const [endDate, setEndDate] = useState(toInputDate(initialAllDayEnd < initialStart ? initialStart : initialAllDayEnd));
const [startTime, setStartTime] = useState(toInputTime(initialStart));
const [endTime, setEndTime] = useState(toInputTime(initialEnd));
const [allDay, setAllDay] = useState(event?.all_day ?? false);
const [endMode, setEndMode] = useState<CalendarEventEndMode>(event && eventUsesDuration(event) ? "duration" : "end");
const [durationSeconds, setDurationSeconds] = useState(String(initialDurationSeconds));
const [uid, setUid] = useState(event?.uid ?? "");
const [recurrenceId, setRecurrenceId] = useState(event?.recurrence_id ?? "");
const [sequence, setSequence] = useState(String(event?.sequence ?? 0));
const [status, setStatus] = useState(event?.status ?? "CONFIRMED");
const [transparency, setTransparency] = useState(event?.transparency ?? "OPAQUE");
const [classification, setClassification] = useState(event?.classification ?? "PUBLIC");
const [timezone, setTimezone] = useState(event?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
const [categoriesText, setCategoriesText] = useState((event?.categories ?? []).join(", "));
const [organizerJson, setOrganizerJson] = useState(jsonTextareaValue(event?.organizer ?? null));
const [attendeesJson, setAttendeesJson] = useState(jsonTextareaValue(event?.attendees ?? []));
const [rruleText, setRruleText] = useState(event?.rrule ? serializeRRuleText(event.rrule) : "");
const [rdateJson, setRdateJson] = useState(jsonTextareaValue(event?.rdate ?? []));
const [exdateJson, setExdateJson] = useState(jsonTextareaValue(event?.exdate ?? []));
const [remindersJson, setRemindersJson] = useState(jsonTextareaValue(event?.reminders ?? []));
const [attachmentsJson, setAttachmentsJson] = useState(jsonTextareaValue(event?.attachments ?? []));
const [relatedToJson, setRelatedToJson] = useState(jsonTextareaValue(event?.related_to ?? []));
const [sourceKind, setSourceKind] = useState(event?.source_kind ?? "local");
const [sourceHref, setSourceHref] = useState(event?.source_href ?? "");
const [etag, setEtag] = useState(event?.etag ?? "");
const [icalendarJson, setICalendarJson] = useState(jsonTextareaValue(event?.icalendar ?? {}));
const [metadataJson, setMetadataJson] = useState(jsonTextareaValue(event?.metadata ?? {}));
const [formError, setFormError] = useState("");
const [confirmingDelete, setConfirmingDelete] = useState(false);
const formId = "calendar-event-form";
const eventDraft = {
summary,
calendarId,
description,
location,
startDate,
endDate,
startTime,
endTime,
allDay,
endMode,
durationSeconds,
uid,
recurrenceId,
sequence,
status,
transparency,
classification,
timezone,
categoriesText,
organizerJson,
attendeesJson,
rruleText,
rdateJson,
exdateJson,
remindersJson,
attachmentsJson,
relatedToJson,
sourceKind,
sourceHref,
etag,
icalendarJson,
metadataJson
};
const initialEventDraftKey = useMemo(() => calendarDraftKey(eventDraft), []);
const eventDirty = calendarDraftKey(eventDraft) !== initialEventDraftKey;
useUnsavedDraftGuard({
dirty: eventDirty,
onSave: saveCurrent,
onDiscard: onCancel
});
function handleAllDayChange(next: boolean) {
setAllDay(next);
if (next) {
setStartTime("00:00");
setEndTime("00:00");
} else if (startTime === "00:00" && endTime === "00:00") {
setStartTime("09:00");
setEndTime("10:00");
}
}
function handleStartDateChange(next: string) {
setStartDate(next);
if (endDate < next) setEndDate(next);
}
function handleStartTimeChange(next: string) {
setStartTime(next);
if (startDate === endDate && endTime <= next) setEndTime(addMinutesToTime(next, 60));
}
async function saveCurrent(): Promise<boolean> {
setFormError("");
const startAt = allDay ? localDateTime(startDate, "00:00") : localDateTime(startDate, startTime);
const parsedDurationSeconds = Math.max(0, Number(durationSeconds) || 0);
const usesDuration = !allDay && endMode === "duration";
const endAt = allDay ?
addDays(localDateTime(endDate, "00:00"), 1) :
usesDuration ?
new Date(startAt.getTime() + parsedDurationSeconds * 1000) :
localDateTime(endDate, endTime);
if (usesDuration && parsedDurationSeconds <= 0) {
setFormError("i18n:govoplan-calendar.duration_must_be_greater_than_zero.519292f7");
return false;
}
if (endAt <= startAt) {
setFormError(allDay ? "i18n:govoplan-calendar.end_date_must_be_on_or_after_start_date.a2518865" : "i18n:govoplan-calendar.end_date_and_time_must_be_after_start_date_and_t.daf31831");
return false;
}
let advanced: CalendarEventAdvancedPayload;
try {
advanced = {
organizer: parseJsonObjectOrNull(organizerJson, "i18n:govoplan-calendar.organizer.debd1720"),
attendees: parseJsonArray(attendeesJson, "i18n:govoplan-calendar.attendees.a45a0962"),
categories: commaSeparatedValues(categoriesText),
rrule: parseRRuleInput(rruleText),
rdate: parseJsonArray(rdateJson, "RDATE"),
exdate: parseJsonArray(exdateJson, "EXDATE"),
reminders: parseJsonArray(remindersJson, "i18n:govoplan-calendar.reminders.ae8c3939"),
attachments: parseJsonArray(attachmentsJson, "i18n:govoplan-calendar.attachments.6771ade6"),
related_to: parseJsonArray(relatedToJson, "i18n:govoplan-calendar.related_to.0e7989ff"),
icalendar: withDurationMode(parseJsonObject(icalendarJson, "i18n:govoplan-calendar.icalendar.f388476d"), usesDuration),
metadata: parseJsonObject(metadataJson, "i18n:govoplan-calendar.metadata.251edc0e")
};
} catch (err) {
setFormError(errorText(err));
return false;
}
const payload: CalendarEventCreatePayload = {
calendar_id: calendarId,
summary,
description: description || null,
location: location || null,
sequence: Math.max(0, Number(sequence) || 0),
status,
transparency,
classification,
start_at: startAt.toISOString(),
end_at: endAt.toISOString(),
duration_seconds: usesDuration ? parsedDurationSeconds : null,
all_day: allDay,
timezone: timezone.trim() || null,
source_kind: sourceKind.trim() || "local",
source_href: sourceHref.trim() || null,
etag: etag.trim() || null,
...advanced
};
if (!event) {
if (uid.trim()) payload.uid = uid.trim();
if (recurrenceId.trim()) payload.recurrence_id = recurrenceId.trim();
}
return onSave(payload, event);
}
function submit(formEvent: FormEvent<HTMLFormElement>) {
formEvent.preventDefault();
void saveCurrent();
}
function requestDelete() {
if (!event) return;
if (!confirmingDelete) {
setConfirmingDelete(true);
return;
}
void onDelete(event);
}
return (
<Dialog
open
title={event ? "i18n:govoplan-calendar.edit_event.a7028454" : "i18n:govoplan-calendar.new_event.2ef3795c"}
className="calendar-event-dialog calendar-vevent-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={
<>
<div>
{event && canDelete &&
<Button type="button" variant="danger" onClick={requestDelete} disabled={saving}>
<Trash2 size={16} /> {confirmingDelete ? "i18n:govoplan-calendar.confirm_delete.c9f2829e" : "i18n:govoplan-calendar.delete.f6fdbe48"}
</Button>
}
</div>
<div className="calendar-dialog-actions">
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="submit" form={formId} variant="primary" disabled={saving || !canWrite}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : "i18n:govoplan-calendar.save.efc007a3"}</Button>
</div>
</>
}>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
{formError && <p className="calendar-form-error">{formError}</p>}
<label>
<span>i18n:govoplan-calendar.title.768e0c1c</span>
<input value={summary} onChange={(item) => setSummary(item.target.value)} required maxLength={500} autoFocus disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.calendar.adab5090</span>
<select value={calendarId} onChange={(item) => setCalendarId(item.target.value)} required disabled={saving || !canWrite}>
{calendars.map((calendar) =>
<option key={calendar.id} value={calendar.id}>{calendar.name}</option>
)}
</select>
</label>
<label>
<span>i18n:govoplan-calendar.description.55f8ebc8</span>
<textarea value={description} onChange={(item) => setDescription(item.target.value)} rows={4} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.location.d219c681</span>
<input value={location} onChange={(item) => setLocation(item.target.value)} maxLength={500} disabled={saving || !canWrite} />
</label>
<ToggleSwitch label="i18n:govoplan-calendar.whole_day.951c82d1" checked={allDay} disabled={saving || !canWrite} onChange={handleAllDayChange} />
<div className="calendar-dialog-date-row">
<label>
<span>i18n:govoplan-calendar.start_date.ff99f5b5</span>
<DateField value={startDate} onChange={handleStartDateChange} required disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.start_time.88d8206d</span>
<TimeField value={startTime} onChange={handleStartTimeChange} disabled={saving || !canWrite || allDay} />
</label>
</div>
{!allDay &&
<div className="calendar-dialog-date-row">
<label>
<span>i18n:govoplan-calendar.end_mode.5a06de37</span>
<select value={endMode} onChange={(item) => setEndMode(item.target.value as CalendarEventEndMode)} disabled={saving || !canWrite}>
<option value="end">i18n:govoplan-calendar.end_time.cd7800da</option>
<option value="duration">i18n:govoplan-calendar.duration.1370004d</option>
</select>
</label>
{endMode === "duration" &&
<label>
<span>i18n:govoplan-calendar.duration_seconds.19d42eeb</span>
<input type="number" min={1} step={60} value={durationSeconds} onChange={(item) => setDurationSeconds(item.target.value)} required disabled={saving || !canWrite} />
</label>
}
</div>
}
{(allDay || endMode === "end") &&
<div className="calendar-dialog-date-row">
<label>
<span>i18n:govoplan-calendar.end_date.89d10cd6</span>
<DateField value={endDate} min={startDate} onChange={setEndDate} required disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.end_time.cd7800da</span>
<TimeField value={endTime} min={!allDay && startDate === endDate ? startTime : undefined} onChange={setEndTime} disabled={saving || !canWrite || allDay} />
</label>
</div>
}
<details className="calendar-advanced-settings calendar-vevent-details">
<summary>i18n:govoplan-calendar.vevent.9cf5be75</summary>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.identity.7e5a975b</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.uid.d946adf5</span>
<input value={uid} onChange={(item) => setUid(item.target.value)} maxLength={255} disabled={saving || !canWrite || Boolean(event)} />
</label>
<label>
<span>i18n:govoplan-calendar.recurrence_id.e0b780ba</span>
<input value={recurrenceId} onChange={(item) => setRecurrenceId(item.target.value)} maxLength={255} disabled={saving || !canWrite || Boolean(event)} />
</label>
<label>
<span>i18n:govoplan-calendar.sequence.5c8f4e0e</span>
<input type="number" min={0} step={1} value={sequence} onChange={(item) => setSequence(item.target.value)} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.state.a7250206</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.status.bae7d5be</span>
<select value={status} onChange={(item) => setStatus(item.target.value)} disabled={saving || !canWrite}>
<option value="CONFIRMED">i18n:govoplan-calendar.confirmed.0542404a</option>
<option value="TENTATIVE">i18n:govoplan-calendar.tentative.d19f9022</option>
<option value="CANCELLED">i18n:govoplan-calendar.cancelled.5587b0af</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.transparency.7cb338a9</span>
<select value={transparency} onChange={(item) => setTransparency(item.target.value)} disabled={saving || !canWrite}>
<option value="OPAQUE">i18n:govoplan-calendar.opaque.3e1d0194</option>
<option value="TRANSPARENT">i18n:govoplan-calendar.transparent.ea4efcae</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.class.41ff354b</span>
<select value={classification} onChange={(item) => setClassification(item.target.value)} disabled={saving || !canWrite}>
<option value="PUBLIC">i18n:govoplan-calendar.public.d1785ca2</option>
<option value="PRIVATE">i18n:govoplan-calendar.private.b0b7ba46</option>
<option value="CONFIDENTIAL">i18n:govoplan-calendar.confidential.84c9cc88</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.timezone.d1f7dc89</span>
<input value={timezone} onChange={(item) => setTimezone(item.target.value)} maxLength={100} disabled={saving || !canWrite} />
</label>
<label className="calendar-dialog-wide">
<span>i18n:govoplan-calendar.categories.6ccb6007</span>
<input value={categoriesText} onChange={(item) => setCategoriesText(item.target.value)} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.participants.cd56e083</h3>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.organizer_json.3add6f9f</span>
<textarea value={organizerJson} onChange={(item) => setOrganizerJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.attendees_json.aeb487bb</span>
<textarea value={attendeesJson} onChange={(item) => setAttendeesJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.recurrence.f7ad40f5</h3>
<label>
<span>i18n:govoplan-calendar.rrule.c7b2f8a3</span>
<input value={rruleText} onChange={(item) => setRruleText(item.target.value)} disabled={saving || !canWrite} />
</label>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.rdate_json.5b51fca4</span>
<textarea value={rdateJson} onChange={(item) => setRdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.exdate_json.7d0c538d</span>
<textarea value={exdateJson} onChange={(item) => setExdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.related.917df91e</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.reminders_json.ca25e08f</span>
<textarea value={remindersJson} onChange={(item) => setRemindersJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.attachments_json.d91abf3c</span>
<textarea value={attachmentsJson} onChange={(item) => setAttachmentsJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.related_to_json.2d4e8f59</span>
<textarea value={relatedToJson} onChange={(item) => setRelatedToJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.source.6da13add</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.source_kind.7eda9bc4</span>
<input value={sourceKind} onChange={(item) => setSourceKind(item.target.value)} maxLength={30} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.source_href.819fa147</span>
<input value={sourceHref} onChange={(item) => setSourceHref(item.target.value)} maxLength={1000} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.etag.11d00f6e</span>
<input value={etag} onChange={(item) => setEtag(item.target.value)} maxLength={255} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.raw.da433cd4</h3>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.icalendar_json.fb6cc33e</span>
<textarea value={icalendarJson} onChange={(item) => setICalendarJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.metadata_json.b0e4c283</span>
<textarea value={metadataJson} onChange={(item) => setMetadataJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
</label>
</div>
</section>
</details>
</form>
</Dialog>);
}
function jsonTextareaValue(value: unknown): string {
return JSON.stringify(value, null, 2);
}
function parseJsonObject(text: string, label: string): Record<string, unknown> {
const value = parseJsonText(text, label, {});
if (!isPlainObject(value)) throw new Error(`${label} must be a JSON object.`);
return value;
}
function parseJsonObjectOrNull(text: string, label: string): Record<string, unknown> | null {
if (!text.trim()) return null;
const value = parseJsonText(text, label, null);
if (value === null) return null;
if (!isPlainObject(value)) throw new Error(`${label} must be a JSON object or null.`);
return value;
}
function parseJsonArray(text: string, label: string): Record<string, unknown>[] {
const value = parseJsonText(text, label, []);
if (!Array.isArray(value)) throw new Error(`${label} must be a JSON array.`);
return value.map((item, index) => {
if (!isPlainObject(item)) throw new Error(`${label} item ${index + 1} must be a JSON object.`);
return item;
});
}
function parseJsonText(text: string, label: string, fallback: unknown): unknown {
const trimmed = text.trim();
if (!trimmed) return fallback;
try {
return JSON.parse(trimmed) as unknown;
} catch (err) {
throw new Error(`${label} contains invalid JSON: ${errorText(err)}`);
}
}
function parseRRuleInput(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
if (trimmed.startsWith("{")) return parseJsonObject(trimmed, "RRULE");
const result: Record<string, unknown> = {};
for (const item of trimmed.split(";")) {
const [rawKey, ...rawValueParts] = item.split("=");
const key = rawKey.trim().toUpperCase();
const rawValue = rawValueParts.join("=").trim();
if (!key || !rawValue) continue;
const values = rawValue.split(",").map((value) => value.trim()).filter(Boolean);
result[key] = values.length > 1 ? values : values[0] ?? rawValue;
}
return Object.keys(result).length ? result : null;
}
function serializeRRuleText(value: Record<string, unknown>): string {
return Object.entries(value).
map(([key, item]) => `${key.toUpperCase()}=${Array.isArray(item) ? item.join(",") : String(item)}`).
join(";");
}
function commaSeparatedValues(text: string): string[] {
return text.split(",").map((item) => item.trim()).filter(Boolean);
}
function withDurationMode(value: Record<string, unknown>, usesDuration: boolean): Record<string, unknown> {
const next = { ...value };
const standard = isPlainObject(next.standard) ? { ...next.standard } : {};
if (usesDuration) {
standard.uses_duration = true;
} else {
delete standard.uses_duration;
}
if (Object.keys(standard).length > 0) {
next.standard = standard;
} else {
delete next.standard;
}
return next;
}
function eventUsesDuration(event: CalendarEvent): boolean {
const standard = isPlainObject(event.icalendar?.standard) ? event.icalendar.standard : null;
return standard?.uses_duration === true;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,666 @@
import {
useEffect,
useMemo,
useRef,
type DragEvent as ReactDragEvent,
} from "react";
import { i18nMessage } from "@govoplan/core-webui";
import type { CalendarEvent } from "../../api/calendar";
import {
HOUR_ROW_HEIGHT,
INITIAL_SCROLL_HOUR,
calendarEventColorStyle,
chunk,
continuousVirtualWindow,
dayKey,
dayMonthLabel,
dropSlotMinuteOfDay,
eventTimeLabel,
isWeekend,
layoutTimedEventsForDay,
minuteOfDayLabel,
monthYearLabel,
sameDay,
sameMonth,
timedEventStyle,
timedOverflowStyle,
timeGridStyle,
weekdayLong,
type CalendarDropTarget,
type CalendarMode,
type CalendarResizeEdge,
type CalendarTimeDropTarget,
type CalendarViewPreferences,
type ContinuousViewport,
} from "./calendarViewModel";
type CalendarViewCallbacks = {
onEventSelect: (event: CalendarEvent) => void;
onEventHover: (eventId: string) => void;
onEventDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
) => void;
onEventDragEnd: () => void;
onEventDragOverDay: (
dragEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
onDropTargetLeave: (
dragEvent: ReactDragEvent<HTMLElement>,
) => void;
onEventDropOnDay: (
dropEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
};
type CalendarWeekRowsProps = CalendarViewCallbacks & {
days: Date[];
eventsByDay: Map<string, CalendarEvent[]>;
calendarColorById: Map<string, string>;
focusDate: Date;
variant: "month" | "continuous";
preferences: CalendarViewPreferences;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarDropTarget;
viewport?: ContinuousViewport;
};
export function CalendarWeekRows({
days,
eventsByDay,
calendarColorById,
focusDate,
variant,
preferences,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
viewport,
onEventSelect,
onEventHover,
onEventDragStart,
onEventDragEnd,
onEventDragOverDay,
onDropTargetLeave,
onEventDropOnDay,
}: CalendarWeekRowsProps) {
const weeks = chunk(days, 7);
const virtualWindow =
variant === "continuous" && preferences.continuousVirtualization
? continuousVirtualWindow(
weeks.length,
viewport ?? { scrollTop: 0, height: 0 },
preferences.continuousOverscanWeeks,
)
: {
start: 0,
end: weeks.length,
topSpacerHeight: 0,
bottomSpacerHeight: 0,
};
const visibleWeeks = weeks.slice(
virtualWindow.start,
virtualWindow.end,
);
const eventLimit = variant === "month" ? 5 : 3;
return (
<div className={`calendar-week-rows is-${variant}`}>
<div className="calendar-weekday-header">
{[
"i18n:govoplan-calendar.mon.24b2a099",
"i18n:govoplan-calendar.tue.529541bb",
"i18n:govoplan-calendar.wed.23408b19",
"i18n:govoplan-calendar.thu.3593ccd9",
"i18n:govoplan-calendar.fri.bbd6e32e",
"i18n:govoplan-calendar.sat.6b782d41",
"i18n:govoplan-calendar.sun.48c98cab",
].map((label) => (
<span key={label}>{label}</span>
))}
</div>
{virtualWindow.topSpacerHeight > 0 && (
<div
className="calendar-week-spacer"
style={{ height: virtualWindow.topSpacerHeight }}
aria-hidden="true"
/>
)}
{visibleWeeks.map((week) => (
<div className="calendar-week-row" key={dayKey(week[0])}>
{week.map((day) => {
const key = dayKey(day);
const dayEvents = eventsByDay.get(key) ?? [];
const outsideFocusMonth =
variant === "month" && !sameMonth(day, focusDate);
return (
<section
key={key}
className={[
"calendar-day-cell",
outsideFocusMonth ? "is-muted" : "",
sameDay(day, new Date()) ? "is-today" : "",
dropTarget?.kind === "day" &&
dropTarget.key === key
? "is-drop-target"
: "",
variant === "continuous" &&
preferences.alternateContinuousMonths
? day.getMonth() % 2 === 0
? "is-even-month"
: "is-odd-month"
: "",
]
.filter(Boolean)
.join(" ")}
onDragOver={
canWrite
? (event) => onEventDragOverDay(event, day)
: undefined
}
onDragLeave={
canWrite ? onDropTargetLeave : undefined
}
onDrop={
canWrite
? (event) => onEventDropOnDay(event, day)
: undefined
}
>
<header>
<span className="calendar-day-number">
{day.getDate()}
</span>
{day.getDate() === 1 && (
<small>{monthYearLabel(day)}</small>
)}
</header>
<div className="calendar-event-stack">
{dayEvents.slice(0, eventLimit).map((event) => (
<CalendarEventChip
key={event.id}
event={event}
color={calendarColorById.get(event.calendar_id)}
canDrag={canWrite}
dragging={draggingEventId === event.id}
linkedHover={hoveredEventId === event.id}
onSelect={onEventSelect}
onHover={onEventHover}
onDragStart={onEventDragStart}
onDragEnd={onEventDragEnd}
/>
))}
{dayEvents.length > eventLimit && (
<span className="calendar-more-events">
+{dayEvents.length - eventLimit}
</span>
)}
</div>
</section>
);
})}
</div>
))}
{virtualWindow.bottomSpacerHeight > 0 && (
<div
className="calendar-week-spacer"
style={{ height: virtualWindow.bottomSpacerHeight }}
aria-hidden="true"
/>
)}
</div>
);
}
type CalendarTimeGridProps = CalendarViewCallbacks & {
days: Date[];
eventsByDay: Map<string, CalendarEvent[]>;
calendarColorById: Map<string, string>;
mode: CalendarMode;
preferences: CalendarViewPreferences;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarDropTarget;
onEventResizeDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
edge: CalendarResizeEdge,
) => void;
onEventDragOverTime: (
dragEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
onEventDropOnTime: (
dropEvent: ReactDragEvent<HTMLElement>,
day: Date,
minuteOfDay: number,
) => void;
};
export function CalendarTimeGrid({
days,
eventsByDay,
calendarColorById,
mode,
preferences,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
onEventSelect,
onEventHover,
onEventDragStart,
onEventResizeDragStart,
onEventDragEnd,
onEventDragOverDay,
onEventDragOverTime,
onDropTargetLeave,
onEventDropOnDay,
onEventDropOnTime,
}: CalendarTimeGridProps) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const hours = Array.from({ length: 24 }, (_item, index) => index);
const columns = `72px repeat(${days.length}, minmax(132px, 1fr))`;
const dimWeekends = preferences.dimWeekends && mode === "week";
const gridStyle = timeGridStyle(columns, preferences);
const allDayEventsByDay = days.map((day) => ({
key: dayKey(day),
date: day,
events: (eventsByDay.get(dayKey(day)) ?? []).filter(
(event) => event.all_day,
),
}));
const hasAllDayEvents = allDayEventsByDay.some(
(day) => day.events.length > 0,
);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop =
INITIAL_SCROLL_HOUR * HOUR_ROW_HEIGHT;
}
}, [days.length, days[0]?.getTime()]);
return (
<div className="calendar-time-view">
<div
className="calendar-time-header"
style={{ gridTemplateColumns: columns }}
>
<div className="calendar-time-corner" />
{days.map((day) => (
<header
key={dayKey(day)}
className={[
sameDay(day, new Date()) ? "is-today" : "",
dimWeekends && isWeekend(day) ? "is-weekend" : "",
]
.filter(Boolean)
.join(" ")}
>
<strong>{weekdayLong(day)}</strong>
<span>{dayMonthLabel(day)}</span>
</header>
))}
</div>
{hasAllDayEvents && (
<div
className="calendar-all-day-strip"
style={{ gridTemplateColumns: columns }}
>
<div className="calendar-all-day-label">
i18n:govoplan-calendar.all_day.11457433
</div>
{allDayEventsByDay.map((day) => (
<div
key={day.key}
className={[
"calendar-all-day-cell",
dimWeekends && isWeekend(day.date)
? "is-weekend"
: "",
dropTarget?.kind === "day" &&
dropTarget.key === day.key
? "is-drop-target"
: "",
]
.filter(Boolean)
.join(" ")}
onDragOver={
canWrite
? (event) => onEventDragOverDay(event, day.date)
: undefined
}
onDragLeave={
canWrite ? onDropTargetLeave : undefined
}
onDrop={
canWrite
? (event) => onEventDropOnDay(event, day.date)
: undefined
}
>
{day.events.map((event) => (
<CalendarEventChip
key={event.id}
event={event}
color={calendarColorById.get(event.calendar_id)}
compact
canDrag={canWrite}
dragging={draggingEventId === event.id}
linkedHover={hoveredEventId === event.id}
onSelect={onEventSelect}
onHover={onEventHover}
onDragStart={onEventDragStart}
onDragEnd={onEventDragEnd}
/>
))}
</div>
))}
</div>
)}
<div className="calendar-time-scroll" ref={scrollRef}>
<div className="calendar-time-grid" style={gridStyle}>
<div className="calendar-hour-label-column">
{hours.map((hour) => (
<div className="calendar-hour-label" key={hour}>
{String(hour).padStart(2, "0")}:00
</div>
))}
</div>
{days.map((day) => (
<TimedDayColumn
key={dayKey(day)}
day={day}
events={eventsByDay.get(dayKey(day)) ?? []}
calendarColorById={calendarColorById}
dimWeekend={dimWeekends && isWeekend(day)}
canWrite={canWrite}
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={
dropTarget?.kind === "time" &&
dropTarget.key === dayKey(day)
? dropTarget
: null
}
onEventSelect={onEventSelect}
onEventHover={onEventHover}
onEventDragStart={onEventDragStart}
onEventResizeDragStart={onEventResizeDragStart}
onEventDragEnd={onEventDragEnd}
onEventDragOverTime={onEventDragOverTime}
onDropTargetLeave={onDropTargetLeave}
onEventDropOnTime={onEventDropOnTime}
/>
))}
</div>
</div>
</div>
);
}
type TimedDayColumnProps = {
day: Date;
events: CalendarEvent[];
calendarColorById: Map<string, string>;
dimWeekend: boolean;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarTimeDropTarget | null;
onEventSelect: (event: CalendarEvent) => void;
onEventHover: (eventId: string) => void;
onEventDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
) => void;
onEventResizeDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
edge: CalendarResizeEdge,
) => void;
onEventDragEnd: () => void;
onEventDragOverTime: (
dragEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
onDropTargetLeave: (
dragEvent: ReactDragEvent<HTMLElement>,
) => void;
onEventDropOnTime: (
dropEvent: ReactDragEvent<HTMLElement>,
day: Date,
minuteOfDay: number,
) => void;
};
function TimedDayColumn({
day,
events,
calendarColorById,
dimWeekend,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
onEventSelect,
onEventHover,
onEventDragStart,
onEventResizeDragStart,
onEventDragEnd,
onEventDragOverTime,
onDropTargetLeave,
onEventDropOnTime,
}: TimedDayColumnProps) {
const layout = useMemo(
() => layoutTimedEventsForDay(events, day),
[day, events],
);
return (
<div
className={[
"calendar-day-time-column",
dimWeekend ? "is-weekend" : "",
dropTarget ? "is-drop-target" : "",
]
.filter(Boolean)
.join(" ")}
onDragOver={
canWrite
? (event) => onEventDragOverTime(event, day)
: undefined
}
onDragLeave={canWrite ? onDropTargetLeave : undefined}
onDrop={
canWrite
? (event) =>
onEventDropOnTime(event, day, dropSlotMinuteOfDay(event))
: undefined
}
>
{dropTarget && (
<div
className="calendar-time-drop-marker"
style={{
top: `${
(dropTarget.minuteOfDay / 60) * HOUR_ROW_HEIGHT
}px`,
}}
aria-hidden="true"
>
<span>{minuteOfDayLabel(dropTarget.minuteOfDay)}</span>
</div>
)}
{layout.items.map((item) => (
<div
key={item.event.id}
className={[
"calendar-timed-event",
draggingEventId === item.event.id ? "is-dragging" : "",
hoveredEventId === item.event.id
? "is-linked-hover"
: "",
]
.filter(Boolean)
.join(" ")}
style={timedEventStyle(
item,
calendarColorById.get(item.event.calendar_id),
)}
draggable={canWrite}
onMouseEnter={() => onEventHover(item.event.id)}
onMouseLeave={() => onEventHover("")}
onDragStart={
canWrite
? (event) => onEventDragStart(event, item.event)
: undefined
}
onDragEnd={canWrite ? onEventDragEnd : undefined}
title={i18nMessage(
"i18n:govoplan-calendar.edit_value.fad75899",
{ value0: item.event.summary },
)}
>
{canWrite && (
<span
className="calendar-event-resize-handle is-start"
draggable
title="i18n:govoplan-calendar.change_start_time.06eea3d3"
onDragStart={(event) =>
onEventResizeDragStart(event, item.event, "start")
}
onDragEnd={onEventDragEnd}
/>
)}
<button
type="button"
className="calendar-timed-event-content"
onClick={() => onEventSelect(item.event)}
onFocus={() => onEventHover(item.event.id)}
onBlur={() => onEventHover("")}
title={i18nMessage(
"i18n:govoplan-calendar.edit_value.fad75899",
{ value0: item.event.summary },
)}
>
<EventInlineLabel event={item.event} />
</button>
{canWrite && (
<span
className="calendar-event-resize-handle is-end"
draggable
title="i18n:govoplan-calendar.change_end_time.db66ef4b"
onDragStart={(event) =>
onEventResizeDragStart(event, item.event, "end")
}
onDragEnd={onEventDragEnd}
/>
)}
</div>
))}
{layout.overflows.map((overflow) => (
<button
key={overflow.key}
type="button"
className="calendar-timed-overflow"
style={timedOverflowStyle(overflow)}
title={overflow.events
.map((event) => event.summary)
.join(", ")}
onClick={() => onEventSelect(overflow.events[0])}
>
+{overflow.events.length}
</button>
))}
</div>
);
}
type CalendarEventChipProps = {
event: CalendarEvent;
color?: string | null;
compact?: boolean;
canDrag?: boolean;
dragging?: boolean;
linkedHover?: boolean;
onSelect: (event: CalendarEvent) => void;
onHover?: (eventId: string) => void;
onDragStart?: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
) => void;
onDragEnd?: () => void;
};
function CalendarEventChip({
event,
color,
compact = false,
canDrag = false,
dragging = false,
linkedHover = false,
onSelect,
onHover,
onDragStart,
onDragEnd,
}: CalendarEventChipProps) {
return (
<button
type="button"
className={[
compact
? "calendar-event-chip is-compact"
: "calendar-event-chip",
dragging ? "is-dragging" : "",
linkedHover ? "is-linked-hover" : "",
]
.filter(Boolean)
.join(" ")}
style={calendarEventColorStyle(color)}
draggable={canDrag}
onClick={() => onSelect(event)}
onMouseEnter={onHover ? () => onHover(event.id) : undefined}
onMouseLeave={onHover ? () => onHover("") : undefined}
onFocus={onHover ? () => onHover(event.id) : undefined}
onBlur={onHover ? () => onHover("") : undefined}
onDragStart={
canDrag && onDragStart
? (dragEvent) => onDragStart(dragEvent, event)
: undefined
}
onDragEnd={canDrag ? onDragEnd : undefined}
title={i18nMessage(
"i18n:govoplan-calendar.edit_value.fad75899",
{ value0: event.summary },
)}
>
<EventInlineLabel event={event} />
</button>
);
}
export function EventInlineLabel({
event,
}: {
event: CalendarEvent;
}) {
return (
<span className="calendar-event-line">
<span className="calendar-event-time">
{eventTimeLabel(event)}
</span>
<span className="calendar-event-separator">-</span>
<strong>{event.summary}</strong>
</span>
);
}

View File

@@ -0,0 +1,110 @@
import { useCallback } from "react";
import { CalendarDays, MapPin } from "lucide-react";
import { Link } from "react-router-dom";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listCalendarEvents,
type CalendarEvent
} from "../../api/calendar";
export default function UpcomingEventsWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const daysAhead = numberSetting(configuration.daysAhead, 14, 1, 90);
const showLocation = configuration.showLocation !== false;
const load = useCallback(async () => {
const start = new Date();
const end = new Date(start);
end.setDate(end.getDate() + daysAhead);
const response = await listCalendarEvents(settings, {
start_at: start.toISOString(),
end_at: end.toISOString()
});
return [...response.events]
.filter((event) => event.status !== "cancelled")
.sort(
(left, right) =>
new Date(left.start_at).getTime() - new Date(right.start_at).getTime()
)
.slice(0, maxItems);
}, [daysAhead, maxItems, settings]);
const { data: events, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label="Loading upcoming calendar events">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText={`No events in the next ${daysAhead} days.`}
items={(events ?? []).map((event) => ({
id: event.id,
title: event.summary,
detail:
showLocation && event.location ? (
<>
<MapPin size={12} aria-hidden="true" /> {event.location}
</>
) : undefined,
meta: eventTimeLabel(event),
leading: <CalendarDays size={17} aria-hidden="true" />,
to: "/calendar"
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/calendar">
Open calendar
</Link>
</div>
</LoadingFrame>
);
}
function eventTimeLabel(event: CalendarEvent): string {
const start = new Date(event.start_at);
if (event.all_day) {
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
day: "2-digit",
month: "short"
}).format(start);
}
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(start);
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}

View File

@@ -0,0 +1,928 @@
import type {
CSSProperties,
DragEvent as ReactDragEvent,
} from "react";
import type {
CalendarEvent,
CalendarEventDeltaResponse,
} from "../../api/calendar";
export type CalendarMode =
| "continuous"
| "month"
| "week"
| "workweek"
| "day";
export type Range = { start: Date; end: Date };
export type AgendaGroup = {
key: string;
date: Date;
events: CalendarEvent[];
};
export type ContinuousViewport = { scrollTop: number; height: number };
export type CalendarDayDropTarget = { kind: "day"; key: string };
export type CalendarTimeDropTarget = {
kind: "time";
key: string;
minuteOfDay: number;
};
export type CalendarDropTarget =
| CalendarDayDropTarget
| CalendarTimeDropTarget
| null;
export type CalendarDragAction =
| { kind: "move"; event: CalendarEvent }
| { kind: "resize-start"; event: CalendarEvent }
| { kind: "resize-end"; event: CalendarEvent };
export type CalendarResizeEdge = "start" | "end";
export type CalendarViewPreferences = {
dimWeekends: boolean;
dimOffHours: boolean;
workdayStartHour: number;
workdayEndHour: number;
continuousVirtualization: boolean;
continuousOverscanWeeks: number;
alternateContinuousMonths: boolean;
};
export type TimedEventLayoutItem = TimedEventSegment & {
column: number;
columns: number;
};
export type TimedOverflowLayoutItem = {
key: string;
top: number;
column: number;
columns: number;
events: CalendarEvent[];
};
type TimedEventSegment = {
event: CalendarEvent;
start: Date;
end: Date;
top: number;
height: number;
};
export const HOUR_ROW_HEIGHT = 64;
export const INITIAL_SCROLL_HOUR = 7;
export const CONTINUOUS_WEEK_ROW_HEIGHT = 92;
export const DEFAULT_CALENDAR_COLOR = "#5aa99b";
const MAX_TIMED_EVENT_COLUMNS = 3;
const CALENDAR_MODE_STORAGE_KEY = "govoplan.calendar.mode";
const CALENDAR_VIEW_PREFERENCES_STORAGE_KEY =
"govoplan.calendar.viewPreferences";
const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
dimWeekends: true,
dimOffHours: true,
workdayStartHour: 6,
workdayEndHour: 20,
continuousVirtualization: true,
continuousOverscanWeeks: 6,
alternateContinuousMonths: true,
};
export function rangeForMode(
mode: CalendarMode,
focusDate: Date,
continuousWeeks: { before: number; after: number },
): Range {
if (mode === "month") {
const start = startOfWeek(startOfMonth(focusDate));
return { start, end: addDays(start, 42) };
}
if (mode === "day") {
return {
start: startOfDay(focusDate),
end: addDays(startOfDay(focusDate), 1),
};
}
if (mode === "continuous") {
const start = addDays(
startOfWeek(focusDate),
-continuousWeeks.before * 7,
);
const end = addDays(
startOfWeek(focusDate),
continuousWeeks.after * 7,
);
return { start, end };
}
const start = startOfWeek(focusDate);
return { start, end: addDays(start, mode === "workweek" ? 5 : 7) };
}
export function daysForMode(
mode: CalendarMode,
focusDate: Date,
continuousWeeks: { before: number; after: number },
): Date[] {
const range = rangeForMode(mode, focusDate, continuousWeeks);
return daysBetween(range.start, range.end);
}
export function continuousEventWindow(
days: Date[],
viewport: ContinuousViewport,
options: {overscanWeeks?: number;minimumWeeks?: number;stepWeeks?: number;} = {},
): Range {
if (days.length === 0) {
const now = startOfDay(new Date());
return { start: now, end: addDays(now, 7) };
}
const totalWeeks = Math.max(1, Math.ceil(days.length / 7));
const overscanWeeks = Math.max(0, Math.floor(options.overscanWeeks ?? 2));
const minimumWeeks = Math.max(1, Math.floor(options.minimumWeeks ?? 10));
const stepWeeks = Math.max(1, Math.floor(options.stepWeeks ?? 4));
const firstVisibleWeek = Math.max(
0,
Math.min(totalWeeks - 1, Math.floor(viewport.scrollTop / CONTINUOUS_WEEK_ROW_HEIGHT)),
);
const visibleWeeks = Math.max(
1,
Math.ceil(Math.max(viewport.height, CONTINUOUS_WEEK_ROW_HEIGHT) / CONTINUOUS_WEEK_ROW_HEIGHT),
);
const desiredWeeks = Math.min(
totalWeeks,
Math.max(minimumWeeks, visibleWeeks + overscanWeeks * 2),
);
let startWeek = Math.max(
0,
Math.floor(Math.max(0, firstVisibleWeek - overscanWeeks) / stepWeeks) * stepWeeks,
);
if (startWeek + desiredWeeks > totalWeeks) {
startWeek = Math.max(0, totalWeeks - desiredWeeks);
}
const endWeek = Math.min(totalWeeks, startWeek + desiredWeeks);
return {
start: days[startWeek * 7],
end: addDays(days[Math.min(days.length - 1, endWeek * 7 - 1)], 1),
};
}
export function groupEventsByDay(
events: CalendarEvent[],
): Map<string, CalendarEvent[]> {
const grouped = new Map<string, CalendarEvent[]>();
for (const event of events) {
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : start;
let cursor = startOfDay(start);
let last =
event.all_day && event.end_at
? addDays(startOfDay(end), -1)
: startOfDay(end);
if (last < cursor) last = cursor;
while (cursor <= last) {
const key = dayKey(cursor);
grouped.set(key, [...(grouped.get(key) ?? []), event]);
cursor = addDays(cursor, 1);
}
}
for (const [key, items] of grouped.entries()) {
grouped.set(
key,
items.sort(
(left, right) =>
left.start_at.localeCompare(right.start_at) ||
left.summary.localeCompare(right.summary),
),
);
}
return grouped;
}
export function agendaGroupsForDays(
days: Date[],
eventsByDay: Map<string, CalendarEvent[]>,
): AgendaGroup[] {
const groups: AgendaGroup[] = [];
for (const day of days) {
const key = dayKey(day);
const events = (eventsByDay.get(key) ?? [])
.slice()
.sort(compareAgendaEvents);
if (events.length > 0) groups.push({ key, date: day, events });
}
return groups;
}
export function eventTimeLabel(event: CalendarEvent): string {
if (event.all_day) return "i18n:govoplan-calendar.all_day.11457433";
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : null;
return end ? `${timeLabel(start)}-${timeLabel(end)}` : timeLabel(start);
}
export function dateTimeLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(value);
}
export function headingForMode(
mode: CalendarMode,
focusDate: Date,
range: Range,
): string {
if (mode === "day") return dayMonthYearLabel(focusDate);
if (mode === "month") return monthYearLabel(focusDate);
return `${dayMonthYearLabel(range.start)} - ${dayMonthYearLabel(
addDays(range.end, -1),
)}`;
}
export function chunk<T>(items: T[], size: number): T[][] {
const rows: T[][] = [];
for (let index = 0; index < items.length; index += size) {
rows.push(items.slice(index, index + size));
}
return rows;
}
export function startOfDay(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
export function startOfWeek(value: Date): Date {
const day = value.getDay() || 7;
return addDays(startOfDay(value), 1 - day);
}
export function startOfMonth(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), 1);
}
export function addDays(value: Date, days: number): Date {
const next = new Date(value);
next.setDate(next.getDate() + days);
return next;
}
export function addMonths(value: Date, months: number): Date {
const next = new Date(value);
next.setMonth(next.getMonth() + months);
return next;
}
export function addHours(value: Date, hours: number): Date {
const next = new Date(value);
next.setHours(next.getHours() + hours);
return next;
}
export function addMinutes(value: Date, minutes: number): Date {
const next = new Date(value);
next.setMinutes(next.getMinutes() + minutes);
return next;
}
export function daysBetween(start: Date, end: Date): Date[] {
const days: Date[] = [];
for (
let cursor = startOfDay(start);
cursor < end;
cursor = addDays(cursor, 1)
) {
days.push(cursor);
}
return days;
}
export function dayKey(value: Date): string {
return toInputDate(value);
}
export function sameDay(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
export function sameMonth(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth()
);
}
export function toInputDate(value: Date): string {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(
2,
"0",
)}-${String(value.getDate()).padStart(2, "0")}`;
}
export function toInputTime(value: Date): string {
return `${String(value.getHours()).padStart(2, "0")}:${String(
value.getMinutes(),
).padStart(2, "0")}`;
}
export function localDateTime(date: string, time: string): Date {
return new Date(`${date}T${time || "00:00"}:00`);
}
export function loadCalendarViewPreferences(): CalendarViewPreferences {
if (typeof window === "undefined") return DEFAULT_CALENDAR_VIEW_PREFERENCES;
try {
const raw = window.localStorage.getItem(
CALENDAR_VIEW_PREFERENCES_STORAGE_KEY,
);
if (!raw) return DEFAULT_CALENDAR_VIEW_PREFERENCES;
const parsed = JSON.parse(raw) as Partial<CalendarViewPreferences>;
let workdayStartHour = clampNumber(
parsed.workdayStartHour,
0,
23,
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour,
);
let workdayEndHour = clampNumber(
parsed.workdayEndHour,
1,
24,
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour,
);
if (workdayEndHour <= workdayStartHour) {
workdayStartHour =
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour;
workdayEndHour = DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour;
}
return {
dimWeekends:
typeof parsed.dimWeekends === "boolean"
? parsed.dimWeekends
: DEFAULT_CALENDAR_VIEW_PREFERENCES.dimWeekends,
dimOffHours:
typeof parsed.dimOffHours === "boolean"
? parsed.dimOffHours
: DEFAULT_CALENDAR_VIEW_PREFERENCES.dimOffHours,
workdayStartHour,
workdayEndHour,
continuousVirtualization:
typeof parsed.continuousVirtualization === "boolean"
? parsed.continuousVirtualization
: DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousVirtualization,
continuousOverscanWeeks: clampNumber(
parsed.continuousOverscanWeeks,
2,
20,
DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousOverscanWeeks,
),
alternateContinuousMonths:
typeof parsed.alternateContinuousMonths === "boolean"
? parsed.alternateContinuousMonths
: DEFAULT_CALENDAR_VIEW_PREFERENCES.alternateContinuousMonths,
};
} catch {
return DEFAULT_CALENDAR_VIEW_PREFERENCES;
}
}
export function loadCalendarMode(): CalendarMode {
if (typeof window === "undefined") return "continuous";
try {
const storedMode = window.localStorage.getItem(
CALENDAR_MODE_STORAGE_KEY,
);
return isCalendarMode(storedMode) ? storedMode : "continuous";
} catch {
return "continuous";
}
}
export function saveCalendarMode(mode: CalendarMode): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(CALENDAR_MODE_STORAGE_KEY, mode);
} catch {
// Storage can be unavailable in locked-down browsers.
}
}
export function normalizeHexColor(
value: string | null | undefined,
): string | null {
if (!value) return null;
const trimmed = value.trim();
if (/^#[0-9a-fA-F]{8}$/.test(trimmed)) {
return trimmed.slice(0, 7).toLowerCase();
}
return /^#[0-9a-fA-F]{6}$/.test(trimmed)
? trimmed.toLowerCase()
: null;
}
export function calendarEventColorStyle(
color: string | null | undefined,
): CSSProperties {
const normalizedColor =
normalizeHexColor(color) || DEFAULT_CALENDAR_COLOR;
return {
"--calendar-event-color": normalizedColor,
"--calendar-event-bg": mixHexWithWhite(normalizedColor, 0.88),
"--calendar-event-border": mixHexWithWhite(normalizedColor, 0.58),
} as CSSProperties;
}
export function continuousVirtualWindow(
weekCount: number,
viewport: ContinuousViewport,
overscanWeeks: number,
): {
start: number;
end: number;
topSpacerHeight: number;
bottomSpacerHeight: number;
} {
if (weekCount === 0) {
return {
start: 0,
end: 0,
topSpacerHeight: 0,
bottomSpacerHeight: 0,
};
}
const visibleStart = Math.floor(
viewport.scrollTop / CONTINUOUS_WEEK_ROW_HEIGHT,
);
const visibleCount = Math.max(
1,
Math.ceil(
(viewport.height || CONTINUOUS_WEEK_ROW_HEIGHT * 8) /
CONTINUOUS_WEEK_ROW_HEIGHT,
),
);
const start = Math.max(0, visibleStart - overscanWeeks);
const end = Math.min(
weekCount,
visibleStart + visibleCount + overscanWeeks,
);
return {
start,
end,
topSpacerHeight: start * CONTINUOUS_WEEK_ROW_HEIGHT,
bottomSpacerHeight: Math.max(
0,
(weekCount - end) * CONTINUOUS_WEEK_ROW_HEIGHT,
),
};
}
export function timeGridStyle(
columns: string,
preferences: CalendarViewPreferences,
): CSSProperties {
return {
gridTemplateColumns: columns,
"--calendar-work-start-y": `${
preferences.workdayStartHour * HOUR_ROW_HEIGHT
}px`,
"--calendar-work-end-y": `${
preferences.workdayEndHour * HOUR_ROW_HEIGHT
}px`,
"--calendar-off-hours-opacity": preferences.dimOffHours ? "1" : "0",
} as CSSProperties;
}
export function moveEventToDay(
event: CalendarEvent,
day: Date,
): { startAt: Date; endAt: Date | null; allDay: boolean } {
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : null;
if (event.all_day) {
const durationDays = Math.max(
1,
Math.round(
daysBetweenCount(
startOfDay(start),
end ? startOfDay(end) : addDays(startOfDay(start), 1),
),
),
);
const startAt = startOfDay(day);
return {
startAt,
endAt: addDays(startAt, durationDays),
allDay: true,
};
}
const startAt = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
start.getHours(),
start.getMinutes(),
);
const durationMs = eventDurationMs(event, 60 * 60 * 1000);
return {
startAt,
endAt: new Date(startAt.getTime() + durationMs),
allDay: false,
};
}
export function moveEventToTime(
event: CalendarEvent,
day: Date,
minuteOfDay: number,
): { startAt: Date; endAt: Date | null; allDay: boolean } {
const snappedMinute = Math.min(
23 * 60 + 45,
Math.max(0, snapMinute(minuteOfDay)),
);
const startAt = dateAtMinuteOfDay(day, snappedMinute);
const durationMs = event.all_day
? 60 * 60 * 1000
: eventDurationMs(event, 60 * 60 * 1000);
return {
startAt,
endAt: new Date(startAt.getTime() + durationMs),
allDay: false,
};
}
export function resizeEventToTime(
event: CalendarEvent,
edge: CalendarResizeEdge,
day: Date,
minuteOfDay: number,
): { startAt: Date; endAt: Date | null; allDay: boolean } {
const target = dateAtMinuteOfDay(
day,
Math.min(23 * 60 + 45, Math.max(0, snapMinute(minuteOfDay))),
);
const currentStart = new Date(event.start_at);
const fallbackEnd = addMinutes(currentStart, 30);
const currentEnd =
event.end_at && new Date(event.end_at) > currentStart
? new Date(event.end_at)
: fallbackEnd;
const minimumDurationMs = 15 * 60_000;
if (edge === "start") {
const latestStart = new Date(
currentEnd.getTime() - minimumDurationMs,
);
return {
startAt: target < latestStart ? target : latestStart,
endAt: currentEnd,
allDay: false,
};
}
const earliestEnd = new Date(
currentStart.getTime() + minimumDurationMs,
);
return {
startAt: currentStart,
endAt: target > earliestEnd ? target : earliestEnd,
allDay: false,
};
}
export function dropMinuteOfDay(
event: ReactDragEvent<HTMLElement>,
): number {
const rect = event.currentTarget.getBoundingClientRect();
const offset = Math.min(
Math.max(event.clientY - rect.top, 0),
HOUR_ROW_HEIGHT * 24,
);
return Math.floor((offset / HOUR_ROW_HEIGHT) * 60);
}
export function dropSlotMinuteOfDay(
event: ReactDragEvent<HTMLElement>,
): number {
return Math.min(
23 * 60 + 45,
Math.max(0, snapMinute(dropMinuteOfDay(event))),
);
}
export function minuteOfDayLabel(value: number): string {
const minute = Math.min(23 * 60 + 45, Math.max(0, value));
return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(
minute % 60,
).padStart(2, "0")}`;
}
export function isWeekend(value: Date): boolean {
const day = value.getDay();
return day === 0 || day === 6;
}
export function daysBetweenCount(start: Date, end: Date): number {
return Math.round(
(startOfDay(end).getTime() - startOfDay(start).getTime()) / 86_400_000,
);
}
export function addMinutesToTime(
time: string,
minutes: number,
): string {
const [hourPart, minutePart] = time.split(":");
const total = Math.min(
23 * 60 + 59,
Math.max(
0,
Number(hourPart || 0) * 60 + Number(minutePart || 0) + minutes,
),
);
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(
total % 60,
).padStart(2, "0")}`;
}
export function layoutTimedEventsForDay(
events: CalendarEvent[],
day: Date,
): {
items: TimedEventLayoutItem[];
overflows: TimedOverflowLayoutItem[];
} {
const segments = timedSegmentsForDay(events, day);
const clusters = clusterTimedSegments(segments);
const items: TimedEventLayoutItem[] = [];
const overflows: TimedOverflowLayoutItem[] = [];
for (const cluster of clusters) {
const columnEnds: Date[] = [];
const assigned = cluster.map((segment) => {
let column = columnEnds.findIndex((end) => end <= segment.start);
if (column === -1) column = columnEnds.length;
columnEnds[column] = segment.end;
return { ...segment, column };
});
const totalColumns = Math.max(1, columnEnds.length);
const visibleColumns =
totalColumns > MAX_TIMED_EVENT_COLUMNS
? MAX_TIMED_EVENT_COLUMNS
: totalColumns;
const overflowColumn = MAX_TIMED_EVENT_COLUMNS - 1;
const hidden =
totalColumns > MAX_TIMED_EVENT_COLUMNS
? assigned.filter((item) => item.column >= overflowColumn)
: [];
for (const item of assigned) {
if (hidden.includes(item)) continue;
items.push({ ...item, columns: visibleColumns });
}
if (hidden.length > 0) {
overflows.push({
key: `${dayKey(day)}-${hidden[0].event.id}-overflow`,
top: Math.min(...hidden.map((item) => item.top)),
column: overflowColumn,
columns: MAX_TIMED_EVENT_COLUMNS,
events: hidden.map((item) => item.event),
});
}
}
return { items, overflows };
}
export function timedEventStyle(
item: TimedEventLayoutItem,
color?: string | null,
): CSSProperties {
return {
top: `${item.top}px`,
height: `${item.height}px`,
left: `calc(${(item.column * 100) / item.columns}% + 4px)`,
width: `calc(${100 / item.columns}% - 8px)`,
...calendarEventColorStyle(color),
};
}
export function timedOverflowStyle(
item: TimedOverflowLayoutItem,
): CSSProperties {
return {
top: `${item.top}px`,
left: `calc(${(item.column * 100) / item.columns}% + 4px)`,
width: `calc(${100 / item.columns}% - 8px)`,
};
}
export function monthYearLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
month: "long",
year: "numeric",
}).format(value);
}
export function dayMonthLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
}).format(value);
}
export function dayMonthYearLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
year: "numeric",
}).format(value);
}
export function agendaDateLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
}).format(value);
}
export function weekdayLong(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
}).format(value);
}
export function calendarDraftKey(value: unknown): string {
return JSON.stringify(value);
}
export function mergeCalendarEventDelta(
current: CalendarEvent[],
response: CalendarEventDeltaResponse,
): CalendarEvent[] {
if (response.full) {
return response.events.slice().sort(compareCalendarEvents);
}
const rows = new Map(current.map((event) => [event.id, event]));
for (const deleted of response.deleted) {
if (
!deleted.resource_type ||
deleted.resource_type === "calendar_event"
) {
rows.delete(deleted.id);
}
}
for (const event of response.events) rows.set(event.id, event);
return Array.from(rows.values()).sort(compareCalendarEvents);
}
export function errorText(error: unknown): string {
if (error instanceof Error) return error.message;
return "i18n:govoplan-calendar.calendar_request_failed.ae8a54f4";
}
function isCalendarMode(value: unknown): value is CalendarMode {
return (
value === "continuous" ||
value === "month" ||
value === "week" ||
value === "workweek" ||
value === "day"
);
}
function mixHexWithWhite(hex: string, whiteRatio: number): string {
const normalized = normalizeHexColor(hex) || DEFAULT_CALENDAR_COLOR;
const ratio = Math.min(1, Math.max(0, whiteRatio));
const red = Number.parseInt(normalized.slice(1, 3), 16);
const green = Number.parseInt(normalized.slice(3, 5), 16);
const blue = Number.parseInt(normalized.slice(5, 7), 16);
return `rgb(${Math.round(red * (1 - ratio) + 255 * ratio)}, ${Math.round(
green * (1 - ratio) + 255 * ratio,
)}, ${Math.round(blue * (1 - ratio) + 255 * ratio)})`;
}
function eventDurationMs(event: CalendarEvent, fallback: number): number {
if (!event.end_at) return fallback;
const start = new Date(event.start_at);
const end = new Date(event.end_at);
return Math.max(30 * 60 * 1000, end.getTime() - start.getTime());
}
function dateAtMinuteOfDay(day: Date, minuteOfDay: number): Date {
return new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
Math.floor(minuteOfDay / 60),
minuteOfDay % 60,
);
}
function snapMinute(value: number): number {
return Math.round(value / 15) * 15;
}
function clampNumber(
value: unknown,
min: number,
max: number,
fallback: number,
): number {
if (typeof value !== "number" || Number.isNaN(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value)));
}
function timedSegmentsForDay(
events: CalendarEvent[],
day: Date,
): TimedEventSegment[] {
const dayStart = startOfDay(day);
const dayEnd = addDays(dayStart, 1);
return events
.filter((event) => !event.all_day)
.map((event): TimedEventSegment | null => {
const eventStart = new Date(event.start_at);
const rawEventEnd = event.end_at
? new Date(event.end_at)
: addMinutes(eventStart, 30);
const eventEnd =
rawEventEnd > eventStart ? rawEventEnd : addMinutes(eventStart, 30);
const start = eventStart < dayStart ? dayStart : eventStart;
const end = eventEnd > dayEnd ? dayEnd : eventEnd;
if (end <= start) return null;
const startMinutes = minutesBetween(dayStart, start);
const durationMinutes = minutesBetween(start, end);
return {
event,
start,
end,
top: Math.max(0, (startMinutes / 60) * HOUR_ROW_HEIGHT),
height: Math.max(
26,
(durationMinutes / 60) * HOUR_ROW_HEIGHT,
),
};
})
.filter(
(segment): segment is TimedEventSegment => segment !== null,
)
.sort(
(left, right) =>
left.start.getTime() - right.start.getTime() ||
right.end.getTime() - left.end.getTime(),
);
}
function clusterTimedSegments(
segments: TimedEventSegment[],
): TimedEventSegment[][] {
const clusters: TimedEventSegment[][] = [];
let current: TimedEventSegment[] = [];
let currentEnd: Date | null = null;
for (const segment of segments) {
if (!current.length || (currentEnd && segment.start < currentEnd)) {
current.push(segment);
if (!currentEnd || segment.end.getTime() > currentEnd.getTime()) {
currentEnd = segment.end;
}
continue;
}
clusters.push(current);
current = [segment];
currentEnd = segment.end;
}
if (current.length) clusters.push(current);
return clusters;
}
function minutesBetween(start: Date, end: Date): number {
return Math.max(0, (end.getTime() - start.getTime()) / 60_000);
}
function compareAgendaEvents(
left: CalendarEvent,
right: CalendarEvent,
): number {
if (left.all_day !== right.all_day) return left.all_day ? -1 : 1;
return (
left.start_at.localeCompare(right.start_at) ||
left.summary.localeCompare(right.summary)
);
}
function timeLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
}).format(value);
}
function compareCalendarEvents(
left: CalendarEvent,
right: CalendarEvent,
): number {
return (
new Date(left.start_at).getTime() -
new Date(right.start_at).getTime() ||
left.summary.localeCompare(right.summary) ||
left.id.localeCompare(right.id)
);
}

View File

@@ -39,7 +39,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED", "i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy", "i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous", "i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential", "i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL", "i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Day", "i18n:govoplan-calendar.day.987b9ced": "Day",
@@ -63,7 +62,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.end_date.89d10cd6": "End date", "i18n:govoplan-calendar.end_date.89d10cd6": "End date",
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode", "i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
"i18n:govoplan-calendar.end_time.cd7800da": "End time", "i18n:govoplan-calendar.end_time.cd7800da": "End time",
"i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as", "i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Enter a password or token for this source.",
"i18n:govoplan-calendar.etag.11d00f6e": "ETag", "i18n:govoplan-calendar.etag.11d00f6e": "ETag",
"i18n:govoplan-calendar.event": "event", "i18n:govoplan-calendar.event": "event",
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.", "i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",
@@ -228,7 +227,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED", "i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy", "i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous", "i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential", "i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL", "i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Tag", "i18n:govoplan-calendar.day.987b9ced": "Tag",
@@ -252,7 +250,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.end_date.89d10cd6": "End date", "i18n:govoplan-calendar.end_date.89d10cd6": "End date",
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode", "i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
"i18n:govoplan-calendar.end_time.cd7800da": "End time", "i18n:govoplan-calendar.end_time.cd7800da": "End time",
"i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as", "i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Geben Sie ein Passwort oder Token für diese Quelle ein.",
"i18n:govoplan-calendar.etag.11d00f6e": "ETag", "i18n:govoplan-calendar.etag.11d00f6e": "ETag",
"i18n:govoplan-calendar.event": "event", "i18n:govoplan-calendar.event": "event",
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.", "i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",

View File

@@ -1,8 +1,13 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { CalendarPickerUiCapability, PlatformWebModule } from "@govoplan/core-webui"; import type {
CalendarPickerUiCapability,
DashboardWidgetsUiCapability,
PlatformWebModule
} from "@govoplan/core-webui";
import "./styles/calendar.css"; import "./styles/calendar.css";
import { generatedTranslations } from "./i18n/generatedTranslations"; import { generatedTranslations } from "./i18n/generatedTranslations";
import CalendarPicker from "./features/calendar/CalendarPicker"; import CalendarPicker from "./features/calendar/CalendarPicker";
import UpcomingEventsWidget from "./features/calendar/UpcomingEventsWidget";
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage")); const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
@@ -13,6 +18,60 @@ const translations = {
}; };
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker }; const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
const calendarDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "calendar.upcoming",
surfaceId: "calendar.widget.upcoming",
title: "Upcoming events",
description: "The next events across visible calendars.",
moduleId: "calendar",
category: "Planning",
order: 40,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: eventRead,
refreshIntervalMs: 60_000,
defaultConfiguration: {
maxItems: 5,
daysAhead: 14,
showLocation: true
},
configurationFields: [
{
id: "maxItems",
label: "Maximum events",
kind: "number",
min: 1,
max: 12,
step: 1,
required: true
},
{
id: "daysAhead",
label: "Days ahead",
kind: "number",
min: 1,
max: 90,
step: 1,
required: true
},
{
id: "showLocation",
label: "Show locations",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(UpcomingEventsWidget, {
settings,
refreshKey,
configuration
})
}
]
};
export const calendarModule: PlatformWebModule = { export const calendarModule: PlatformWebModule = {
id: "calendar", id: "calendar",
@@ -21,11 +80,21 @@ export const calendarModule: PlatformWebModule = {
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"], optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
translations, translations,
viewSurfaces: [
{
id: "calendar.widget.upcoming",
moduleId: "calendar",
kind: "section",
label: "Upcoming events widget",
order: 40
}
],
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }], navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
routes: [ routes: [
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }], { path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
uiCapabilities: { uiCapabilities: {
"calendar.picker": calendarPicker "calendar.picker": calendarPicker,
"dashboard.widgets": calendarDashboardWidgets
} }
}; };

View File

@@ -89,21 +89,6 @@
gap: 6px; gap: 6px;
} }
.calendar-icon-button.btn {
width: 36px;
min-width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.calendar-icon-button.btn:disabled {
opacity: .42;
cursor: default;
}
.calendar-mode-switch { .calendar-mode-switch {
flex: 0 1 520px; flex: 0 1 520px;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -250,40 +235,7 @@
} }
.calendar-list-actions { .calendar-list-actions {
display: flex; width: auto;
align-items: center;
justify-content: flex-end;
gap: 2px;
min-width: 28px;
}
.calendar-row-icon-button.btn {
width: 28px;
min-width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-color: transparent;
background: transparent;
opacity: .72;
}
.calendar-row-icon-button.btn:hover,
.calendar-row-icon-button.btn:focus-visible {
border-color: var(--line-dark);
background: var(--surface);
opacity: 1;
}
.calendar-row-icon-button.btn.is-syncing,
.calendar-row-icon-button.btn.is-syncing:disabled {
border-color: var(--green);
background: var(--surface);
color: var(--green);
opacity: 1;
cursor: progress;
} }
.calendar-sync-spin { .calendar-sync-spin {

View File

@@ -0,0 +1,78 @@
import fs from "node:fs";
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const page = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarPage.tsx",
import.meta.url,
),
"utf8",
);
const views = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarViews.tsx",
import.meta.url,
),
"utf8",
);
const model = fs.readFileSync(
new URL(
"../src/features/calendar/calendarViewModel.ts",
import.meta.url,
),
"utf8",
);
const collectionDialogs = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarCollectionDialogs.tsx",
import.meta.url,
),
"utf8",
);
const eventDialog = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarEventDialog.tsx",
import.meta.url,
),
"utf8",
);
assert(
page.includes('from "./CalendarViews"'),
"CalendarPage must compose the focused calendar view components",
);
assert(
!page.includes("function CalendarWeekRows("),
"CalendarPage must not own month/continuous rendering",
);
assert(
!page.includes("function CalendarTimeGrid("),
"CalendarPage must not own time-grid rendering",
);
assert(
views.includes("export function CalendarWeekRows(") &&
views.includes("export function CalendarTimeGrid("),
"CalendarViews must own both view families",
);
assert(
model.includes("export function layoutTimedEventsForDay(") &&
model.includes("export function continuousVirtualWindow("),
"calendar layout and virtualization must remain independently testable",
);
assert(
!page.includes("function CalendarCollectionDialog(") &&
!page.includes("function CalendarEventDialog("),
"CalendarPage must not own source or event form dialogs",
);
assert(
collectionDialogs.includes(
"export function CalendarCollectionDialog(",
) &&
eventDialog.includes("export function CalendarEventDialog("),
"calendar dialogs must remain focused components",
);
console.log("Calendar page decomposition checks passed.");

View File

@@ -0,0 +1,115 @@
import {
continuousEventWindow,
continuousVirtualWindow,
daysForMode,
groupEventsByDay,
layoutTimedEventsForDay,
moveEventToDay,
rangeForMode,
resizeEventToTime,
} from "../src/features/calendar/calendarViewModel.ts";
function assert(condition: unknown, message: string): void {
if (!condition) throw new Error(message);
}
function event(
id: string,
start: Date,
end: Date,
allDay = false,
) {
return {
id,
calendar_id: "calendar-1",
summary: id,
start_at: start.toISOString(),
end_at: end.toISOString(),
all_day: allDay,
} as never;
}
const focus = new Date(2026, 6, 15, 12);
const month = rangeForMode("month", focus, {
before: 8,
after: 12,
});
assert(
(month.end.getTime() - month.start.getTime()) / 86_400_000 === 42,
"month view should retain its six-week window",
);
const virtual = continuousVirtualWindow(
100,
{ scrollTop: 20 * 92, height: 8 * 92 },
3,
);
assert(virtual.start === 17, "virtualization should retain overscan above");
assert(virtual.end === 31, "virtualization should retain overscan below");
assert(
virtual.topSpacerHeight + virtual.bottomSpacerHeight > 0,
"virtualization should preserve offscreen geometry",
);
const continuousDays = daysForMode("continuous", focus, {
before: 50,
after: 50,
});
const eventWindow = continuousEventWindow(
continuousDays,
{ scrollTop: 40 * 92, height: 6 * 92 },
);
assert(
(eventWindow.end.getTime() - eventWindow.start.getTime()) / 86_400_000 === 10 * 7,
"continuous event loading should stay bounded to a ten-week window",
);
assert(
eventWindow.start > continuousDays[0],
"continuous event loading should follow the virtualized viewport",
);
const day = new Date(2026, 6, 7);
const parallel = [0, 1, 2, 3].map((index) =>
event(
`event-${index}`,
new Date(2026, 6, 7, 9),
new Date(2026, 6, 7, 10),
),
);
const layout = layoutTimedEventsForDay(parallel, day);
assert(
layout.items.length === 2 && layout.overflows.length === 1,
"parallel events should use bounded columns plus one overflow control",
);
assert(
layout.overflows[0].events.length === 2,
"overflow should retain every hidden event",
);
const allDay = event(
"all-day",
new Date(2026, 6, 7),
new Date(2026, 6, 9),
true,
);
const grouped = groupEventsByDay([allDay]);
assert(grouped.size === 2, "all-day end dates should remain exclusive");
const moved = moveEventToDay(allDay, new Date(2026, 6, 20));
assert(
moved.endAt?.getDate() === 22,
"moving an all-day event should preserve its duration",
);
const timed = event(
"timed",
new Date(2026, 6, 7, 10),
new Date(2026, 6, 7, 11),
);
const resized = resizeEventToTime(timed, "end", day, 9 * 60);
assert(
resized.endAt !== null &&
resized.endAt.getTime() > resized.startAt.getTime(),
"resizing must not invert an event",
);
console.log("Calendar view model interaction checks passed.");

View File

@@ -0,0 +1,38 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": [
"../../govoplan-core/webui/src/index.ts"
],
"lucide-react": [
"../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"
],
"react": [
"../../govoplan-core/webui/node_modules/@types/react/index.d.ts"
],
"react/jsx-runtime": [
"../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"
]
}
},
"include": [
"../../govoplan-core/webui/src/vite-env.d.ts",
"src/api/calendar.ts",
"src/features/calendar/CalendarPage.tsx",
"src/features/calendar/CalendarViews.tsx",
"src/features/calendar/CalendarCollectionDialogs.tsx",
"src/features/calendar/CalendarEventDialog.tsx",
"src/features/calendar/calendarViewModel.ts"
]
}