Compare commits
25
Commits
a52e35b52b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccdf12162a | ||
|
|
001ebd3b6d | ||
|
|
f00cff1d8c | ||
|
|
1e063f51cc | ||
|
|
b1cf001452 | ||
|
|
56b387a784 | ||
|
|
4011e61a63 | ||
|
|
2cb3aca27c | ||
|
|
77072d057a | ||
|
|
202eb78ae4 | ||
|
|
bf59cd830c | ||
|
|
041e2159e7 | ||
|
|
d64de50802 | ||
|
|
7843fe88e5 | ||
|
|
39b199d7e6 | ||
|
|
74495e56cb | ||
|
|
6ca1816c95 | ||
|
|
2d7d105ba3 | ||
|
|
41b9426670 | ||
|
|
baf624c7a6 | ||
|
|
d10570fc0c | ||
|
|
30f6d79f9e | ||
|
|
760a87ab99 | ||
|
|
7098751f7f | ||
|
|
1df2a1a730 |
@@ -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
|
||||
records the remote collection URL, sync token, ETag/ctag state, username,
|
||||
credential reference, sync interval, sync direction, and conflict policy.
|
||||
Credentials can be supplied transiently for manual sync, referenced from
|
||||
environment variables with `env:NAME`, stored through a platform secret provider
|
||||
when one is available, or stored as encrypted calendar-owned credentials.
|
||||
Credentials can be supplied transiently for manual sync. Persisted API-managed
|
||||
sources accept only a password or token: Calendar stores an opaque, tenant- and
|
||||
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`
|
||||
when a sync token exists. It imports all VEVENT components in a resource and
|
||||
@@ -53,10 +65,12 @@ and the user must sync before retrying. A calendar-owned task,
|
||||
`govoplan_calendar.sync_due_caldav_sources`, can run due sources in a worker or
|
||||
cron-style scheduler.
|
||||
|
||||
This is not yet a full CalDAV network server. Scheduling inbox/outbox behavior,
|
||||
CalDAV server endpoints, UI credential management, and full user-facing
|
||||
recurrence editing remain follow-up work. The backend now includes a free/busy
|
||||
API primitive for scheduling and appointment modules.
|
||||
Calendar exposes collection and credential management, sync status/actions,
|
||||
durable per-user view preferences, recurrence expansion, detached occurrence
|
||||
overrides, instance/series editing, and a free/busy API primitive for scheduling
|
||||
and appointment modules. It is not yet a CalDAV network server: external clients
|
||||
cannot use GovOPlaN itself as their CalDAV endpoint, and scheduling inbox/outbox
|
||||
delivery remains owned by the scheduling and mail integration work.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
- calendar collections
|
||||
- VEVENT storage and iCalendar import/export
|
||||
- event recurrence data, recurrence exceptions, and future recurrence expansion
|
||||
- event recurrence data, recurrence expansion, and recurrence exceptions
|
||||
- availability and free/busy semantics
|
||||
- resources such as rooms, shared equipment, and service desks
|
||||
- groupware calendar adapter boundaries, including CalDAV and Open-Xchange
|
||||
@@ -22,7 +22,10 @@ The first standalone module provides:
|
||||
- normalized query fields: start, end, summary, location, all-day flag, status, transparency, classification, calendar ID, UID, recurrence ID, sequence, source, and ETag
|
||||
- iCalendar preservation: raw VEVENT properties, parameters, and generated `text/calendar` export
|
||||
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
|
||||
- free/busy API primitive with recurrence expansion for scheduling and appointment conflict checks
|
||||
- bounded occurrence expansion with RRULE, RDATE, EXDATE, detached overrides,
|
||||
instance/series editing, and free/busy reconciliation
|
||||
- durable per-user calendar view preferences exposed through the platform
|
||||
Settings surface
|
||||
- calendar-owned CalDAV sync sources with credential references, scheduled due-sync metadata, full/incremental inbound sync, two-way PUT/DELETE writes, and ETag conflict handling
|
||||
- a Calendar-owned durable desired-state outbox for two-way CalDAV writes. Event
|
||||
state and the exact external resource snapshot commit atomically; workers use
|
||||
@@ -30,7 +33,10 @@ The first standalone module provides:
|
||||
exponential retry, and semantic GET reconciliation after ambiguous outcomes
|
||||
- WebUI views: month, week, workweek, day, and continuous week-row scrolling
|
||||
|
||||
The first implementation is not yet a full CalDAV network server. It is the internal calendar storage, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built.
|
||||
The implementation is not yet a full CalDAV network server. It is the internal
|
||||
calendar storage, recurrence, sync, availability, and UI foundation on which
|
||||
Open-Xchange integration, scheduling inbox/outbox behavior, and CalDAV server
|
||||
endpoints can be built.
|
||||
|
||||
## Outbound delivery operations
|
||||
|
||||
@@ -69,6 +75,12 @@ bindings or unresolved delivery. Credential rotation does not discard committed
|
||||
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
|
||||
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
|
||||
calendar; multi-source fan-in will require per-event source routing. Celery beat
|
||||
triggers recovery every minute, while root-transaction after-commit dispatch
|
||||
@@ -191,9 +203,6 @@ The connector boundary should hand calendar a configured profile and credentials
|
||||
|
||||
## Follow-Up Work
|
||||
|
||||
- Full recurrence expansion for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
|
||||
- User-facing recurrence editing for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
|
||||
- UI management for CalDAV credentials, sync status, sync direction, and conflict resolution.
|
||||
- CalDAV server endpoints if GovOPlaN should expose calendars to external clients rather than only syncing remote collections.
|
||||
- Open-Xchange adapter that maps OX calendars, attendees, resources, recurrence, and free/busy to the internal calendar model.
|
||||
- Attendee RSVP workflow and mail/notification bridge.
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.8",
|
||||
"govoplan-core>=0.1.9",
|
||||
"govoplan-access>=0.1.8",
|
||||
"defusedxml>=0.7,<1",
|
||||
"icalendar>=7.2",
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.4"
|
||||
__version__ = "0.1.8"
|
||||
|
||||
@@ -9,6 +9,12 @@ from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
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):
|
||||
@@ -353,20 +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]:
|
||||
url = validate_http_url(url)
|
||||
try:
|
||||
url = validate_outbound_http_url(url, label="CalDAV URL")
|
||||
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
|
||||
url,
|
||||
data=body,
|
||||
headers=dict(headers),
|
||||
method=method,
|
||||
)
|
||||
opener = urllib.request.build_opener(_SameOriginRedirectHandler(url))
|
||||
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
|
||||
return response.status, dict(response.headers.items()), response.read()
|
||||
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:
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
@@ -542,7 +559,8 @@ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
del fp, msg, headers
|
||||
try:
|
||||
candidate = validate_http_url(newurl)
|
||||
except CalDAVError:
|
||||
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
|
||||
|
||||
@@ -1,16 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.calendar import (
|
||||
CalendarCapabilityError,
|
||||
CalendarEventRef,
|
||||
CalendarEventRequest,
|
||||
CalendarInvitationAttendeeRequest,
|
||||
CalendarInvitationProvider,
|
||||
CalendarInvitationRef,
|
||||
CalendarInvitationRequest,
|
||||
CalendarSchedulingProvider,
|
||||
)
|
||||
from govoplan_calendar.backend.schemas import CalendarEventCreateRequest
|
||||
from govoplan_calendar.backend.service import CalendarError, create_event, list_freebusy
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_calendar.backend.db.models import CalendarEvent
|
||||
from govoplan_calendar.backend.schemas import (
|
||||
CalendarEventCreateRequest,
|
||||
CalendarEventUpdateRequest,
|
||||
)
|
||||
from govoplan_calendar.backend.service import (
|
||||
CalendarError,
|
||||
create_event,
|
||||
list_freebusy,
|
||||
update_event,
|
||||
)
|
||||
|
||||
|
||||
_PARTICIPATION_STATUSES = {
|
||||
"NEEDS-ACTION",
|
||||
"ACCEPTED",
|
||||
"DECLINED",
|
||||
"TENTATIVE",
|
||||
"DELEGATED",
|
||||
}
|
||||
|
||||
|
||||
def _external_state(event: CalendarEvent) -> tuple[str, str | None]:
|
||||
caldav_metadata = (event.metadata_ or {}).get("caldav")
|
||||
if not isinstance(caldav_metadata, dict):
|
||||
return "local", None
|
||||
state = str(caldav_metadata.get("external_state") or "queued")
|
||||
operation_id = caldav_metadata.get("outbox_operation_id")
|
||||
return state, str(operation_id) if operation_id else None
|
||||
|
||||
|
||||
def _attendee_address(item: Mapping[str, object]) -> str:
|
||||
raw = item.get("email") or item.get("address") or item.get("value") or ""
|
||||
value = str(raw).strip()
|
||||
if value.casefold().startswith("mailto:"):
|
||||
value = value[7:]
|
||||
return value.casefold()
|
||||
|
||||
|
||||
def _attendee_record(
|
||||
attendee: CalendarInvitationAttendeeRequest,
|
||||
) -> dict[str, object]:
|
||||
status = attendee.participation_status.strip().upper()
|
||||
if status not in _PARTICIPATION_STATUSES:
|
||||
raise CalendarCapabilityError(
|
||||
f"Unsupported attendee participation status: {status}"
|
||||
)
|
||||
params: dict[str, list[str]] = {
|
||||
"ROLE": [attendee.role.strip().upper() or "REQ-PARTICIPANT"],
|
||||
"PARTSTAT": [status],
|
||||
"RSVP": ["TRUE" if attendee.rsvp else "FALSE"],
|
||||
}
|
||||
if attendee.name:
|
||||
params["CN"] = [attendee.name]
|
||||
return {
|
||||
"value": f"mailto:{attendee.address.strip()}",
|
||||
"params": params,
|
||||
"response_at": None,
|
||||
}
|
||||
|
||||
|
||||
def _attendee_status(item: Mapping[str, object]) -> str:
|
||||
direct = item.get("participation_status") or item.get("response_status")
|
||||
if direct:
|
||||
return str(direct).upper()
|
||||
params = item.get("params")
|
||||
if isinstance(params, Mapping):
|
||||
value = params.get("PARTSTAT")
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
return str(value[0]).upper() if value else "NEEDS-ACTION"
|
||||
if value:
|
||||
return str(value).upper()
|
||||
return "NEEDS-ACTION"
|
||||
|
||||
|
||||
def _merge_attendees(
|
||||
current: Sequence[Mapping[str, object]],
|
||||
requested: Sequence[CalendarInvitationAttendeeRequest],
|
||||
) -> list[dict[str, object]]:
|
||||
prior = {_attendee_address(item): item for item in current}
|
||||
merged: list[dict[str, object]] = []
|
||||
for attendee in requested:
|
||||
record = _attendee_record(attendee)
|
||||
existing = prior.get(attendee.address.strip().casefold())
|
||||
if existing is not None and _attendee_status(existing) != "NEEDS-ACTION":
|
||||
params = dict(record["params"])
|
||||
params["PARTSTAT"] = [_attendee_status(existing)]
|
||||
record["params"] = params
|
||||
record["response_at"] = existing.get("response_at")
|
||||
if existing.get("response_evidence") is not None:
|
||||
record["response_evidence"] = existing["response_evidence"]
|
||||
merged.append(record)
|
||||
return merged
|
||||
|
||||
|
||||
def _invitation_uid(request: CalendarInvitationRequest) -> str:
|
||||
identity = ":".join(
|
||||
(
|
||||
request.source_module,
|
||||
request.source_resource_type,
|
||||
request.source_resource_id or "",
|
||||
request.correlation_id,
|
||||
)
|
||||
)
|
||||
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:40]
|
||||
return f"invitation-{digest}@govoplan.local"
|
||||
|
||||
|
||||
def _invitation_metadata(
|
||||
request: CalendarInvitationRequest,
|
||||
previous: Mapping[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
metadata = dict(previous or {})
|
||||
metadata.update(dict(request.metadata))
|
||||
metadata["calendar_invitation"] = {
|
||||
"correlation_id": request.correlation_id,
|
||||
"source_module": request.source_module,
|
||||
"source_resource_type": request.source_resource_type,
|
||||
"source_resource_id": request.source_resource_id,
|
||||
}
|
||||
return metadata
|
||||
|
||||
|
||||
def _invitation_ref(event: CalendarEvent) -> CalendarInvitationRef:
|
||||
state, operation_id = _external_state(event)
|
||||
return CalendarInvitationRef(
|
||||
event_id=event.id,
|
||||
calendar_id=event.calendar_id,
|
||||
uid=event.uid,
|
||||
correlation_id=event.correlation_key or "",
|
||||
source_module=event.producer_module or "unknown",
|
||||
source_resource_type=event.producer_resource_type or "unknown",
|
||||
source_resource_id=event.producer_resource_id,
|
||||
attendees=tuple(dict(item) for item in event.attendees or []),
|
||||
external_state=state,
|
||||
outbox_operation_id=operation_id,
|
||||
degraded_reasons=(
|
||||
"Recurring campaign invitations require a separate series workflow.",
|
||||
"Mail-delivered iCalendar replies require a Mail adapter to call record_response.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
|
||||
@@ -66,13 +213,7 @@ class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
|
||||
event = create_event(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
|
||||
except CalendarError as exc:
|
||||
raise CalendarCapabilityError(str(exc)) from exc
|
||||
caldav_metadata = (event.metadata_ or {}).get("caldav")
|
||||
external_state = "local"
|
||||
outbox_operation_id = None
|
||||
if isinstance(caldav_metadata, dict):
|
||||
external_state = str(caldav_metadata.get("external_state") or "queued")
|
||||
raw_operation_id = caldav_metadata.get("outbox_operation_id")
|
||||
outbox_operation_id = str(raw_operation_id) if raw_operation_id else None
|
||||
external_state, outbox_operation_id = _external_state(event)
|
||||
return CalendarEventRef(
|
||||
id=event.id,
|
||||
calendar_id=event.calendar_id,
|
||||
@@ -80,3 +221,213 @@ class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
|
||||
external_state=external_state,
|
||||
outbox_operation_id=outbox_operation_id,
|
||||
)
|
||||
|
||||
|
||||
class SqlCalendarInvitationProvider(CalendarInvitationProvider):
|
||||
def upsert_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
request: CalendarInvitationRequest,
|
||||
) -> CalendarInvitationRef:
|
||||
db = self._session(session)
|
||||
self._validate_request(request)
|
||||
existing = (
|
||||
db.query(CalendarEvent)
|
||||
.filter(
|
||||
CalendarEvent.tenant_id == tenant_id,
|
||||
CalendarEvent.correlation_key == request.correlation_id,
|
||||
CalendarEvent.deleted_at.is_(None),
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
attendees = _merge_attendees(
|
||||
existing.attendees if existing is not None else (),
|
||||
request.attendees,
|
||||
)
|
||||
metadata = _invitation_metadata(
|
||||
request,
|
||||
existing.metadata_ if existing is not None else None,
|
||||
)
|
||||
try:
|
||||
if existing is None:
|
||||
event = create_event(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
payload=CalendarEventCreateRequest(
|
||||
calendar_id=request.calendar_id,
|
||||
uid=_invitation_uid(request),
|
||||
summary=request.summary,
|
||||
description=request.description,
|
||||
location=request.location,
|
||||
classification=request.classification,
|
||||
start_at=request.start_at,
|
||||
end_at=request.end_at,
|
||||
timezone=request.timezone,
|
||||
organizer=(
|
||||
dict(request.organizer) if request.organizer else None
|
||||
),
|
||||
attendees=attendees,
|
||||
categories=list(request.categories),
|
||||
metadata=metadata,
|
||||
),
|
||||
)
|
||||
else:
|
||||
event = update_event(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
event_id=existing.id,
|
||||
payload=CalendarEventUpdateRequest(
|
||||
calendar_id=request.calendar_id,
|
||||
summary=request.summary,
|
||||
description=request.description,
|
||||
location=request.location,
|
||||
classification=request.classification,
|
||||
start_at=request.start_at,
|
||||
end_at=request.end_at,
|
||||
timezone=request.timezone,
|
||||
organizer=(
|
||||
dict(request.organizer) if request.organizer else None
|
||||
),
|
||||
attendees=attendees,
|
||||
categories=list(request.categories),
|
||||
metadata=metadata,
|
||||
),
|
||||
)
|
||||
except (CalendarError, TypeError, ValueError) as exc:
|
||||
raise CalendarCapabilityError(str(exc)) from exc
|
||||
event.correlation_key = request.correlation_id
|
||||
event.producer_module = request.source_module
|
||||
event.producer_resource_type = request.source_resource_type
|
||||
event.producer_resource_id = request.source_resource_id
|
||||
db.flush()
|
||||
return _invitation_ref(event)
|
||||
|
||||
def get_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
correlation_id: str,
|
||||
) -> CalendarInvitationRef | None:
|
||||
event = (
|
||||
self._session(session)
|
||||
.query(CalendarEvent)
|
||||
.filter(
|
||||
CalendarEvent.tenant_id == tenant_id,
|
||||
CalendarEvent.correlation_key == correlation_id,
|
||||
CalendarEvent.deleted_at.is_(None),
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
return _invitation_ref(event) if event is not None else None
|
||||
|
||||
def record_response(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
attendee_address: str,
|
||||
participation_status: str,
|
||||
correlation_id: str | None = None,
|
||||
uid: str | None = None,
|
||||
responded_at: datetime | None = None,
|
||||
evidence: Mapping[str, object] | None = None,
|
||||
) -> CalendarInvitationRef:
|
||||
if bool(correlation_id) == bool(uid):
|
||||
raise CalendarCapabilityError(
|
||||
"Specify exactly one invitation correlation_id or uid."
|
||||
)
|
||||
status = participation_status.strip().upper()
|
||||
if status not in _PARTICIPATION_STATUSES - {"NEEDS-ACTION"}:
|
||||
raise CalendarCapabilityError(
|
||||
f"Unsupported attendee participation status: {status}"
|
||||
)
|
||||
db = self._session(session)
|
||||
query = db.query(CalendarEvent).filter(
|
||||
CalendarEvent.tenant_id == tenant_id,
|
||||
CalendarEvent.deleted_at.is_(None),
|
||||
)
|
||||
query = query.filter(
|
||||
CalendarEvent.correlation_key == correlation_id
|
||||
if correlation_id
|
||||
else CalendarEvent.uid == uid
|
||||
)
|
||||
event = query.one_or_none()
|
||||
if event is None:
|
||||
raise CalendarCapabilityError("Calendar invitation was not found.")
|
||||
|
||||
target = attendee_address.strip().casefold()
|
||||
attendees: list[dict[str, object]] = []
|
||||
matched = False
|
||||
response_time = responded_at or utc_now()
|
||||
for raw in event.attendees or []:
|
||||
item = dict(raw)
|
||||
if _attendee_address(item) == target:
|
||||
params = dict(item.get("params") or {})
|
||||
params["PARTSTAT"] = [status]
|
||||
item["params"] = params
|
||||
item["response_at"] = response_time.isoformat()
|
||||
item["response_evidence"] = dict(evidence or {})
|
||||
matched = True
|
||||
attendees.append(item)
|
||||
if not matched:
|
||||
raise CalendarCapabilityError(
|
||||
"The reply address is not an attendee of this invitation."
|
||||
)
|
||||
metadata = dict(event.metadata_ or {})
|
||||
invitation_metadata = dict(metadata.get("calendar_invitation") or {})
|
||||
invitation_metadata["last_response_at"] = response_time.isoformat()
|
||||
metadata["calendar_invitation"] = invitation_metadata
|
||||
try:
|
||||
event = update_event(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=None,
|
||||
event_id=event.id,
|
||||
payload=CalendarEventUpdateRequest(
|
||||
attendees=attendees,
|
||||
metadata=metadata,
|
||||
),
|
||||
)
|
||||
except CalendarError as exc:
|
||||
raise CalendarCapabilityError(str(exc)) from exc
|
||||
return _invitation_ref(event)
|
||||
|
||||
@staticmethod
|
||||
def _session(session: object) -> Session:
|
||||
if not isinstance(session, Session):
|
||||
raise CalendarCapabilityError(
|
||||
"Calendar invitations require a SQLAlchemy Session."
|
||||
)
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def _validate_request(request: CalendarInvitationRequest) -> None:
|
||||
for label, value, maximum in (
|
||||
("correlation_id", request.correlation_id, 255),
|
||||
("source_module", request.source_module, 100),
|
||||
("source_resource_type", request.source_resource_type, 100),
|
||||
("summary", request.summary, 500),
|
||||
):
|
||||
if not value.strip() or len(value) > maximum:
|
||||
raise CalendarCapabilityError(
|
||||
f"Invitation {label} must contain 1 to {maximum} characters."
|
||||
)
|
||||
if request.source_resource_id and len(request.source_resource_id) > 255:
|
||||
raise CalendarCapabilityError(
|
||||
"Invitation source_resource_id must not exceed 255 characters."
|
||||
)
|
||||
if not request.attendees:
|
||||
raise CalendarCapabilityError(
|
||||
"Calendar invitations require at least one attendee."
|
||||
)
|
||||
addresses = [item.address.strip().casefold() for item in request.attendees]
|
||||
if any(not item for item in addresses) or len(set(addresses)) != len(addresses):
|
||||
raise CalendarCapabilityError(
|
||||
"Invitation attendee addresses must be non-empty and unique."
|
||||
)
|
||||
|
||||
@@ -52,6 +52,11 @@ class CalendarEvent(Base, TimestampMixin):
|
||||
UniqueConstraint("calendar_id", "uid", "recurrence_id", name="uq_calendar_events_calendar_uid_recurrence"),
|
||||
Index("ix_calendar_events_range", "tenant_id", "calendar_id", "start_at", "end_at"),
|
||||
Index("ix_calendar_events_tenant_uid", "tenant_id", "uid"),
|
||||
Index(
|
||||
"ix_calendar_events_tenant_correlation",
|
||||
"tenant_id",
|
||||
"correlation_key",
|
||||
),
|
||||
Index("ix_calendar_events_tenant_status", "tenant_id", "status"),
|
||||
)
|
||||
|
||||
@@ -59,6 +64,18 @@ class CalendarEvent(Base, TimestampMixin):
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
uid: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
correlation_key: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
producer_module: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, index=True
|
||||
)
|
||||
producer_resource_type: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True
|
||||
)
|
||||
producer_resource_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
recurrence_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
sequence: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
summary: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
@@ -94,6 +111,36 @@ class CalendarEvent(Base, TimestampMixin):
|
||||
calendar: Mapped[CalendarCollection] = relationship(back_populates="events")
|
||||
|
||||
|
||||
class CalendarViewPreference(Base, TimestampMixin):
|
||||
__tablename__ = "calendar_view_preferences"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"user_id",
|
||||
name="uq_calendar_view_preferences_tenant_user",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
dim_weekends: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
dim_off_hours: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
workday_start_hour: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
workday_end_hour: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
continuous_virtualization: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
continuous_overscan_weeks: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
alternate_continuous_months: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
|
||||
|
||||
class CalendarSyncSource(Base, TimestampMixin):
|
||||
__tablename__ = "calendar_sync_sources"
|
||||
__table_args__ = (
|
||||
@@ -215,5 +262,6 @@ __all__ = [
|
||||
"CalendarOutboxOperation",
|
||||
"CalendarSyncCredential",
|
||||
"CalendarSyncSource",
|
||||
"CalendarViewPreference",
|
||||
"new_uuid",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from defusedxml import ElementTree as SafeElementTree
|
||||
|
||||
|
||||
EWS_SOAP_NS = "http://schemas.xmlsoap.org/soap/envelope/"
|
||||
EWS_MESSAGES_NS = (
|
||||
"http://schemas.microsoft.com/exchange/services/2006/messages"
|
||||
)
|
||||
EWS_TYPES_NS = "http://schemas.microsoft.com/exchange/services/2006/types"
|
||||
|
||||
|
||||
class EwsAdapterError(ValueError):
|
||||
"""Raised when an EWS response cannot be translated safely."""
|
||||
|
||||
|
||||
def ews_find_item_body(
|
||||
*,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
mailbox: Any | None = None,
|
||||
) -> str:
|
||||
start_text = normalize_datetime(start).isoformat().replace("+00:00", "Z")
|
||||
end_text = normalize_datetime(end).isoformat().replace("+00:00", "Z")
|
||||
mailbox_xml = ""
|
||||
if mailbox:
|
||||
mailbox_xml = (
|
||||
"<t:Mailbox><t:EmailAddress>"
|
||||
f"{xml_escape(str(mailbox))}"
|
||||
"</t:EmailAddress></t:Mailbox>"
|
||||
)
|
||||
return f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="{EWS_SOAP_NS}" xmlns:m="{EWS_MESSAGES_NS}" xmlns:t="{EWS_TYPES_NS}">
|
||||
<s:Header>
|
||||
<t:RequestServerVersion Version="Exchange2013_SP1" />
|
||||
</s:Header>
|
||||
<s:Body>
|
||||
<m:FindItem Traversal="Shallow">
|
||||
<m:ItemShape>
|
||||
<t:BaseShape>AllProperties</t:BaseShape>
|
||||
</m:ItemShape>
|
||||
<m:CalendarView StartDate="{start_text}" EndDate="{end_text}" />
|
||||
<m:ParentFolderIds>
|
||||
<t:DistinguishedFolderId Id="calendar">{mailbox_xml}</t:DistinguishedFolderId>
|
||||
</m:ParentFolderIds>
|
||||
</m:FindItem>
|
||||
</s:Body>
|
||||
</s:Envelope>"""
|
||||
|
||||
|
||||
def parse_ews_calendar_items(xml_text: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
root = SafeElementTree.fromstring(xml_text)
|
||||
except SafeElementTree.ParseError as exc:
|
||||
raise EwsAdapterError(f"Invalid EWS response XML: {exc}") from exc
|
||||
items: list[dict[str, Any]] = []
|
||||
for item in root.findall(f".//{{{EWS_TYPES_NS}}}CalendarItem"):
|
||||
item_id = item.find(f"./{{{EWS_TYPES_NS}}}ItemId")
|
||||
href = item_id.get("Id") if item_id is not None else None
|
||||
if not href:
|
||||
continue
|
||||
change_key = (
|
||||
item_id.get("ChangeKey") if item_id is not None else None
|
||||
)
|
||||
start = parse_ews_datetime(text_of(item, "Start"))
|
||||
end_text = text_of(item, "End")
|
||||
end = parse_ews_datetime(end_text) if end_text else start
|
||||
uid = text_of(item, "UID") or href
|
||||
subject = text_of(item, "Subject") or "(Untitled event)"
|
||||
all_day = text_of(item, "IsAllDayEvent") == "true"
|
||||
free_busy = text_of(item, "LegacyFreeBusyStatus") or "Busy"
|
||||
sensitivity = text_of(item, "Sensitivity") or "Normal"
|
||||
body = item.find(f"./{{{EWS_TYPES_NS}}}Body")
|
||||
provider_payload = element_to_dict(item)
|
||||
items.append(
|
||||
{
|
||||
"href": href,
|
||||
"uid": uid,
|
||||
"recurrence_id": (
|
||||
href
|
||||
if text_of(item, "CalendarItemType")
|
||||
in {"Occurrence", "Exception"}
|
||||
else None
|
||||
),
|
||||
"sequence": int_or_default(
|
||||
text_of(item, "AppointmentSequenceNumber"),
|
||||
0,
|
||||
),
|
||||
"summary": subject,
|
||||
"description": body.text if body is not None else None,
|
||||
"location": text_of(item, "Location"),
|
||||
"status": (
|
||||
"CANCELLED"
|
||||
if text_of(item, "IsCancelled") == "true"
|
||||
else "CONFIRMED"
|
||||
),
|
||||
"transparency": (
|
||||
"TRANSPARENT"
|
||||
if free_busy.lower() == "free"
|
||||
else "OPAQUE"
|
||||
),
|
||||
"classification": (
|
||||
"PRIVATE"
|
||||
if sensitivity.lower() == "private"
|
||||
else "PUBLIC"
|
||||
),
|
||||
"start_at": start,
|
||||
"end_at": end,
|
||||
"duration_seconds": int((end - start).total_seconds()),
|
||||
"all_day": all_day,
|
||||
"timezone": "UTC",
|
||||
"organizer": ews_mailbox_record(
|
||||
item.find(
|
||||
f"./{{{EWS_TYPES_NS}}}Organizer/"
|
||||
f"{{{EWS_TYPES_NS}}}Mailbox"
|
||||
)
|
||||
),
|
||||
"attendees": ews_attendees(item),
|
||||
"categories": [
|
||||
category.text
|
||||
for category in item.findall(
|
||||
f"./{{{EWS_TYPES_NS}}}Categories/"
|
||||
f"{{{EWS_TYPES_NS}}}String"
|
||||
)
|
||||
if category.text
|
||||
],
|
||||
"rrule": None,
|
||||
"rdate": [],
|
||||
"exdate": [],
|
||||
"reminders": ews_reminders(item),
|
||||
"attachments": [],
|
||||
"related_to": [],
|
||||
"etag": change_key,
|
||||
"icalendar": {
|
||||
"component": "VEVENT",
|
||||
"schema_version": 1,
|
||||
"provider": "ews",
|
||||
"ews": provider_payload,
|
||||
},
|
||||
"metadata": {"ews": provider_payload},
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def parse_ews_datetime(value: str | None) -> datetime:
|
||||
if not value:
|
||||
return datetime.now(timezone.utc)
|
||||
return normalize_datetime(
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
)
|
||||
|
||||
|
||||
def text_of(item: Any, name: str) -> str | None:
|
||||
child = item.find(f"./{{{EWS_TYPES_NS}}}{name}")
|
||||
return child.text if child is not None else None
|
||||
|
||||
|
||||
def ews_mailbox_record(mailbox: Any | None) -> dict[str, Any] | None:
|
||||
if mailbox is None:
|
||||
return None
|
||||
return {
|
||||
"name": text_of(mailbox, "Name") or "",
|
||||
"email": text_of(mailbox, "EmailAddress") or "",
|
||||
"routing_type": text_of(mailbox, "RoutingType") or "",
|
||||
}
|
||||
|
||||
|
||||
def ews_attendees(item: Any) -> list[dict[str, Any]]:
|
||||
attendees: list[dict[str, Any]] = []
|
||||
for role, path in (
|
||||
("required", "RequiredAttendees"),
|
||||
("optional", "OptionalAttendees"),
|
||||
):
|
||||
for attendee in item.findall(
|
||||
f"./{{{EWS_TYPES_NS}}}{path}/"
|
||||
f"{{{EWS_TYPES_NS}}}Attendee"
|
||||
):
|
||||
mailbox = attendee.find(f"./{{{EWS_TYPES_NS}}}Mailbox")
|
||||
record = ews_mailbox_record(mailbox) or {}
|
||||
record["role"] = role
|
||||
response = text_of(attendee, "ResponseType")
|
||||
if response:
|
||||
record["status"] = response
|
||||
attendees.append(record)
|
||||
return attendees
|
||||
|
||||
|
||||
def ews_reminders(item: Any) -> list[dict[str, Any]]:
|
||||
if text_of(item, "ReminderIsSet") == "true":
|
||||
minutes = int_or_default(
|
||||
text_of(item, "ReminderMinutesBeforeStart"),
|
||||
15,
|
||||
)
|
||||
return [{"action": "DISPLAY", "trigger_minutes_before": minutes}]
|
||||
return []
|
||||
|
||||
|
||||
def element_to_dict(element: Any) -> dict[str, Any]:
|
||||
tag = element.tag.rsplit("}", 1)[-1]
|
||||
children = list(element)
|
||||
result: dict[str, Any] = {"tag": tag}
|
||||
if element.attrib:
|
||||
result["attributes"] = dict(element.attrib)
|
||||
if element.text and element.text.strip():
|
||||
result["text"] = element.text.strip()
|
||||
if children:
|
||||
result["children"] = [
|
||||
element_to_dict(child) for child in children
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def int_or_default(value: str | None, default: int) -> int:
|
||||
try:
|
||||
return int(value) if value is not None else default
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def xml_escape(value: str) -> str:
|
||||
return (
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
def normalize_datetime(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
import uuid
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
|
||||
def graph_event_payload(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Translate one Microsoft Graph event into the calendar event contract."""
|
||||
|
||||
start = graph_datetime(item.get("start"))
|
||||
end = graph_datetime(item.get("end"))
|
||||
all_day = bool(item.get("isAllDay"))
|
||||
uid = str(
|
||||
item.get("iCalUId")
|
||||
or item.get("uid")
|
||||
or item.get("id")
|
||||
or uuid.uuid4()
|
||||
)
|
||||
return {
|
||||
"uid": uid,
|
||||
"recurrence_id": (
|
||||
str(item.get("id"))
|
||||
if item.get("type") == "occurrence"
|
||||
else None
|
||||
),
|
||||
"sequence": int(item.get("sequence") or 0),
|
||||
"summary": str(item.get("subject") or "(Untitled event)"),
|
||||
"description": graph_body_text(item),
|
||||
"location": graph_location_text(item.get("location")),
|
||||
"status": (
|
||||
"CANCELLED" if item.get("isCancelled") else "CONFIRMED"
|
||||
),
|
||||
"transparency": (
|
||||
"TRANSPARENT"
|
||||
if item.get("showAs") in {"free", "workingElsewhere"}
|
||||
else "OPAQUE"
|
||||
),
|
||||
"classification": (
|
||||
"PRIVATE"
|
||||
if item.get("sensitivity") == "private"
|
||||
else "PUBLIC"
|
||||
),
|
||||
"start_at": start,
|
||||
"end_at": end,
|
||||
"duration_seconds": int((end - start).total_seconds()),
|
||||
"all_day": all_day,
|
||||
"timezone": (
|
||||
(item.get("start") or {}).get("timeZone")
|
||||
if isinstance(item.get("start"), dict)
|
||||
else None
|
||||
)
|
||||
or "UTC",
|
||||
"organizer": graph_party(item.get("organizer")),
|
||||
"attendees": [
|
||||
graph_party(attendee)
|
||||
for attendee in item.get("attendees") or []
|
||||
],
|
||||
"categories": [
|
||||
str(category) for category in item.get("categories") or []
|
||||
],
|
||||
"rrule": (
|
||||
item.get("recurrence")
|
||||
if isinstance(item.get("recurrence"), dict)
|
||||
else None
|
||||
),
|
||||
"rdate": [],
|
||||
"exdate": [],
|
||||
"reminders": graph_reminders(item),
|
||||
"attachments": [],
|
||||
"related_to": [],
|
||||
"etag": item.get("@odata.etag"),
|
||||
"icalendar": {
|
||||
"component": "VEVENT",
|
||||
"schema_version": 1,
|
||||
"provider": "graph",
|
||||
"graph": item,
|
||||
},
|
||||
"metadata": {"graph": item},
|
||||
}
|
||||
|
||||
|
||||
def graph_datetime(value: Any) -> datetime:
|
||||
if not isinstance(value, dict) or not value.get("dateTime"):
|
||||
return datetime.now(timezone.utc)
|
||||
raw = str(value["dateTime"]).replace("Z", "+00:00")
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
tz_name = str(value.get("timeZone") or "UTC")
|
||||
if parsed.tzinfo is None:
|
||||
try:
|
||||
parsed = parsed.replace(tzinfo=ZoneInfo(tz_name))
|
||||
except ZoneInfoNotFoundError:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return normalize_datetime(parsed)
|
||||
|
||||
|
||||
def graph_body_text(item: dict[str, Any]) -> str | None:
|
||||
body = item.get("body")
|
||||
if isinstance(body, dict) and body.get("content"):
|
||||
return str(body["content"])
|
||||
return str(item.get("bodyPreview")) if item.get("bodyPreview") else None
|
||||
|
||||
|
||||
def graph_location_text(value: Any) -> str | None:
|
||||
if isinstance(value, dict) and value.get("displayName"):
|
||||
return str(value["displayName"])
|
||||
return None
|
||||
|
||||
|
||||
def graph_party(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
email = (
|
||||
value.get("emailAddress")
|
||||
if isinstance(value.get("emailAddress"), dict)
|
||||
else value
|
||||
)
|
||||
result = {
|
||||
"name": (
|
||||
str(email.get("name") or "")
|
||||
if isinstance(email, dict)
|
||||
else ""
|
||||
),
|
||||
"email": (
|
||||
str(email.get("address") or "")
|
||||
if isinstance(email, dict)
|
||||
else ""
|
||||
),
|
||||
}
|
||||
if value.get("type"):
|
||||
result["role"] = value["type"]
|
||||
if value.get("status"):
|
||||
result["status"] = value["status"]
|
||||
return result
|
||||
|
||||
|
||||
def graph_reminders(item: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if (
|
||||
item.get("isReminderOn")
|
||||
and item.get("reminderMinutesBeforeStart") is not None
|
||||
):
|
||||
return [
|
||||
{
|
||||
"action": "DISPLAY",
|
||||
"trigger_minutes_before": int(
|
||||
item["reminderMinutesBeforeStart"]
|
||||
),
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def normalize_datetime(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
@@ -302,6 +302,30 @@ def recurrence_id_value(value: Any | None) -> str | None:
|
||||
return f"TZID={tzid}:{raw_value}" if tzid else raw_value
|
||||
|
||||
|
||||
def recurrence_id_datetime(value: str | None) -> datetime | None:
|
||||
"""Parse the canonical/raw RECURRENCE-ID forms stored by the module."""
|
||||
|
||||
if not value:
|
||||
return None
|
||||
raw_value = value
|
||||
params: dict[str, Any] = {}
|
||||
if value.startswith("TZID=") and ":" in value:
|
||||
tzid, raw_value = value.removeprefix("TZID=").split(":", 1)
|
||||
params["TZID"] = [tzid]
|
||||
parsed = parse_ical_temporal(raw_value, params)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
try:
|
||||
return normalize_datetime(datetime.fromisoformat(raw_value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def normalized_recurrence_id(value: str | None) -> str | None:
|
||||
parsed = recurrence_id_datetime(value)
|
||||
return format_ical_datetime(parsed) if parsed is not None else value
|
||||
|
||||
|
||||
def recurrence_rule(value: Any | None) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -653,13 +677,30 @@ def occurrence_overlaps(start_at: datetime, duration: timedelta, range_start: da
|
||||
def occurrence_payload(start_at: datetime, duration: timedelta, event: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"uid": event.uid,
|
||||
"recurrence_id": format_ical_datetime(start_at),
|
||||
"recurrence_id": occurrence_recurrence_id(start_at, event),
|
||||
"start_at": start_at,
|
||||
"end_at": start_at + duration if duration else None,
|
||||
"all_day": event.all_day,
|
||||
}
|
||||
|
||||
|
||||
def occurrence_recurrence_id(start_at: datetime, event: Any) -> str:
|
||||
start_at = normalize_datetime(start_at)
|
||||
if event.all_day:
|
||||
return start_at.strftime("%Y%m%d")
|
||||
timezone_id = getattr(event, "timezone", None)
|
||||
if timezone_id:
|
||||
try:
|
||||
local_start = start_at.astimezone(ZoneInfo(timezone_id))
|
||||
return (
|
||||
f"TZID={timezone_id}:"
|
||||
f"{local_start.strftime('%Y%m%dT%H%M%S')}"
|
||||
)
|
||||
except ZoneInfoNotFoundError:
|
||||
pass
|
||||
return format_ical_datetime(start_at)
|
||||
|
||||
|
||||
def int_or_zero(value: str | None) -> int:
|
||||
try:
|
||||
return int(value or "0")
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
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_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.calendar import (
|
||||
CALENDAR_AVAILABILITY_READ_SCOPE,
|
||||
CALENDAR_EVENT_WRITE_SCOPE,
|
||||
CAPABILITY_CALENDAR_INVITATIONS,
|
||||
CAPABILITY_CALENDAR_OUTBOX,
|
||||
CAPABILITY_CALENDAR_SCHEDULING,
|
||||
)
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
@@ -21,9 +26,46 @@ from govoplan_core.core.modules import (
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
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,
|
||||
calendar_models.CalendarViewPreference,
|
||||
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:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
@@ -87,6 +129,7 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
CalendarOutboxOperation,
|
||||
CalendarSyncCredential,
|
||||
CalendarSyncSource,
|
||||
CalendarViewPreference,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -94,6 +137,7 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
|
||||
"calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(),
|
||||
"calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(),
|
||||
"calendar_view_preferences": session.query(CalendarViewPreference).filter(CalendarViewPreference.tenant_id == tenant_id).count(),
|
||||
"calendar_outbox_pending": session.query(CalendarOutboxOperation)
|
||||
.filter(
|
||||
CalendarOutboxOperation.tenant_id == tenant_id,
|
||||
@@ -119,6 +163,13 @@ def _calendar_scheduling_provider(context: ModuleContext) -> object:
|
||||
return SqlCalendarSchedulingProvider()
|
||||
|
||||
|
||||
def _calendar_invitation_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_calendar.backend.capabilities import SqlCalendarInvitationProvider
|
||||
|
||||
return SqlCalendarInvitationProvider()
|
||||
|
||||
|
||||
def _calendar_outbox_provider(context: ModuleContext) -> object:
|
||||
from govoplan_calendar.backend.outbox import (
|
||||
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
|
||||
@@ -139,10 +190,11 @@ manifest = ModuleManifest(
|
||||
name="Calendar",
|
||||
version="0.1.8",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
|
||||
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow_engine", "notifications", "dms", "connectors"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"),
|
||||
ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"),
|
||||
ModuleInterfaceProvider(name="calendar.invitations", version="0.1.0"),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
route_factory=_calendar_router,
|
||||
@@ -151,26 +203,44 @@ manifest = ModuleManifest(
|
||||
capability_factories={
|
||||
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
|
||||
CAPABILITY_CALENDAR_SCHEDULING: _calendar_scheduling_provider,
|
||||
CAPABILITY_CALENDAR_INVITATIONS: _calendar_invitation_provider,
|
||||
},
|
||||
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
|
||||
frontend=FrontendModule(
|
||||
module_id="calendar",
|
||||
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),),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="calendar.widget.upcoming",
|
||||
module_id="calendar",
|
||||
kind="section",
|
||||
label="Upcoming events widget",
|
||||
order=40,
|
||||
),
|
||||
ViewSurface(
|
||||
id="calendar.settings.preferences",
|
||||
module_id="calendar",
|
||||
kind="section",
|
||||
label="Calendar preferences",
|
||||
order=45,
|
||||
),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="calendar",
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
calendar_models.CalendarCollection,
|
||||
calendar_models.CalendarEvent,
|
||||
calendar_models.CalendarOutboxOperation,
|
||||
calendar_models.CalendarSyncCredential,
|
||||
calendar_models.CalendarSyncSource,
|
||||
label="Calendar",
|
||||
),
|
||||
retirement_provider=_calendar_retirement_provider,
|
||||
retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.",
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
@@ -180,6 +250,7 @@ manifest = ModuleManifest(
|
||||
calendar_models.CalendarOutboxOperation,
|
||||
calendar_models.CalendarSyncCredential,
|
||||
calendar_models.CalendarSyncSource,
|
||||
calendar_models.CalendarViewPreference,
|
||||
label="Calendar",
|
||||
),
|
||||
),
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
"""Add durable per-user calendar view preferences on the development track.
|
||||
|
||||
Revision ID: b02c3d4e5f60
|
||||
Revises: af1b2c3d4e5f
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_calendar.backend.migrations.versions.b02c3d4e5f60_calendar_view_preferences import (
|
||||
downgrade as _downgrade,
|
||||
)
|
||||
from govoplan_calendar.backend.migrations.versions.b02c3d4e5f60_calendar_view_preferences import (
|
||||
upgrade as _upgrade,
|
||||
)
|
||||
|
||||
|
||||
revision = "b02c3d4e5f60"
|
||||
down_revision = "af1b2c3d4e5f"
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_upgrade()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_downgrade()
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
"""Add durable per-user calendar view preferences.
|
||||
|
||||
Revision ID: b02c3d4e5f60
|
||||
Revises: af1b2c3d4e5f
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "b02c3d4e5f60"
|
||||
down_revision = "af1b2c3d4e5f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"calendar_view_preferences",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("dim_weekends", sa.Boolean(), nullable=True),
|
||||
sa.Column("dim_off_hours", sa.Boolean(), nullable=True),
|
||||
sa.Column("workday_start_hour", sa.Integer(), nullable=True),
|
||||
sa.Column("workday_end_hour", sa.Integer(), nullable=True),
|
||||
sa.Column("continuous_virtualization", sa.Boolean(), nullable=True),
|
||||
sa.Column("continuous_overscan_weeks", sa.Integer(), nullable=True),
|
||||
sa.Column("alternate_continuous_months", sa.Boolean(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f(
|
||||
"fk_calendar_view_preferences_tenant_id_core_scopes"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["access_users.id"],
|
||||
name=op.f(
|
||||
"fk_calendar_view_preferences_user_id_access_users"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_calendar_view_preferences"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"user_id",
|
||||
name="uq_calendar_view_preferences_tenant_user",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_calendar_view_preferences_tenant_id"),
|
||||
"calendar_view_preferences",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_calendar_view_preferences_user_id"),
|
||||
"calendar_view_preferences",
|
||||
["user_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_calendar_view_preferences_user_id"),
|
||||
table_name="calendar_view_preferences",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_calendar_view_preferences_tenant_id"),
|
||||
table_name="calendar_view_preferences",
|
||||
)
|
||||
op.drop_table("calendar_view_preferences")
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
"""calendar invitation correlation
|
||||
|
||||
Revision ID: c13d4e5f6071
|
||||
Revises: b02c3d4e5f60
|
||||
Create Date: 2026-07-31 18:30:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c13d4e5f6071"
|
||||
down_revision = "b02c3d4e5f60"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("calendar_events") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("correlation_key", sa.String(length=255), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("producer_module", sa.String(length=100), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"producer_resource_type", sa.String(length=100), nullable=True
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("producer_resource_id", sa.String(length=255), nullable=True)
|
||||
)
|
||||
batch_op.create_index(
|
||||
"ix_calendar_events_correlation_key", ("correlation_key",)
|
||||
)
|
||||
batch_op.create_index(
|
||||
"ix_calendar_events_producer_module", ("producer_module",)
|
||||
)
|
||||
batch_op.create_index(
|
||||
"ix_calendar_events_producer_resource_id", ("producer_resource_id",)
|
||||
)
|
||||
batch_op.create_index(
|
||||
"ix_calendar_events_tenant_correlation",
|
||||
("tenant_id", "correlation_key"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("calendar_events") as batch_op:
|
||||
batch_op.drop_index("ix_calendar_events_tenant_correlation")
|
||||
batch_op.drop_index("ix_calendar_events_producer_resource_id")
|
||||
batch_op.drop_index("ix_calendar_events_producer_module")
|
||||
batch_op.drop_index("ix_calendar_events_correlation_key")
|
||||
batch_op.drop_column("producer_resource_id")
|
||||
batch_op.drop_column("producer_resource_type")
|
||||
batch_op.drop_column("producer_module")
|
||||
batch_op.drop_column("correlation_key")
|
||||
@@ -858,15 +858,18 @@ def _fail_operation(
|
||||
session.flush()
|
||||
|
||||
|
||||
def execute_calendar_outbox_operation(
|
||||
def _lock_leased_outbox_operation(
|
||||
session: Session,
|
||||
*,
|
||||
operation_id: str,
|
||||
lease_token: str,
|
||||
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None,
|
||||
) -> CalendarOutboxOperation:
|
||||
) -> tuple[CalendarOutboxOperation, CalendarSyncSource | None]:
|
||||
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")
|
||||
source = (
|
||||
session.query(CalendarSyncSource)
|
||||
@@ -882,36 +885,86 @@ def execute_calendar_outbox_operation(
|
||||
)
|
||||
if operation.status != "in_progress" or operation.lease_token != lease_token:
|
||||
raise ValueError("Calendar outbox lease is no longer valid")
|
||||
operation_metadata = operation.metadata_ or {}
|
||||
unavailable_reason = _operation_source_unavailable_reason(operation, source)
|
||||
if unavailable_reason:
|
||||
return operation, source
|
||||
|
||||
|
||||
def _cancel_undeliverable_operation(
|
||||
session: Session,
|
||||
*,
|
||||
operation: CalendarOutboxOperation,
|
||||
source: CalendarSyncSource | None,
|
||||
reason: str,
|
||||
) -> None:
|
||||
if source is not None and source.tenant_id == operation.tenant_id:
|
||||
_fail_operation(
|
||||
session,
|
||||
operation=operation,
|
||||
source=source,
|
||||
error=unavailable_reason,
|
||||
error=reason,
|
||||
terminal_status="cancelled",
|
||||
)
|
||||
else:
|
||||
return
|
||||
operation.status = "cancelled"
|
||||
operation.completed_at = utcnow()
|
||||
operation.last_error = unavailable_reason
|
||||
operation.last_error = 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:
|
||||
|
||||
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
|
||||
|
||||
client = caldav_client_for_source(session, source)
|
||||
else:
|
||||
client = client_factory(session, source)
|
||||
if operation.operation_kind == "put":
|
||||
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 "",
|
||||
@@ -920,41 +973,48 @@ def execute_calendar_outbox_operation(
|
||||
overwrite=overwrite,
|
||||
)
|
||||
remote_etag = result.etag
|
||||
reconciled = False
|
||||
if not remote_etag:
|
||||
try:
|
||||
matched, remote_etag = _remote_matches_operation(client, operation)
|
||||
except Exception:
|
||||
matched, remote_etag = False, None
|
||||
if not matched or not remote_etag:
|
||||
if remote_etag:
|
||||
_complete_operation(
|
||||
session,
|
||||
operation=operation,
|
||||
source=source,
|
||||
remote_etag=remote_etag,
|
||||
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",
|
||||
)
|
||||
return operation
|
||||
reconciled = True
|
||||
_complete_operation(
|
||||
session,
|
||||
operation=operation,
|
||||
source=source,
|
||||
remote_etag=remote_etag,
|
||||
reconciled=reconciled,
|
||||
)
|
||||
return operation
|
||||
|
||||
|
||||
def _execute_calendar_outbox_delete(
|
||||
session: Session,
|
||||
*,
|
||||
operation: CalendarOutboxOperation,
|
||||
source: CalendarSyncSource,
|
||||
client: object,
|
||||
overwrite: bool,
|
||||
) -> None:
|
||||
if not operation.expected_etag and not overwrite:
|
||||
matched, remote_etag = _remote_matches_operation(client, operation)
|
||||
if matched:
|
||||
_complete_operation(
|
||||
remote_match = _remote_matches_operation(client, operation)
|
||||
if _complete_if_remote_match_is_usable(
|
||||
session,
|
||||
operation=operation,
|
||||
source=source,
|
||||
remote_etag=remote_etag,
|
||||
reconciled=True,
|
||||
)
|
||||
return operation
|
||||
remote_match=remote_match,
|
||||
):
|
||||
return
|
||||
_fail_operation(
|
||||
session,
|
||||
operation=operation,
|
||||
@@ -965,7 +1025,7 @@ def execute_calendar_outbox_operation(
|
||||
),
|
||||
terminal_status="conflict",
|
||||
)
|
||||
return operation
|
||||
return
|
||||
result = client.delete_object(
|
||||
operation.resource_href,
|
||||
etag=operation.expected_etag,
|
||||
@@ -978,25 +1038,28 @@ def execute_calendar_outbox_operation(
|
||||
remote_etag=result.etag,
|
||||
reconciled=result.status == 404,
|
||||
)
|
||||
return operation
|
||||
except CalDAVPreconditionFailed as exc:
|
||||
if client is None:
|
||||
_fail_operation(session, operation=operation, source=source, error=exc)
|
||||
return operation
|
||||
try:
|
||||
matched, remote_etag = _remote_matches_operation(client, operation)
|
||||
except Exception:
|
||||
_fail_operation(session, operation=operation, source=source, error=exc)
|
||||
else:
|
||||
if matched and (operation.operation_kind == "delete" or remote_etag):
|
||||
_complete_operation(
|
||||
|
||||
|
||||
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_etag=remote_etag,
|
||||
reconciled=True,
|
||||
)
|
||||
else:
|
||||
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,
|
||||
@@ -1008,24 +1071,96 @@ def execute_calendar_outbox_operation(
|
||||
),
|
||||
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(
|
||||
|
||||
|
||||
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_etag=remote_etag,
|
||||
reconciled=True,
|
||||
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
|
||||
|
||||
if source is None:
|
||||
raise ValueError("Calendar outbox source is no longer available")
|
||||
operation_metadata = operation.metadata_ or {}
|
||||
overwrite = (
|
||||
bool(operation_metadata.get("overwrite"))
|
||||
if isinstance(operation_metadata, dict)
|
||||
else False
|
||||
)
|
||||
client: object | None = None
|
||||
try:
|
||||
client = _calendar_outbox_client(
|
||||
session,
|
||||
source=source,
|
||||
client_factory=client_factory,
|
||||
)
|
||||
if operation.operation_kind == "put":
|
||||
_execute_calendar_outbox_put(
|
||||
session,
|
||||
operation=operation,
|
||||
source=source,
|
||||
client=client,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
else:
|
||||
_fail_operation(session, operation=operation, source=source, error=exc)
|
||||
_execute_calendar_outbox_delete(
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -28,9 +28,13 @@ from govoplan_calendar.backend.schemas import (
|
||||
CalendarCollectionListResponse,
|
||||
CalendarCollectionResponse,
|
||||
CalendarCollectionUpdateRequest,
|
||||
CalendarCredentialEnvelopeListResponse,
|
||||
CalendarCredentialEnvelopeResponse,
|
||||
CalendarEventCreateRequest,
|
||||
CalendarEventDeltaResponse,
|
||||
CalendarEventListResponse,
|
||||
CalendarEventOccurrenceDeleteRequest,
|
||||
CalendarEventOccurrenceUpdateRequest,
|
||||
CalendarEventResponse,
|
||||
CalendarEventUpdateRequest,
|
||||
CalendarFreeBusyRequest,
|
||||
@@ -47,6 +51,8 @@ from govoplan_calendar.backend.schemas import (
|
||||
CalendarSyncSourceSyncRequest,
|
||||
CalendarSyncSourceSyncResponse,
|
||||
CalendarSyncSourceUpdateRequest,
|
||||
CalendarViewPreferencesResponse,
|
||||
CalendarViewPreferencesUpdateRequest,
|
||||
)
|
||||
from govoplan_calendar.backend.outbox import (
|
||||
calendar_outbox_operation_response,
|
||||
@@ -61,6 +67,7 @@ from govoplan_calendar.backend.service import (
|
||||
CALENDAR_EVENT_RESOURCE,
|
||||
CALENDAR_MODULE_ID,
|
||||
CalendarError,
|
||||
available_calendar_credentials,
|
||||
caldav_source_response,
|
||||
caldav_sync_response,
|
||||
calendar_response,
|
||||
@@ -72,14 +79,17 @@ from govoplan_calendar.backend.service import (
|
||||
delete_caldav_source,
|
||||
delete_sync_source,
|
||||
delete_event,
|
||||
delete_event_occurrence,
|
||||
discover_caldav_calendars,
|
||||
event_response,
|
||||
get_calendar_view_preferences,
|
||||
get_event,
|
||||
import_ics_event,
|
||||
list_freebusy,
|
||||
list_caldav_sources,
|
||||
list_calendars,
|
||||
list_events,
|
||||
list_event_occurrences,
|
||||
list_sync_sources,
|
||||
normalize_datetime,
|
||||
sync_due_sources,
|
||||
@@ -90,12 +100,23 @@ from govoplan_calendar.backend.service import (
|
||||
update_caldav_source,
|
||||
update_sync_source,
|
||||
update_event,
|
||||
update_event_occurrence,
|
||||
update_calendar_view_preferences,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
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:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
||||
@@ -129,6 +150,25 @@ def _parse_payload_datetime(value) -> datetime | 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:
|
||||
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
|
||||
@@ -298,7 +338,14 @@ def api_delete_calendar(
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
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()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except CalendarError as exc:
|
||||
@@ -344,7 +391,14 @@ def api_update_sync_source(
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
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.refresh(source)
|
||||
return _sync_source_response(source)
|
||||
@@ -361,7 +415,13 @@ def api_delete_sync_source(
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
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()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except CalendarError as exc:
|
||||
@@ -450,7 +510,7 @@ def api_discover_caldav_calendars(
|
||||
calendars = discover_caldav_calendars(session, tenant_id=principal.tenant_id, payload=payload)
|
||||
return CalendarCalDavDiscoveryResponse(calendars=calendars)
|
||||
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)
|
||||
@@ -479,7 +539,14 @@ def api_update_caldav_source(
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
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.refresh(source)
|
||||
return _caldav_source_response(source)
|
||||
@@ -496,7 +563,13 @@ def api_delete_caldav_source(
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
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()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except CalendarError as exc:
|
||||
@@ -679,10 +752,39 @@ def api_list_events(
|
||||
calendar_id: str | None = None,
|
||||
start_at: datetime | None = Query(default=None),
|
||||
end_at: datetime | None = Query(default=None),
|
||||
expand_recurring: bool = Query(default=False),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:read")
|
||||
if expand_recurring:
|
||||
if start_at is None or end_at is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=(
|
||||
"start_at and end_at are required when expanding "
|
||||
"recurring events"
|
||||
),
|
||||
)
|
||||
try:
|
||||
occurrences = list_event_occurrences(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
calendar_id=calendar_id,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
)
|
||||
return CalendarEventListResponse(
|
||||
events=[
|
||||
CalendarEventResponse.model_validate(item)
|
||||
for item in occurrences
|
||||
]
|
||||
)
|
||||
except CalendarError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
events = list_events(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
|
||||
return CalendarEventListResponse(events=[_event_response(event) for event in events])
|
||||
|
||||
@@ -786,6 +888,70 @@ def api_update_event(
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/events/{series_event_id}/occurrence",
|
||||
response_model=CalendarEventResponse,
|
||||
)
|
||||
def api_update_event_occurrence(
|
||||
series_event_id: str,
|
||||
payload: CalendarEventOccurrenceUpdateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||
try:
|
||||
event = update_event_occurrence(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
series_event_id=series_event_id,
|
||||
payload=payload,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(event)
|
||||
return _event_response(event)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/events/{series_event_id}/occurrence",
|
||||
response_model=CalendarEventResponse,
|
||||
)
|
||||
def api_delete_event_occurrence(
|
||||
series_event_id: str,
|
||||
payload: CalendarEventOccurrenceDeleteRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_any_scope(
|
||||
principal,
|
||||
"calendar:event:delete",
|
||||
CALENDAR_EVENT_WRITE_SCOPE,
|
||||
)
|
||||
try:
|
||||
event = delete_event_occurrence(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
series_event_id=series_event_id,
|
||||
recurrence_id=payload.recurrence_id,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(event)
|
||||
return _event_response(event)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def api_delete_event(
|
||||
event_id: str,
|
||||
@@ -802,6 +968,51 @@ def api_delete_event(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/preferences/view",
|
||||
response_model=CalendarViewPreferencesResponse,
|
||||
)
|
||||
def api_get_calendar_view_preferences(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:read")
|
||||
return CalendarViewPreferencesResponse.model_validate(
|
||||
get_calendar_view_preferences(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/preferences/view",
|
||||
response_model=CalendarViewPreferencesResponse,
|
||||
)
|
||||
def api_update_calendar_view_preferences(
|
||||
payload: CalendarViewPreferencesUpdateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:read")
|
||||
try:
|
||||
response = update_calendar_view_preferences(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
payload=payload,
|
||||
)
|
||||
session.commit()
|
||||
return CalendarViewPreferencesResponse.model_validate(response)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/events/import-ics", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED)
|
||||
def api_import_ics_event(
|
||||
payload: CalendarIcsImportRequest,
|
||||
|
||||
@@ -136,6 +136,7 @@ class CalendarSyncSourceResponse(BaseModel):
|
||||
auth_type: str
|
||||
username: str | None = None
|
||||
credential_ref: str | None = None
|
||||
credential_envelope_id: str | None = None
|
||||
has_credential: bool = False
|
||||
sync_enabled: bool
|
||||
sync_interval_seconds: int
|
||||
@@ -165,6 +166,26 @@ class CalendarCalDavSourceListResponse(BaseModel):
|
||||
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):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -354,6 +375,16 @@ class CalendarEventUpdateRequest(BaseModel):
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CalendarEventOccurrenceUpdateRequest(CalendarEventUpdateRequest):
|
||||
recurrence_id: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class CalendarEventOccurrenceDeleteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
recurrence_id: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class CalendarEventResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
@@ -388,6 +419,10 @@ class CalendarEventResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
instance_id: str | None = None
|
||||
series_event_id: str | None = None
|
||||
is_occurrence: bool = False
|
||||
is_override: bool = False
|
||||
|
||||
|
||||
class CalendarEventListResponse(BaseModel):
|
||||
@@ -402,6 +437,30 @@ class CalendarEventDeltaResponse(BaseModel):
|
||||
full: bool = False
|
||||
|
||||
|
||||
class CalendarViewPreferencesUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
dim_weekends: bool | None = None
|
||||
dim_off_hours: bool | None = None
|
||||
workday_start_hour: int | None = Field(default=None, ge=0, le=23)
|
||||
workday_end_hour: int | None = Field(default=None, ge=1, le=24)
|
||||
continuous_virtualization: bool | None = None
|
||||
continuous_overscan_weeks: int | None = Field(default=None, ge=2, le=20)
|
||||
alternate_continuous_months: bool | None = None
|
||||
|
||||
|
||||
class CalendarViewPreferencesResponse(BaseModel):
|
||||
dim_weekends: bool
|
||||
dim_off_hours: bool
|
||||
workday_start_hour: int
|
||||
workday_end_hour: int
|
||||
continuous_virtualization: bool
|
||||
continuous_overscan_weeks: int
|
||||
alternate_continuous_months: bool
|
||||
overridden_fields: list[str] = Field(default_factory=list)
|
||||
defaults: dict[str, bool | int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CalendarFreeBusyRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+673
-3
@@ -4,30 +4,61 @@ import unittest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
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.manifest import manifest
|
||||
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 (
|
||||
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
|
||||
CalendarError,
|
||||
_CalDAVDiscoveryAuth,
|
||||
_caldav_discovery_client,
|
||||
_caldav_discovery_response,
|
||||
_plan_sync_source_creation,
|
||||
caldav_client_for_source,
|
||||
caldav_source_response,
|
||||
create_calendar,
|
||||
create_caldav_source,
|
||||
create_event,
|
||||
delete_caldav_source,
|
||||
delete_calendar,
|
||||
delete_event,
|
||||
discover_caldav_calendars,
|
||||
list_caldav_sources,
|
||||
list_freebusy,
|
||||
resolve_caldav_credential_ref,
|
||||
resolve_trusted_deployment_caldav_credential_ref,
|
||||
sync_caldav_source,
|
||||
sync_due_caldav_sources,
|
||||
update_caldav_source,
|
||||
update_event,
|
||||
)
|
||||
from govoplan_calendar.backend.router import _caldav_discovery_http_error
|
||||
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_tenancy.backend.db.models import Tenant
|
||||
|
||||
@@ -93,6 +124,84 @@ class FakeNotificationProvider:
|
||||
|
||||
|
||||
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:
|
||||
result = parse_multistatus(
|
||||
b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
@@ -286,6 +395,259 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
self.assertEqual(client.username, "ada")
|
||||
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:
|
||||
session = self.session()
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
@@ -309,9 +671,317 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
|
||||
credential = session.query(CalendarSyncCredential).one()
|
||||
self.assertIsNotNone(source.deleted_at)
|
||||
self.assertIsNone(source.credential_ref)
|
||||
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"), [])
|
||||
|
||||
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:
|
||||
session = self.session()
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
|
||||
@@ -5,6 +5,7 @@ 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,
|
||||
@@ -29,6 +30,22 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
|
||||
|
||||
|
||||
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/"
|
||||
|
||||
|
||||
+109
-2
@@ -3,8 +3,25 @@ from __future__ import annotations
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
|
||||
from govoplan_core.core.calendar import CalendarCapabilityError, CalendarEventRequest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||
from govoplan_calendar.backend.capabilities import (
|
||||
SqlCalendarInvitationProvider,
|
||||
SqlCalendarSchedulingProvider,
|
||||
)
|
||||
from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest
|
||||
from govoplan_calendar.backend.service import create_calendar
|
||||
from govoplan_core.core.calendar import (
|
||||
CalendarCapabilityError,
|
||||
CalendarEventRequest,
|
||||
CalendarInvitationAttendeeRequest,
|
||||
CalendarInvitationRequest,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.tenancy.scope import create_scope_tables
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
|
||||
|
||||
class CalendarSchedulingCapabilityTests(unittest.TestCase):
|
||||
@@ -23,5 +40,95 @@ class CalendarSchedulingCapabilityTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class CalendarInvitationCapabilityTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
self.calendar = create_calendar(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarCollectionCreateRequest(name="Invitations"),
|
||||
)
|
||||
self.provider = SqlCalendarInvitationProvider()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def request(self, *, summary: str = "Planning") -> CalendarInvitationRequest:
|
||||
return CalendarInvitationRequest(
|
||||
correlation_id="campaign-1:recipient-1",
|
||||
source_module="campaigns",
|
||||
source_resource_type="campaign_recipient",
|
||||
source_resource_id="recipient-1",
|
||||
calendar_id=self.calendar.id,
|
||||
summary=summary,
|
||||
start_at=datetime(2026, 8, 5, 9, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 8, 5, 10, tzinfo=timezone.utc),
|
||||
attendees=(
|
||||
CalendarInvitationAttendeeRequest(
|
||||
address="ada@example.test",
|
||||
name="Ada",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def test_upsert_and_response_reconciliation_preserve_correlation(self) -> None:
|
||||
created = self.provider.upsert_invitation(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
request=self.request(),
|
||||
)
|
||||
response = self.provider.record_response(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
correlation_id=created.correlation_id,
|
||||
attendee_address="ADA@example.test",
|
||||
participation_status="accepted",
|
||||
evidence={"transport": "ical-reply"},
|
||||
)
|
||||
updated = self.provider.upsert_invitation(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
request=self.request(summary="Updated planning"),
|
||||
)
|
||||
loaded = self.provider.get_invitation(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
correlation_id=created.correlation_id,
|
||||
)
|
||||
|
||||
self.assertEqual(created.event_id, updated.event_id)
|
||||
self.assertEqual("ACCEPTED", response.attendees[0]["params"]["PARTSTAT"][0])
|
||||
self.assertEqual("ACCEPTED", updated.attendees[0]["params"]["PARTSTAT"][0])
|
||||
self.assertEqual(updated, loaded)
|
||||
self.assertFalse(updated.recurrence_supported)
|
||||
self.assertTrue(updated.degraded_reasons)
|
||||
|
||||
def test_reply_must_match_an_existing_attendee(self) -> None:
|
||||
created = self.provider.upsert_invitation(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
request=self.request(),
|
||||
)
|
||||
with self.assertRaisesRegex(CalendarCapabilityError, "not an attendee"):
|
||||
self.provider.record_response(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
uid=created.uid,
|
||||
attendee_address="mallory@example.test",
|
||||
participation_status="DECLINED",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from govoplan_calendar.backend.ews import (
|
||||
EwsAdapterError,
|
||||
ews_find_item_body,
|
||||
parse_ews_calendar_items,
|
||||
)
|
||||
from govoplan_calendar.backend.graph import graph_event_payload
|
||||
|
||||
|
||||
class ProviderAdapterTests(unittest.TestCase):
|
||||
def test_graph_event_fixture_maps_provider_fields(self) -> None:
|
||||
payload = graph_event_payload(
|
||||
{
|
||||
"id": "graph-event-1",
|
||||
"iCalUId": "uid-1@example.test",
|
||||
"@odata.etag": 'W/"etag-1"',
|
||||
"type": "occurrence",
|
||||
"subject": "Planning",
|
||||
"body": {"content": "Agenda"},
|
||||
"start": {
|
||||
"dateTime": "2026-07-08T09:00:00",
|
||||
"timeZone": "Europe/Berlin",
|
||||
},
|
||||
"end": {
|
||||
"dateTime": "2026-07-08T10:30:00",
|
||||
"timeZone": "Europe/Berlin",
|
||||
},
|
||||
"organizer": {
|
||||
"emailAddress": {
|
||||
"name": "Ada",
|
||||
"address": "ada@example.test",
|
||||
}
|
||||
},
|
||||
"attendees": [
|
||||
{
|
||||
"type": "required",
|
||||
"emailAddress": {
|
||||
"name": "Lin",
|
||||
"address": "lin@example.test",
|
||||
},
|
||||
}
|
||||
],
|
||||
"isReminderOn": True,
|
||||
"reminderMinutesBeforeStart": 15,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(payload["uid"], "uid-1@example.test")
|
||||
self.assertEqual(payload["recurrence_id"], "graph-event-1")
|
||||
self.assertEqual(
|
||||
payload["start_at"],
|
||||
datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(payload["duration_seconds"], 5_400)
|
||||
self.assertEqual(
|
||||
payload["attendees"][0]["email"],
|
||||
"lin@example.test",
|
||||
)
|
||||
self.assertEqual(
|
||||
payload["reminders"][0]["trigger_minutes_before"],
|
||||
15,
|
||||
)
|
||||
|
||||
def test_ews_calendar_fixture_maps_item_and_escapes_mailbox(self) -> None:
|
||||
response_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
|
||||
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
||||
<s:Body><m:FindItemResponse><m:ResponseMessages>
|
||||
<m:FindItemResponseMessage ResponseClass="Success"><m:RootFolder>
|
||||
<t:Items><t:CalendarItem>
|
||||
<t:ItemId Id="ews-event-1" ChangeKey="ews-etag-1" />
|
||||
<t:Subject>EWS item</t:Subject>
|
||||
<t:Start>2026-07-08T09:00:00Z</t:Start>
|
||||
<t:End>2026-07-08T10:00:00Z</t:End>
|
||||
<t:IsAllDayEvent>false</t:IsAllDayEvent>
|
||||
<t:UID>ews-uid-1</t:UID>
|
||||
<t:RequiredAttendees><t:Attendee><t:Mailbox>
|
||||
<t:Name>Ada</t:Name>
|
||||
<t:EmailAddress>ada@example.test</t:EmailAddress>
|
||||
</t:Mailbox></t:Attendee></t:RequiredAttendees>
|
||||
</t:CalendarItem></t:Items>
|
||||
</m:RootFolder></m:FindItemResponseMessage>
|
||||
</m:ResponseMessages></m:FindItemResponse></s:Body>
|
||||
</s:Envelope>"""
|
||||
|
||||
items = parse_ews_calendar_items(response_xml)
|
||||
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0]["href"], "ews-event-1")
|
||||
self.assertEqual(items[0]["etag"], "ews-etag-1")
|
||||
self.assertEqual(
|
||||
items[0]["attendees"][0]["email"],
|
||||
"ada@example.test",
|
||||
)
|
||||
request_body = ews_find_item_body(
|
||||
start=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||
end=datetime(2026, 8, 1, tzinfo=timezone.utc),
|
||||
mailbox='ops&"<@example.test',
|
||||
)
|
||||
self.assertIn("ops&"<@example.test", request_body)
|
||||
|
||||
def test_ews_parser_rejects_invalid_xml(self) -> None:
|
||||
with self.assertRaisesRegex(EwsAdapterError, "Invalid EWS"):
|
||||
parse_ews_calendar_items("<not-closed>")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||
from govoplan_calendar.backend.db.models import CalendarEvent
|
||||
from govoplan_calendar.backend.schemas import (
|
||||
CalendarCollectionCreateRequest,
|
||||
CalendarEventCreateRequest,
|
||||
CalendarEventOccurrenceUpdateRequest,
|
||||
CalendarViewPreferencesUpdateRequest,
|
||||
)
|
||||
from govoplan_calendar.backend.service import (
|
||||
CalendarError,
|
||||
create_calendar,
|
||||
create_event,
|
||||
delete_event,
|
||||
delete_event_occurrence,
|
||||
get_calendar_view_preferences,
|
||||
list_event_occurrences,
|
||||
list_freebusy,
|
||||
update_calendar_view_preferences,
|
||||
update_event_occurrence,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.tenancy.scope import create_scope_tables
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
|
||||
|
||||
class CalendarRecurrenceAndPreferenceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.session.add(
|
||||
Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
|
||||
)
|
||||
self.calendar = create_calendar(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarCollectionCreateRequest(name="Calendar"),
|
||||
)
|
||||
self.session.flush()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def recurring_master(self) -> CalendarEvent:
|
||||
return create_event(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarEventCreateRequest(
|
||||
calendar_id=self.calendar.id,
|
||||
uid="series@example.test",
|
||||
summary="Planning",
|
||||
start_at=datetime(
|
||||
2026, 7, 8, 9, 0, tzinfo=timezone.utc
|
||||
),
|
||||
end_at=datetime(
|
||||
2026, 7, 8, 10, 0, tzinfo=timezone.utc
|
||||
),
|
||||
rrule={"FREQ": "WEEKLY", "COUNT": "3"},
|
||||
),
|
||||
)
|
||||
|
||||
def test_occurrence_override_and_cancellation_reconcile_list_and_freebusy(
|
||||
self,
|
||||
) -> None:
|
||||
master = self.recurring_master()
|
||||
override = update_event_occurrence(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
series_event_id=master.id,
|
||||
payload=CalendarEventOccurrenceUpdateRequest(
|
||||
recurrence_id="20260715T090000Z",
|
||||
summary="Moved planning",
|
||||
start_at=datetime(
|
||||
2026, 7, 15, 13, 0, tzinfo=timezone.utc
|
||||
),
|
||||
end_at=datetime(
|
||||
2026, 7, 15, 14, 30, tzinfo=timezone.utc
|
||||
),
|
||||
),
|
||||
)
|
||||
cancelled = delete_event_occurrence(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
series_event_id=master.id,
|
||||
recurrence_id="20260722T090000Z",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
events = list_event_occurrences(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(
|
||||
[event["summary"] for event in events],
|
||||
["Planning", "Moved planning"],
|
||||
)
|
||||
self.assertEqual(events[1]["id"], override.id)
|
||||
self.assertEqual(events[1]["series_event_id"], master.id)
|
||||
self.assertTrue(events[1]["is_override"])
|
||||
self.assertEqual(
|
||||
events[1]["start_at"],
|
||||
datetime(2026, 7, 15, 13, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(cancelled.status, "CANCELLED")
|
||||
|
||||
busy = list_freebusy(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(len(busy), 2)
|
||||
self.assertEqual(
|
||||
busy[1]["start_at"],
|
||||
datetime(2026, 7, 15, 13, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
def test_deleting_series_removes_its_overrides(self) -> None:
|
||||
master = self.recurring_master()
|
||||
update_event_occurrence(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
series_event_id=master.id,
|
||||
payload=CalendarEventOccurrenceUpdateRequest(
|
||||
recurrence_id="20260715T090000Z",
|
||||
summary="Override",
|
||||
),
|
||||
)
|
||||
|
||||
delete_event(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
event_id=master.id,
|
||||
)
|
||||
|
||||
active = (
|
||||
self.session.query(CalendarEvent)
|
||||
.filter(CalendarEvent.deleted_at.is_(None))
|
||||
.count()
|
||||
)
|
||||
self.assertEqual(active, 0)
|
||||
|
||||
def test_all_day_occurrence_retains_date_recurrence_id(self) -> None:
|
||||
master = create_event(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarEventCreateRequest(
|
||||
calendar_id=self.calendar.id,
|
||||
uid="all-day-series@example.test",
|
||||
summary="All-day planning",
|
||||
start_at=datetime(
|
||||
2026, 7, 8, tzinfo=timezone.utc
|
||||
),
|
||||
end_at=datetime(
|
||||
2026, 7, 9, tzinfo=timezone.utc
|
||||
),
|
||||
all_day=True,
|
||||
rrule={"FREQ": "WEEKLY", "COUNT": "2"},
|
||||
),
|
||||
)
|
||||
|
||||
events = list_event_occurrences(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(
|
||||
[event["recurrence_id"] for event in events],
|
||||
["20260708", "20260715"],
|
||||
)
|
||||
|
||||
override = update_event_occurrence(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
series_event_id=master.id,
|
||||
payload=CalendarEventOccurrenceUpdateRequest(
|
||||
recurrence_id=events[1]["recurrence_id"],
|
||||
summary="Moved all-day planning",
|
||||
),
|
||||
)
|
||||
self.assertEqual(override.recurrence_id, "20260715")
|
||||
|
||||
def test_calendar_preferences_have_defaults_and_durable_user_overrides(
|
||||
self,
|
||||
) -> None:
|
||||
defaults = get_calendar_view_preferences(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
)
|
||||
self.assertTrue(defaults["dim_weekends"])
|
||||
self.assertEqual(defaults["overridden_fields"], [])
|
||||
|
||||
updated = update_calendar_view_preferences(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
payload=CalendarViewPreferencesUpdateRequest(
|
||||
dim_weekends=False,
|
||||
workday_start_hour=8,
|
||||
workday_end_hour=18,
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
reloaded = get_calendar_view_preferences(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
self.assertEqual(updated, reloaded)
|
||||
self.assertFalse(reloaded["dim_weekends"])
|
||||
self.assertEqual(reloaded["workday_start_hour"], 8)
|
||||
self.assertEqual(reloaded["workday_end_hour"], 18)
|
||||
self.assertEqual(
|
||||
set(reloaded["overridden_fields"]),
|
||||
{"dim_weekends", "workday_start_hour", "workday_end_hour"},
|
||||
)
|
||||
|
||||
def test_calendar_preferences_reject_inverted_workday(self) -> None:
|
||||
with self.assertRaisesRegex(CalendarError, "end hour"):
|
||||
update_calendar_view_preferences(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
payload=CalendarViewPreferencesUpdateRequest(
|
||||
workday_start_hour=18,
|
||||
workday_end_hour=8,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+8
-7
@@ -14,17 +14,18 @@
|
||||
"./styles/calendar.css": "./src/styles/calendar.css"
|
||||
},
|
||||
"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": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
"vite": "^7.3.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
+107
-2
@@ -51,6 +51,10 @@ export type CalendarEvent = {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
instance_id?: string | null;
|
||||
series_event_id?: string | null;
|
||||
is_occurrence?: boolean;
|
||||
is_override?: boolean;
|
||||
};
|
||||
|
||||
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
|
||||
@@ -78,7 +82,7 @@ export type CalendarSyncSource = {
|
||||
display_name?: string | null;
|
||||
auth_type: CalendarCalDavAuthType;
|
||||
username?: string | null;
|
||||
credential_ref?: string | null;
|
||||
credential_envelope_id?: string | null;
|
||||
has_credential: boolean;
|
||||
sync_enabled: boolean;
|
||||
sync_interval_seconds: number;
|
||||
@@ -143,6 +147,26 @@ export type CalendarCalDavSourceCreatePayload = CalendarSyncSourceCreatePayload;
|
||||
export type CalendarCalDavSourceUpdatePayload = Partial<CalendarCalDavSourceCreatePayload>;
|
||||
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 = {
|
||||
password?: string | null;
|
||||
bearer_token?: string | null;
|
||||
@@ -223,6 +247,29 @@ export type CalendarEventCreatePayload = {
|
||||
};
|
||||
|
||||
export type CalendarEventUpdatePayload = Partial<CalendarEventCreatePayload>;
|
||||
export type CalendarViewPreferences = {
|
||||
dim_weekends: boolean;
|
||||
dim_off_hours: boolean;
|
||||
workday_start_hour: number;
|
||||
workday_end_hour: number;
|
||||
continuous_virtualization: boolean;
|
||||
continuous_overscan_weeks: number;
|
||||
alternate_continuous_months: boolean;
|
||||
overridden_fields: string[];
|
||||
defaults: Record<string, boolean | number>;
|
||||
};
|
||||
export type CalendarViewPreferencesUpdatePayload = Partial<
|
||||
Pick<
|
||||
CalendarViewPreferences,
|
||||
| "dim_weekends"
|
||||
| "dim_off_hours"
|
||||
| "workday_start_hour"
|
||||
| "workday_end_hour"
|
||||
| "continuous_virtualization"
|
||||
| "continuous_overscan_weeks"
|
||||
| "alternate_continuous_months"
|
||||
>
|
||||
>;
|
||||
|
||||
export function listCalendars(settings: ApiSettings): Promise<CalendarCollectionListResponse> {
|
||||
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
|
||||
@@ -255,6 +302,13 @@ export function listCalDavSources(settings: ApiSettings, params: { calendar_id?:
|
||||
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> {
|
||||
return apiFetch<CalendarCalDavDiscoveryResponse>(settings, "/api/v1/calendar/caldav/discover", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
@@ -293,12 +347,13 @@ export function syncCalDavSource(settings: ApiSettings, sourceId: string, payloa
|
||||
|
||||
export function listCalendarEvents(
|
||||
settings: ApiSettings,
|
||||
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
|
||||
params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean } = {}
|
||||
): Promise<CalendarEventListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
|
||||
if (params.start_at) search.set("start_at", params.start_at);
|
||||
if (params.end_at) search.set("end_at", params.end_at);
|
||||
if (params.expand_recurring) search.set("expand_recurring", "true");
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
|
||||
}
|
||||
@@ -317,6 +372,10 @@ export function listCalendarEventsDelta(
|
||||
return apiFetch<CalendarEventDeltaResponse>(settings, `/api/v1/calendar/events/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function getCalendarEvent(settings: ApiSettings, eventId: string): Promise<CalendarEvent> {
|
||||
return apiFetch<CalendarEvent>(settings, `/api/v1/calendar/events/${eventId}`);
|
||||
}
|
||||
|
||||
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
|
||||
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
@@ -328,3 +387,49 @@ export function updateCalendarEvent(settings: ApiSettings, eventId: string, payl
|
||||
export function deleteCalendarEvent(settings: ApiSettings, eventId: string): Promise<void> {
|
||||
return apiFetch<void>(settings, `/api/v1/calendar/events/${eventId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function updateCalendarEventOccurrence(
|
||||
settings: ApiSettings,
|
||||
seriesEventId: string,
|
||||
recurrenceId: string,
|
||||
payload: CalendarEventUpdatePayload
|
||||
): Promise<CalendarEvent> {
|
||||
return apiFetch<CalendarEvent>(
|
||||
settings,
|
||||
`/api/v1/calendar/events/${seriesEventId}/occurrence`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ ...payload, recurrence_id: recurrenceId })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCalendarEventOccurrence(
|
||||
settings: ApiSettings,
|
||||
seriesEventId: string,
|
||||
recurrenceId: string
|
||||
): Promise<CalendarEvent> {
|
||||
return apiFetch<CalendarEvent>(
|
||||
settings,
|
||||
`/api/v1/calendar/events/${seriesEventId}/occurrence`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ recurrence_id: recurrenceId })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function getCalendarViewPreferences(settings: ApiSettings): Promise<CalendarViewPreferences> {
|
||||
return apiFetch<CalendarViewPreferences>(settings, "/api/v1/calendar/preferences/view");
|
||||
}
|
||||
|
||||
export function updateCalendarViewPreferences(
|
||||
settings: ApiSettings,
|
||||
payload: CalendarViewPreferencesUpdatePayload
|
||||
): Promise<CalendarViewPreferences> {
|
||||
return apiFetch<CalendarViewPreferences>(
|
||||
settings,
|
||||
"/api/v1/calendar/preferences/view",
|
||||
{ method: "PATCH", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
DateField,
|
||||
Dialog,
|
||||
SegmentedControl,
|
||||
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 CalendarEventEditScope = "occurrence" | "series";
|
||||
type CalendarEventAdvancedPayload = Pick<
|
||||
CalendarEventCreatePayload,
|
||||
| "organizer"
|
||||
| "attendees"
|
||||
| "categories"
|
||||
| "rrule"
|
||||
| "rdate"
|
||||
| "exdate"
|
||||
| "reminders"
|
||||
| "attachments"
|
||||
| "related_to"
|
||||
| "icalendar"
|
||||
| "metadata"
|
||||
>;
|
||||
|
||||
export function CalendarEventDialog({
|
||||
calendars,
|
||||
defaultCalendarId,
|
||||
event,
|
||||
editScope,
|
||||
canChooseSeries,
|
||||
seriesLoaded,
|
||||
focusDate,
|
||||
saving,
|
||||
canWrite,
|
||||
canDelete,
|
||||
onEditScopeChange,
|
||||
onCancel,
|
||||
onSave,
|
||||
onDelete
|
||||
}: {
|
||||
calendars: CalendarCollection[];
|
||||
defaultCalendarId: string;
|
||||
event: CalendarEvent | null;
|
||||
editScope: CalendarEventEditScope;
|
||||
canChooseSeries: boolean;
|
||||
seriesLoaded: boolean;
|
||||
focusDate: Date;
|
||||
saving: boolean;
|
||||
canWrite: boolean;
|
||||
canDelete: boolean;
|
||||
onEditScopeChange: (scope: CalendarEventEditScope) => void;
|
||||
onCancel: () => void;
|
||||
onSave: (
|
||||
payload: CalendarEventCreatePayload,
|
||||
event: CalendarEvent | null,
|
||||
editScope: CalendarEventEditScope
|
||||
) => Promise<boolean>;
|
||||
onDelete: (
|
||||
event: CalendarEvent,
|
||||
editScope: CalendarEventEditScope
|
||||
) => 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, editScope);
|
||||
}
|
||||
|
||||
function submit(formEvent: FormEvent<HTMLFormElement>) {
|
||||
formEvent.preventDefault();
|
||||
void saveCurrent();
|
||||
}
|
||||
|
||||
function requestDelete() {
|
||||
if (!event) return;
|
||||
if (!confirmingDelete) {
|
||||
setConfirmingDelete(true);
|
||||
return;
|
||||
}
|
||||
void onDelete(event, editScope);
|
||||
}
|
||||
|
||||
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}>
|
||||
{canChooseSeries && (
|
||||
<SegmentedControl
|
||||
ariaLabel="Recurring event edit scope"
|
||||
value={editScope}
|
||||
onChange={onEditScopeChange}
|
||||
width="fill"
|
||||
size="equal"
|
||||
disabled={saving}
|
||||
options={[
|
||||
{ id: "occurrence", label: "This occurrence" },
|
||||
{
|
||||
id: "series",
|
||||
label: seriesLoaded ? "Whole series" : "Loading series",
|
||||
disabled: !seriesLoaded
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{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
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Save } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
ToggleSwitch,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
getCalendarViewPreferences,
|
||||
updateCalendarViewPreferences,
|
||||
type CalendarViewPreferences
|
||||
} from "../../api/calendar";
|
||||
|
||||
type CalendarPreferenceDraft = Pick<
|
||||
CalendarViewPreferences,
|
||||
| "dim_weekends"
|
||||
| "dim_off_hours"
|
||||
| "workday_start_hour"
|
||||
| "workday_end_hour"
|
||||
| "continuous_virtualization"
|
||||
| "continuous_overscan_weeks"
|
||||
| "alternate_continuous_months"
|
||||
>;
|
||||
|
||||
const FALLBACK_DRAFT: CalendarPreferenceDraft = {
|
||||
dim_weekends: true,
|
||||
dim_off_hours: true,
|
||||
workday_start_hour: 6,
|
||||
workday_end_hour: 20,
|
||||
continuous_virtualization: true,
|
||||
continuous_overscan_weeks: 6,
|
||||
alternate_continuous_months: true
|
||||
};
|
||||
|
||||
export default function CalendarSettingsPanel({
|
||||
settings,
|
||||
auth
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState<CalendarPreferenceDraft | null>(null);
|
||||
const [draft, setDraft] = useState<CalendarPreferenceDraft>(FALLBACK_DRAFT);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [tone, setTone] = useState<"success" | "warning">("success");
|
||||
const dirty = useMemo(
|
||||
() => loaded !== null && JSON.stringify(loaded) !== JSON.stringify(draft),
|
||||
[draft, loaded]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPreferences();
|
||||
}, [auth.user.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
async function loadPreferences() {
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const response = await getCalendarViewPreferences(settings);
|
||||
const next = preferenceDraft(response);
|
||||
setLoaded(next);
|
||||
setDraft(next);
|
||||
} catch (error) {
|
||||
setTone("warning");
|
||||
setMessage(errorText(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePreferences() {
|
||||
if (draft.workday_end_hour <= draft.workday_start_hour) {
|
||||
setTone("warning");
|
||||
setMessage("Workday end must be after workday start.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const response = await updateCalendarViewPreferences(settings, draft);
|
||||
const next = preferenceDraft(response);
|
||||
setLoaded(next);
|
||||
setDraft(next);
|
||||
setTone("success");
|
||||
setMessage("Calendar preferences saved.");
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("govoplan:calendar-preferences-changed")
|
||||
);
|
||||
} catch (error) {
|
||||
setTone("warning");
|
||||
setMessage(errorText(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const disabled = loading || saving;
|
||||
return (
|
||||
<div className="dashboard-grid settings-dashboard-grid calendar-settings-panel">
|
||||
<Card title="Calendar display">
|
||||
<div className="form-grid">
|
||||
<ToggleSwitch
|
||||
label="Dim weekends"
|
||||
help="Use a quieter background for Saturday and Sunday in week views."
|
||||
checked={draft.dim_weekends}
|
||||
disabled={disabled}
|
||||
onChange={(dim_weekends) =>
|
||||
setDraft((current) => ({ ...current, dim_weekends }))
|
||||
}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="Dim hours outside the workday"
|
||||
checked={draft.dim_off_hours}
|
||||
disabled={disabled}
|
||||
onChange={(dim_off_hours) =>
|
||||
setDraft((current) => ({ ...current, dim_off_hours }))
|
||||
}
|
||||
/>
|
||||
<div className="calendar-dialog-grid-two">
|
||||
<FormField label="Workday starts">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
value={draft.workday_start_hour}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
workday_start_hour: Number(event.target.value)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Workday ends">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
value={draft.workday_end_hour}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
workday_end_hour: Number(event.target.value)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Continuous view">
|
||||
<div className="form-grid">
|
||||
<ToggleSwitch
|
||||
label="Virtualize distant weeks"
|
||||
help="Keep the continuous calendar responsive by rendering only nearby weeks."
|
||||
checked={draft.continuous_virtualization}
|
||||
disabled={disabled}
|
||||
onChange={(continuous_virtualization) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
continuous_virtualization
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<FormField
|
||||
label="Overscan weeks"
|
||||
help="Additional weeks rendered above and below the viewport."
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={20}
|
||||
value={draft.continuous_overscan_weeks}
|
||||
disabled={disabled || !draft.continuous_virtualization}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
continuous_overscan_weeks: Number(event.target.value)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<ToggleSwitch
|
||||
label="Alternate month backgrounds"
|
||||
checked={draft.alternate_continuous_months}
|
||||
disabled={disabled}
|
||||
onChange={(alternate_continuous_months) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
alternate_continuous_months
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="button-row compact-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={disabled || !dirty}
|
||||
onClick={() => void savePreferences()}
|
||||
>
|
||||
<Save size={16} /> {saving ? "Saving" : "Save preferences"}
|
||||
</Button>
|
||||
</div>
|
||||
{message && (
|
||||
<DismissibleAlert tone={tone} resetKey={message} floating>
|
||||
{message}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function preferenceDraft(
|
||||
preferences: CalendarViewPreferences
|
||||
): CalendarPreferenceDraft {
|
||||
return {
|
||||
dim_weekends: preferences.dim_weekends,
|
||||
dim_off_hours: preferences.dim_off_hours,
|
||||
workday_start_hour: preferences.workday_start_hour,
|
||||
workday_end_hour: preferences.workday_end_hour,
|
||||
continuous_virtualization: preferences.continuous_virtualization,
|
||||
continuous_overscan_weeks: preferences.continuous_overscan_weeks,
|
||||
alternate_continuous_months: preferences.alternate_continuous_months
|
||||
};
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: "Calendar preferences could not be loaded.";
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
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,
|
||||
calendarEventInstanceId,
|
||||
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={calendarEventInstanceId(event)}
|
||||
event={event}
|
||||
color={calendarColorById.get(event.calendar_id)}
|
||||
canDrag={canWrite}
|
||||
dragging={draggingEventId === calendarEventInstanceId(event)}
|
||||
linkedHover={hoveredEventId === calendarEventInstanceId(event)}
|
||||
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={calendarEventInstanceId(event)}
|
||||
event={event}
|
||||
color={calendarColorById.get(event.calendar_id)}
|
||||
compact
|
||||
canDrag={canWrite}
|
||||
dragging={draggingEventId === calendarEventInstanceId(event)}
|
||||
linkedHover={hoveredEventId === calendarEventInstanceId(event)}
|
||||
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={calendarEventInstanceId(item.event)}
|
||||
className={[
|
||||
"calendar-timed-event",
|
||||
draggingEventId === calendarEventInstanceId(item.event) ? "is-dragging" : "",
|
||||
hoveredEventId === calendarEventInstanceId(item.event)
|
||||
? "is-linked-hover"
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
style={timedEventStyle(
|
||||
item,
|
||||
calendarColorById.get(item.event.calendar_id),
|
||||
)}
|
||||
draggable={canWrite}
|
||||
onMouseEnter={() => onEventHover(calendarEventInstanceId(item.event))}
|
||||
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(calendarEventInstanceId(item.event))}
|
||||
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(calendarEventInstanceId(event)) : undefined}
|
||||
onMouseLeave={onHover ? () => onHover("") : undefined}
|
||||
onFocus={onHover ? () => onHover(calendarEventInstanceId(event)) : 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useCallback } from "react";
|
||||
import { CalendarDays, MapPin } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
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(),
|
||||
expand_recurring: true
|
||||
});
|
||||
return [...response.events]
|
||||
.filter((event) => event.status.toUpperCase() !== "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.instance_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;
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
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";
|
||||
export const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
|
||||
dimWeekends: true,
|
||||
dimOffHours: true,
|
||||
workdayStartHour: 6,
|
||||
workdayEndHour: 20,
|
||||
continuousVirtualization: true,
|
||||
continuousOverscanWeeks: 6,
|
||||
alternateContinuousMonths: true,
|
||||
};
|
||||
|
||||
export function calendarEventInstanceId(event: CalendarEvent): string {
|
||||
return event.instance_id || event.id;
|
||||
}
|
||||
|
||||
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)}-${calendarEventInstanceId(hidden[0].event)}-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)
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,6 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
|
||||
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
|
||||
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
|
||||
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
|
||||
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
|
||||
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
|
||||
"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_mode.5a06de37": "End mode",
|
||||
"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.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.",
|
||||
@@ -228,7 +227,6 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
|
||||
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
|
||||
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
|
||||
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
|
||||
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
|
||||
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
|
||||
"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_mode.5a06de37": "End mode",
|
||||
"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.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.",
|
||||
|
||||
+97
-2
@@ -1,10 +1,19 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { CalendarPickerUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||
import type {
|
||||
CalendarPickerUiCapability,
|
||||
DashboardWidgetsUiCapability,
|
||||
PlatformWebModule,
|
||||
SettingsSectionsUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import "./styles/calendar.css";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import CalendarPicker from "./features/calendar/CalendarPicker";
|
||||
import UpcomingEventsWidget from "./features/calendar/UpcomingEventsWidget";
|
||||
|
||||
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
|
||||
const CalendarSettingsPanel = lazy(
|
||||
() => import("./features/calendar/CalendarSettingsPanel")
|
||||
);
|
||||
|
||||
const eventRead = ["calendar:event:read"];
|
||||
const translations = {
|
||||
@@ -13,6 +22,74 @@ const translations = {
|
||||
};
|
||||
|
||||
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
|
||||
const calendarSettingsSections: SettingsSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "calendar",
|
||||
surfaceId: "calendar.settings.preferences",
|
||||
label: "Calendar",
|
||||
group: "ui",
|
||||
order: 45,
|
||||
anyOf: eventRead,
|
||||
render: ({ settings, auth }) =>
|
||||
createElement(CalendarSettingsPanel, { settings, auth })
|
||||
}
|
||||
]
|
||||
};
|
||||
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 = {
|
||||
id: "calendar",
|
||||
@@ -21,11 +98,29 @@ export const calendarModule: PlatformWebModule = {
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
|
||||
translations,
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "calendar.widget.upcoming",
|
||||
moduleId: "calendar",
|
||||
kind: "section",
|
||||
label: "Upcoming events widget",
|
||||
order: 40
|
||||
},
|
||||
{
|
||||
id: "calendar.settings.preferences",
|
||||
moduleId: "calendar",
|
||||
kind: "section",
|
||||
label: "Calendar preferences",
|
||||
order: 45
|
||||
}
|
||||
],
|
||||
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
|
||||
routes: [
|
||||
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
|
||||
uiCapabilities: {
|
||||
"calendar.picker": calendarPicker
|
||||
"calendar.picker": calendarPicker,
|
||||
"dashboard.widgets": calendarDashboardWidgets,
|
||||
"settings.sections": calendarSettingsSections
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -89,21 +89,6 @@
|
||||
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 {
|
||||
flex: 0 1 520px;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
@@ -250,40 +235,7 @@
|
||||
}
|
||||
|
||||
.calendar-list-actions {
|
||||
display: flex;
|
||||
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;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.calendar-sync-spin {
|
||||
|
||||
@@ -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.");
|
||||
@@ -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.");
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"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"
|
||||
],
|
||||
"react-router": [
|
||||
"../../govoplan-core/webui/node_modules/react-router/dist/development/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"../../govoplan-core/webui/src/vite-env.d.ts",
|
||||
"src/module.ts",
|
||||
"src/api/calendar.ts",
|
||||
"src/features/calendar/CalendarPage.tsx",
|
||||
"src/features/calendar/CalendarSettingsPanel.tsx",
|
||||
"src/features/calendar/UpcomingEventsWidget.tsx",
|
||||
"src/features/calendar/CalendarViews.tsx",
|
||||
"src/features/calendar/CalendarCollectionDialogs.tsx",
|
||||
"src/features/calendar/CalendarEventDialog.tsx",
|
||||
"src/features/calendar/calendarViewModel.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user