chore: sync GovOPlaN module split state
This commit is contained in:
@@ -20,7 +20,7 @@
|
|||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.6",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ class CalDAVPreconditionFailed(CalDAVError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CalDAVNotFound(CalDAVError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class CalDAVTransport(Protocol):
|
class CalDAVTransport(Protocol):
|
||||||
def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
||||||
...
|
...
|
||||||
@@ -48,6 +52,29 @@ class CalDAVWriteResult:
|
|||||||
status: int = 0
|
status: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalDAVDiscoveryCalendar:
|
||||||
|
collection_url: str
|
||||||
|
href: str
|
||||||
|
display_name: str | None = None
|
||||||
|
color: str | None = None
|
||||||
|
ctag: str | None = None
|
||||||
|
sync_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _DAVDiscoveryResponse:
|
||||||
|
href: str
|
||||||
|
display_name: str | None = None
|
||||||
|
color: str | None = None
|
||||||
|
ctag: str | None = None
|
||||||
|
sync_token: str | None = None
|
||||||
|
is_calendar: bool = False
|
||||||
|
principal_hrefs: tuple[str, ...] = ()
|
||||||
|
calendar_home_set_hrefs: tuple[str, ...] = ()
|
||||||
|
supported_components: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
class CalDAVClient:
|
class CalDAVClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -78,6 +105,96 @@ class CalDAVClient:
|
|||||||
payload = self.request("PROPFIND", self.collection_url, body=body, depth="0", expected={207})
|
payload = self.request("PROPFIND", self.collection_url, body=body, depth="0", expected={207})
|
||||||
return parse_multistatus(payload)
|
return parse_multistatus(payload)
|
||||||
|
|
||||||
|
def discover_calendars(self) -> list[CalDAVDiscoveryCalendar]:
|
||||||
|
start_url = self.collection_url
|
||||||
|
calendars: dict[str, CalDAVDiscoveryCalendar] = {}
|
||||||
|
home_urls: list[str] = []
|
||||||
|
principal_urls: list[str] = []
|
||||||
|
visited_urls: set[tuple[str, str]] = set()
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
def add_home_href(base_url: str, href: str) -> None:
|
||||||
|
url = ensure_collection_url(absolute_dav_url(base_url, href))
|
||||||
|
if url not in home_urls:
|
||||||
|
home_urls.append(url)
|
||||||
|
|
||||||
|
def add_principal_href(base_url: str, href: str) -> None:
|
||||||
|
url = ensure_collection_url(absolute_dav_url(base_url, href))
|
||||||
|
if url not in principal_urls:
|
||||||
|
principal_urls.append(url)
|
||||||
|
|
||||||
|
def add_calendar(base_url: str, response: _DAVDiscoveryResponse) -> None:
|
||||||
|
if not response.is_calendar:
|
||||||
|
return
|
||||||
|
if response.supported_components and "VEVENT" not in response.supported_components:
|
||||||
|
return
|
||||||
|
url = ensure_collection_url(absolute_dav_url(base_url, response.href or base_url))
|
||||||
|
calendars[url] = CalDAVDiscoveryCalendar(
|
||||||
|
collection_url=url,
|
||||||
|
href=response.href,
|
||||||
|
display_name=response.display_name,
|
||||||
|
color=response.color,
|
||||||
|
ctag=response.ctag,
|
||||||
|
sync_token=response.sync_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def propfind(url: str, depth: str) -> list[_DAVDiscoveryResponse]:
|
||||||
|
key = (url, depth)
|
||||||
|
if key in visited_urls:
|
||||||
|
return []
|
||||||
|
visited_urls.add(key)
|
||||||
|
return self.propfind_discovery(url, depth=depth)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for response in propfind(start_url, "0"):
|
||||||
|
add_calendar(start_url, response)
|
||||||
|
for href in response.calendar_home_set_hrefs:
|
||||||
|
add_home_href(start_url, href)
|
||||||
|
for href in response.principal_hrefs:
|
||||||
|
add_principal_href(start_url, href)
|
||||||
|
except CalDAVError as exc:
|
||||||
|
errors.append(str(exc))
|
||||||
|
|
||||||
|
for principal_url in principal_urls[:6]:
|
||||||
|
try:
|
||||||
|
for response in propfind(principal_url, "0"):
|
||||||
|
for href in response.calendar_home_set_hrefs:
|
||||||
|
add_home_href(principal_url, href)
|
||||||
|
except CalDAVError as exc:
|
||||||
|
errors.append(str(exc))
|
||||||
|
|
||||||
|
if not home_urls:
|
||||||
|
home_urls.append(start_url)
|
||||||
|
|
||||||
|
for home_url in home_urls:
|
||||||
|
try:
|
||||||
|
for response in propfind(home_url, "1"):
|
||||||
|
add_calendar(home_url, response)
|
||||||
|
except CalDAVError as exc:
|
||||||
|
errors.append(str(exc))
|
||||||
|
|
||||||
|
if not calendars and errors:
|
||||||
|
raise CalDAVError(f"CalDAV discovery did not find any calendar collections: {errors[0]}")
|
||||||
|
return sorted(calendars.values(), key=lambda item: ((item.display_name or item.collection_url).lower(), item.collection_url))
|
||||||
|
|
||||||
|
def propfind_discovery(self, url: str, *, depth: str) -> list[_DAVDiscoveryResponse]:
|
||||||
|
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
|
||||||
|
<D:prop>
|
||||||
|
<D:displayname/>
|
||||||
|
<D:current-user-principal/>
|
||||||
|
<D:principal-URL/>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:sync-token/>
|
||||||
|
<C:calendar-home-set/>
|
||||||
|
<C:supported-calendar-component-set/>
|
||||||
|
<CS:getctag/>
|
||||||
|
<ICAL:calendar-color/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>"""
|
||||||
|
payload = self.request("PROPFIND", ensure_collection_url(url), body=body, depth=depth, expected={207})
|
||||||
|
return parse_discovery_multistatus(payload)
|
||||||
|
|
||||||
def list_objects(self) -> CalDAVReportResult:
|
def list_objects(self) -> CalDAVReportResult:
|
||||||
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
@@ -182,6 +299,8 @@ class CalDAVClient:
|
|||||||
headers.update(dict(extra_headers))
|
headers.update(dict(extra_headers))
|
||||||
status, response_headers, payload = self.transport(method, url, headers, body, self.timeout)
|
status, response_headers, payload = self.transport(method, url, headers, body, self.timeout)
|
||||||
if status not in expected:
|
if status not in expected:
|
||||||
|
if status == 404:
|
||||||
|
raise CalDAVNotFound(f"{method} {url} returned HTTP {status}")
|
||||||
if status == 412:
|
if status == 412:
|
||||||
raise CalDAVPreconditionFailed(f"{method} {url} failed because the remote resource changed")
|
raise CalDAVPreconditionFailed(f"{method} {url} failed because the remote resource changed")
|
||||||
raise CalDAVError(f"{method} {url} returned HTTP {status}")
|
raise CalDAVError(f"{method} {url} returned HTTP {status}")
|
||||||
@@ -189,14 +308,16 @@ class CalDAVClient:
|
|||||||
|
|
||||||
|
|
||||||
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
||||||
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
|
|
||||||
try:
|
try:
|
||||||
|
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
return response.status, dict(response.headers.items()), response.read()
|
return response.status, dict(response.headers.items()), response.read()
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
return exc.code, dict(exc.headers.items()), exc.read()
|
return exc.code, dict(exc.headers.items()), exc.read()
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
|
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CalDAVError(f"{method} {url} failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def parse_multistatus(payload: bytes) -> CalDAVReportResult:
|
def parse_multistatus(payload: bytes) -> CalDAVReportResult:
|
||||||
@@ -232,10 +353,80 @@ def parse_multistatus(payload: bytes) -> CalDAVReportResult:
|
|||||||
return CalDAVReportResult(objects=objects, sync_token=sync_token, ctag=ctag)
|
return CalDAVReportResult(objects=objects, sync_token=sync_token, ctag=ctag)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_discovery_multistatus(payload: bytes) -> list[_DAVDiscoveryResponse]:
|
||||||
|
try:
|
||||||
|
root = ElementTree.fromstring(payload)
|
||||||
|
except ElementTree.ParseError as exc:
|
||||||
|
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
|
||||||
|
responses: list[_DAVDiscoveryResponse] = []
|
||||||
|
for response in child_elements(root, "response"):
|
||||||
|
href = first_child_text(response, "href")
|
||||||
|
if not href:
|
||||||
|
continue
|
||||||
|
display_name = None
|
||||||
|
color = None
|
||||||
|
ctag = None
|
||||||
|
sync_token = None
|
||||||
|
is_calendar = False
|
||||||
|
principal_hrefs: list[str] = []
|
||||||
|
calendar_home_set_hrefs: list[str] = []
|
||||||
|
supported_components: list[str] = []
|
||||||
|
for propstat in child_elements(response, "propstat"):
|
||||||
|
status = first_child_text(propstat, "status") or ""
|
||||||
|
if status and " 200 " not in status and not status.endswith(" 200") and " 207 " not in status:
|
||||||
|
continue
|
||||||
|
prop = first_child(propstat, "prop")
|
||||||
|
if prop is None:
|
||||||
|
continue
|
||||||
|
for item in prop:
|
||||||
|
name = local_name(item.tag)
|
||||||
|
if name == "displayname" and item.text:
|
||||||
|
display_name = item.text.strip() or display_name
|
||||||
|
elif name == "calendar-color" and item.text:
|
||||||
|
color = item.text.strip() or color
|
||||||
|
elif name == "getctag" and item.text:
|
||||||
|
ctag = item.text.strip() or ctag
|
||||||
|
elif name == "sync-token" and item.text:
|
||||||
|
sync_token = item.text.strip() or sync_token
|
||||||
|
elif name == "resourcetype":
|
||||||
|
is_calendar = is_calendar or any(local_name(child.tag) == "calendar" for child in item)
|
||||||
|
elif name in {"current-user-principal", "principal-URL"}:
|
||||||
|
principal_hrefs.extend(nested_href_texts(item))
|
||||||
|
elif name == "calendar-home-set":
|
||||||
|
calendar_home_set_hrefs.extend(nested_href_texts(item))
|
||||||
|
elif name == "supported-calendar-component-set":
|
||||||
|
for component in item:
|
||||||
|
if local_name(component.tag) == "comp":
|
||||||
|
component_name = (component.attrib.get("name") or "").strip().upper()
|
||||||
|
if component_name:
|
||||||
|
supported_components.append(component_name)
|
||||||
|
responses.append(
|
||||||
|
_DAVDiscoveryResponse(
|
||||||
|
href=href,
|
||||||
|
display_name=display_name,
|
||||||
|
color=color,
|
||||||
|
ctag=ctag,
|
||||||
|
sync_token=sync_token,
|
||||||
|
is_calendar=is_calendar,
|
||||||
|
principal_hrefs=tuple(dict.fromkeys(principal_hrefs)),
|
||||||
|
calendar_home_set_hrefs=tuple(dict.fromkeys(calendar_home_set_hrefs)),
|
||||||
|
supported_components=tuple(dict.fromkeys(supported_components)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return responses
|
||||||
|
|
||||||
|
|
||||||
def ensure_collection_url(value: str) -> str:
|
def ensure_collection_url(value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if value and "://" not in value and not value.startswith("/"):
|
||||||
|
value = f"https://{value}"
|
||||||
return value if value.endswith("/") else f"{value}/"
|
return value if value.endswith("/") else f"{value}/"
|
||||||
|
|
||||||
|
|
||||||
|
def absolute_dav_url(base_url: str, href: str) -> str:
|
||||||
|
return urllib.parse.urljoin(ensure_collection_url(base_url), href)
|
||||||
|
|
||||||
|
|
||||||
def strip_weak_etag(value: str | None) -> str | None:
|
def strip_weak_etag(value: str | None) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -271,5 +462,13 @@ def child_elements(element: ElementTree.Element, name: str) -> list[ElementTree.
|
|||||||
return [child for child in element if local_name(child.tag) == name]
|
return [child for child in element if local_name(child.tag) == name]
|
||||||
|
|
||||||
|
|
||||||
|
def nested_href_texts(element: ElementTree.Element) -> list[str]:
|
||||||
|
hrefs: list[str] = []
|
||||||
|
for child in element.iter():
|
||||||
|
if local_name(child.tag) == "href" and child.text and child.text.strip():
|
||||||
|
hrefs.append(child.text.strip())
|
||||||
|
return hrefs
|
||||||
|
|
||||||
|
|
||||||
def local_name(tag: str) -> str:
|
def local_name(tag: str) -> str:
|
||||||
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class CalendarCollection(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -39,7 +39,7 @@ class CalendarCollection(Base, TimestampMixin):
|
|||||||
owner_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
owner_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
visibility: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
|
visibility: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
|
||||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ class CalendarEvent(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), 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)
|
uid: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
recurrence_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)
|
||||||
@@ -86,8 +86,8 @@ class CalendarEvent(Base, TimestampMixin):
|
|||||||
etag: Mapped[str | None] = mapped_column(String(255), index=True)
|
etag: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||||
icalendar: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
icalendar: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
raw_ics: Mapped[str | None] = mapped_column(Text)
|
raw_ics: Mapped[str | None] = mapped_column(Text)
|
||||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ class CalendarSyncSource(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), nullable=False, index=True)
|
calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
source_kind: Mapped[str] = mapped_column(String(30), default="caldav", nullable=False, index=True)
|
source_kind: Mapped[str] = mapped_column(String(30), default="caldav", nullable=False, index=True)
|
||||||
collection_url: Mapped[str] = mapped_column(String(1000), nullable=False)
|
collection_url: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||||
@@ -142,11 +142,11 @@ class CalendarSyncCredential(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
credential_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
credential_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
label: Mapped[str | None] = mapped_column(String(255))
|
label: Mapped[str | None] = mapped_column(String(255))
|
||||||
secret_encrypted: Mapped[str | None] = mapped_column(Text)
|
secret_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ def parse_vevent_component(calendar: Calendar, event: Event, *, raw_ics: str) ->
|
|||||||
properties = component_property_records(event)
|
properties = component_property_records(event)
|
||||||
alarms = [component_alarm_record(alarm) for alarm in event.subcomponents if alarm.name == "VALARM"]
|
alarms = [component_alarm_record(alarm) for alarm in event.subcomponents if alarm.name == "VALARM"]
|
||||||
standard = standard_property_index(properties)
|
standard = standard_property_index(properties)
|
||||||
|
if dtend is None and duration_seconds is not None:
|
||||||
|
standard["uses_duration"] = True
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"uid": uid,
|
"uid": uid,
|
||||||
@@ -154,7 +156,9 @@ def event_to_component(event: Any) -> Event:
|
|||||||
component.add("uid", event.uid)
|
component.add("uid", event.uid)
|
||||||
component.add("dtstamp", datetime.now(timezone.utc))
|
component.add("dtstamp", datetime.now(timezone.utc))
|
||||||
component.add("dtstart", event_temporal_value(event.start_at, all_day=event.all_day, timezone_id=event.timezone))
|
component.add("dtstart", event_temporal_value(event.start_at, all_day=event.all_day, timezone_id=event.timezone))
|
||||||
if event.end_at is not None:
|
if event_uses_duration(event):
|
||||||
|
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
|
||||||
|
elif event.end_at is not None:
|
||||||
component.add("dtend", event_temporal_value(event.end_at, all_day=event.all_day, timezone_id=event.timezone))
|
component.add("dtend", event_temporal_value(event.end_at, all_day=event.all_day, timezone_id=event.timezone))
|
||||||
elif getattr(event, "duration_seconds", None) is not None:
|
elif getattr(event, "duration_seconds", None) is not None:
|
||||||
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
|
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
|
||||||
@@ -474,6 +478,16 @@ def metadata_value(event: Any, key: str) -> str | None:
|
|||||||
return str(value) if value else None
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def event_uses_duration(event: Any) -> bool:
|
||||||
|
if getattr(event, "duration_seconds", None) is None:
|
||||||
|
return False
|
||||||
|
raw_metadata = getattr(event, "icalendar", None)
|
||||||
|
if not isinstance(raw_metadata, dict):
|
||||||
|
return False
|
||||||
|
standard = raw_metadata.get("standard")
|
||||||
|
return isinstance(standard, dict) and standard.get("uses_duration") is True
|
||||||
|
|
||||||
|
|
||||||
def event_temporal_value(value: datetime, *, all_day: bool, timezone_id: str | None) -> date | datetime:
|
def event_temporal_value(value: datetime, *, all_day: bool, timezone_id: str | None) -> date | datetime:
|
||||||
value = normalize_datetime(value)
|
value = normalize_datetime(value)
|
||||||
if all_day:
|
if all_day:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""calendar collections and VEVENT storage
|
"""calendar collections and VEVENT storage
|
||||||
|
|
||||||
Revision ID: 7c8d9e0f1a2b
|
Revision ID: 7c8d9e0f1a2b
|
||||||
Revises: 1b2c3d4e5f70
|
Revises: 2e3f4a5b6c7d
|
||||||
Create Date: 2026-07-07 00:00:00.000000
|
Create Date: 2026-07-07 00:00:00.000000
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,7 +11,7 @@ import sqlalchemy as sa
|
|||||||
|
|
||||||
|
|
||||||
revision = "7c8d9e0f1a2b"
|
revision = "7c8d9e0f1a2b"
|
||||||
down_revision = "1b2c3d4e5f70"
|
down_revision = "2e3f4a5b6c7d"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
@@ -38,8 +38,8 @@ def upgrade() -> None:
|
|||||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name=op.f("fk_calendar_collections_created_by_user_id_users"), ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], name=op.f("fk_calendar_collections_created_by_user_id_users"), ondelete="SET NULL"),
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_collections_tenant_id_tenants"), ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], name=op.f("fk_calendar_collections_tenant_id_tenants"), ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_collections")),
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_collections")),
|
||||||
)
|
)
|
||||||
op.create_index(op.f("ix_calendar_collections_tenant_id"), "calendar_collections", ["tenant_id"], unique=False)
|
op.create_index(op.f("ix_calendar_collections_tenant_id"), "calendar_collections", ["tenant_id"], unique=False)
|
||||||
@@ -100,9 +100,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(["calendar_id"], ["calendar_collections.id"], name=op.f("fk_calendar_events_calendar_id_calendar_collections"), ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["calendar_id"], ["calendar_collections.id"], name=op.f("fk_calendar_events_calendar_id_calendar_collections"), ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name=op.f("fk_calendar_events_created_by_user_id_users"), ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], name=op.f("fk_calendar_events_created_by_user_id_users"), ondelete="SET NULL"),
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_events_tenant_id_tenants"), ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], name=op.f("fk_calendar_events_tenant_id_tenants"), ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], name=op.f("fk_calendar_events_updated_by_user_id_users"), ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["access_users.id"], name=op.f("fk_calendar_events_updated_by_user_id_users"), ondelete="SET NULL"),
|
||||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_events")),
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_events")),
|
||||||
sa.UniqueConstraint("calendar_id", "uid", "recurrence_id", name="uq_calendar_events_calendar_uid_recurrence"),
|
sa.UniqueConstraint("calendar_id", "uid", "recurrence_id", name="uq_calendar_events_calendar_uid_recurrence"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(["calendar_id"], ["calendar_collections.id"], name=op.f("fk_calendar_sync_sources_calendar_id_calendar_collections"), ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["calendar_id"], ["calendar_collections.id"], name=op.f("fk_calendar_sync_sources_calendar_id_calendar_collections"), ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_sync_sources_tenant_id_tenants"), ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], name=op.f("fk_calendar_sync_sources_tenant_id_tenants"), ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_sources")),
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_sources")),
|
||||||
)
|
)
|
||||||
for column in ("tenant_id", "calendar_id", "source_kind", "deleted_at"):
|
for column in ("tenant_id", "calendar_id", "source_kind", "deleted_at"):
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ def upgrade() -> None:
|
|||||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name=op.f("fk_calendar_sync_credentials_created_by_user_id_users"), ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], name=op.f("fk_calendar_sync_credentials_created_by_user_id_users"), ondelete="SET NULL"),
|
||||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_sync_credentials_tenant_id_tenants"), ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], name=op.f("fk_calendar_sync_credentials_tenant_id_tenants"), ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_credentials")),
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_credentials")),
|
||||||
)
|
)
|
||||||
for column in ("tenant_id", "credential_kind", "created_by_user_id", "deleted_at"):
|
for column in ("tenant_id", "credential_kind", "created_by_user_id", "deleted_at"):
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, st
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
|
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
|
||||||
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
|
from govoplan_core.core.change_sequence import decode_sequence_watermark, encode_sequence_watermark, max_sequence_id, sequence_entries_since, sequence_watermark_is_expired
|
||||||
|
from govoplan_calendar.backend.db.models import CalendarEvent
|
||||||
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified
|
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified
|
||||||
from govoplan_calendar.backend.caldav import CalDAVError
|
from govoplan_calendar.backend.caldav import CalDAVError
|
||||||
from govoplan_calendar.backend.schemas import (
|
from govoplan_calendar.backend.schemas import (
|
||||||
|
CalendarCalDavDiscoveryRequest,
|
||||||
|
CalendarCalDavDiscoveryResponse,
|
||||||
CalendarCalDavDueSyncItemResponse,
|
CalendarCalDavDueSyncItemResponse,
|
||||||
CalendarCalDavDueSyncResponse,
|
CalendarCalDavDueSyncResponse,
|
||||||
CalendarCalDavSourceCreateRequest,
|
CalendarCalDavSourceCreateRequest,
|
||||||
@@ -23,24 +28,39 @@ from govoplan_calendar.backend.schemas import (
|
|||||||
CalendarCollectionResponse,
|
CalendarCollectionResponse,
|
||||||
CalendarCollectionUpdateRequest,
|
CalendarCollectionUpdateRequest,
|
||||||
CalendarEventCreateRequest,
|
CalendarEventCreateRequest,
|
||||||
|
CalendarEventDeltaResponse,
|
||||||
CalendarEventListResponse,
|
CalendarEventListResponse,
|
||||||
CalendarEventResponse,
|
CalendarEventResponse,
|
||||||
CalendarEventUpdateRequest,
|
CalendarEventUpdateRequest,
|
||||||
CalendarFreeBusyRequest,
|
CalendarFreeBusyRequest,
|
||||||
CalendarFreeBusyResponse,
|
CalendarFreeBusyResponse,
|
||||||
CalendarIcsImportRequest,
|
CalendarIcsImportRequest,
|
||||||
|
CalendarSyncDueSyncItemResponse,
|
||||||
|
CalendarSyncDueSyncResponse,
|
||||||
|
CalendarSyncSourceCreateRequest,
|
||||||
|
CalendarSyncSourceListResponse,
|
||||||
|
CalendarSyncSourceResponse,
|
||||||
|
CalendarSyncSourceSyncRequest,
|
||||||
|
CalendarSyncSourceSyncResponse,
|
||||||
|
CalendarSyncSourceUpdateRequest,
|
||||||
)
|
)
|
||||||
from govoplan_calendar.backend.service import (
|
from govoplan_calendar.backend.service import (
|
||||||
|
CALENDAR_EVENTS_COLLECTION,
|
||||||
|
CALENDAR_EVENT_RESOURCE,
|
||||||
|
CALENDAR_MODULE_ID,
|
||||||
CalendarError,
|
CalendarError,
|
||||||
caldav_source_response,
|
caldav_source_response,
|
||||||
caldav_sync_response,
|
caldav_sync_response,
|
||||||
calendar_response,
|
calendar_response,
|
||||||
create_calendar,
|
create_calendar,
|
||||||
create_caldav_source,
|
create_caldav_source,
|
||||||
|
create_sync_source,
|
||||||
create_event,
|
create_event,
|
||||||
delete_calendar,
|
delete_calendar,
|
||||||
delete_caldav_source,
|
delete_caldav_source,
|
||||||
|
delete_sync_source,
|
||||||
delete_event,
|
delete_event,
|
||||||
|
discover_caldav_calendars,
|
||||||
event_response,
|
event_response,
|
||||||
get_caldav_source,
|
get_caldav_source,
|
||||||
get_event,
|
get_event,
|
||||||
@@ -49,10 +69,15 @@ from govoplan_calendar.backend.service import (
|
|||||||
list_caldav_sources,
|
list_caldav_sources,
|
||||||
list_calendars,
|
list_calendars,
|
||||||
list_events,
|
list_events,
|
||||||
|
list_sync_sources,
|
||||||
|
normalize_datetime,
|
||||||
|
sync_due_sources,
|
||||||
sync_due_caldav_sources,
|
sync_due_caldav_sources,
|
||||||
sync_caldav_source,
|
sync_caldav_source,
|
||||||
|
sync_source,
|
||||||
update_calendar,
|
update_calendar,
|
||||||
update_caldav_source,
|
update_caldav_source,
|
||||||
|
update_sync_source,
|
||||||
update_event,
|
update_event,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
@@ -78,10 +103,123 @@ def _event_response(event) -> CalendarEventResponse:
|
|||||||
return CalendarEventResponse.model_validate(event_response(event))
|
return CalendarEventResponse.model_validate(event_response(event))
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_event_watermark(session: Session, tenant_id: str) -> str:
|
||||||
|
return encode_sequence_watermark(
|
||||||
|
max_sequence_id(session, tenant_id=tenant_id, module_id=CALENDAR_MODULE_ID, collections=(CALENDAR_EVENTS_COLLECTION,))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_payload_datetime(value) -> datetime | None:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
if event_start is None:
|
||||||
|
return False
|
||||||
|
if start_at is not None and event_end is not None and event_end < normalize_datetime(start_at):
|
||||||
|
return False
|
||||||
|
if end_at is not None and event_start > normalize_datetime(end_at):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _event_payload_matches_window(entry, *, calendar_id: str | None, start_at: datetime | None, end_at: datetime | None) -> bool:
|
||||||
|
payload = entry.payload or {}
|
||||||
|
current_calendar = payload.get("calendar_id")
|
||||||
|
previous_calendar = payload.get("previous_calendar_id")
|
||||||
|
current_calendar_matches = calendar_id is None or current_calendar == calendar_id
|
||||||
|
previous_calendar_matches = calendar_id is None or previous_calendar == calendar_id
|
||||||
|
return (
|
||||||
|
current_calendar_matches and _event_interval_overlaps(payload, prefix="", start_at=start_at, end_at=end_at)
|
||||||
|
) or (
|
||||||
|
previous_calendar_matches and _event_interval_overlaps(payload, prefix="previous_", start_at=start_at, end_at=end_at)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_events_for_delta(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
event_ids: list[str],
|
||||||
|
calendar_id: str | None,
|
||||||
|
start_at: datetime | None,
|
||||||
|
end_at: datetime | None,
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
|
if not event_ids:
|
||||||
|
return []
|
||||||
|
query = session.query(CalendarEvent).filter(
|
||||||
|
CalendarEvent.tenant_id == tenant_id,
|
||||||
|
CalendarEvent.id.in_(event_ids),
|
||||||
|
CalendarEvent.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if calendar_id:
|
||||||
|
query = query.filter(CalendarEvent.calendar_id == calendar_id)
|
||||||
|
if start_at is not None:
|
||||||
|
normalized_start = normalize_datetime(start_at)
|
||||||
|
query = query.filter((CalendarEvent.end_at.is_(None)) | (CalendarEvent.end_at >= normalized_start))
|
||||||
|
if end_at is not None:
|
||||||
|
query = query.filter(CalendarEvent.start_at <= normalize_datetime(end_at))
|
||||||
|
return query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc(), CalendarEvent.id.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def _full_event_delta_response(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
calendar_id: str | None,
|
||||||
|
start_at: datetime | None,
|
||||||
|
end_at: datetime | None,
|
||||||
|
) -> CalendarEventDeltaResponse:
|
||||||
|
events = list_events(session, tenant_id=tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
|
||||||
|
return CalendarEventDeltaResponse(
|
||||||
|
events=[_event_response(event) for event in events],
|
||||||
|
deleted=[],
|
||||||
|
watermark=_calendar_event_watermark(session, tenant_id),
|
||||||
|
has_more=False,
|
||||||
|
full=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_delta_entries(session: Session, *, tenant_id: str, since: str, limit: int):
|
||||||
|
try:
|
||||||
|
since_sequence = decode_sequence_watermark(since)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
if sequence_watermark_is_expired(
|
||||||
|
session,
|
||||||
|
since=since_sequence,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
module_id=CALENDAR_MODULE_ID,
|
||||||
|
collections=(CALENDAR_EVENTS_COLLECTION,),
|
||||||
|
):
|
||||||
|
return None, False
|
||||||
|
entries_plus_one = sequence_entries_since(
|
||||||
|
session,
|
||||||
|
since=since_sequence,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
module_id=CALENDAR_MODULE_ID,
|
||||||
|
collections=(CALENDAR_EVENTS_COLLECTION,),
|
||||||
|
limit=limit + 1,
|
||||||
|
)
|
||||||
|
has_more = len(entries_plus_one) > limit
|
||||||
|
return entries_plus_one[:limit], has_more
|
||||||
|
|
||||||
|
|
||||||
def _caldav_source_response(source) -> CalendarCalDavSourceResponse:
|
def _caldav_source_response(source) -> CalendarCalDavSourceResponse:
|
||||||
return CalendarCalDavSourceResponse.model_validate(caldav_source_response(source))
|
return CalendarCalDavSourceResponse.model_validate(caldav_source_response(source))
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_source_response(source) -> CalendarSyncSourceResponse:
|
||||||
|
return CalendarSyncSourceResponse.model_validate(caldav_source_response(source))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/calendars", response_model=CalendarCollectionListResponse)
|
@router.get("/calendars", response_model=CalendarCollectionListResponse)
|
||||||
def api_list_calendars(
|
def api_list_calendars(
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
@@ -107,7 +245,7 @@ def api_create_calendar(
|
|||||||
return _calendar_response(calendar)
|
return _calendar_response(calendar)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/calendars/{calendar_id}", response_model=CalendarCollectionResponse)
|
@router.patch("/calendars/{calendar_id}", response_model=CalendarCollectionResponse)
|
||||||
@@ -142,7 +280,124 @@ def api_delete_calendar(
|
|||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sync-sources", response_model=CalendarSyncSourceListResponse)
|
||||||
|
def api_list_sync_sources(
|
||||||
|
calendar_id: str | None = None,
|
||||||
|
source_kind: str | None = None,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "calendar:calendar:read")
|
||||||
|
sources = list_sync_sources(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, source_kind=source_kind)
|
||||||
|
return CalendarSyncSourceListResponse(sources=[_sync_source_response(source) for source in sources])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync-sources", response_model=CalendarSyncSourceResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def api_create_sync_source(
|
||||||
|
payload: CalendarSyncSourceCreateRequest,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
|
try:
|
||||||
|
source = create_sync_source(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(source)
|
||||||
|
return _sync_source_response(source)
|
||||||
|
except CalendarError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/sync-sources/{source_id}", response_model=CalendarSyncSourceResponse)
|
||||||
|
def api_update_sync_source(
|
||||||
|
source_id: str,
|
||||||
|
payload: CalendarSyncSourceUpdateRequest,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
|
try:
|
||||||
|
source = update_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(source)
|
||||||
|
return _sync_source_response(source)
|
||||||
|
except CalendarError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sync-sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def api_delete_sync_source(
|
||||||
|
source_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
|
try:
|
||||||
|
delete_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id)
|
||||||
|
session.commit()
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
except CalendarError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync-sources/{source_id}/sync", response_model=CalendarSyncSourceSyncResponse)
|
||||||
|
def api_sync_source(
|
||||||
|
source_id: str,
|
||||||
|
payload: CalendarSyncSourceSyncRequest | None = None,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "calendar:event:import")
|
||||||
|
payload = payload or CalendarSyncSourceSyncRequest()
|
||||||
|
try:
|
||||||
|
source, stats = sync_source(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
source_id=source_id,
|
||||||
|
password=payload.password,
|
||||||
|
bearer_token=payload.bearer_token,
|
||||||
|
force_full=payload.force_full,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(source)
|
||||||
|
return CalendarSyncSourceSyncResponse.model_validate(caldav_sync_response(source, stats))
|
||||||
|
except (CalendarError, CalDAVError, ICalendarError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync-sources/sync-due", response_model=CalendarSyncDueSyncResponse)
|
||||||
|
def api_sync_due_sources(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "calendar:event:import")
|
||||||
|
results = sync_due_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit)
|
||||||
|
session.commit()
|
||||||
|
return CalendarSyncDueSyncResponse(
|
||||||
|
results=[
|
||||||
|
CalendarSyncDueSyncItemResponse(
|
||||||
|
source_id=item.source_id,
|
||||||
|
calendar_id=item.calendar_id,
|
||||||
|
status=item.status,
|
||||||
|
error=item.error,
|
||||||
|
created=item.stats.created if item.stats else 0,
|
||||||
|
updated=item.stats.updated if item.stats else 0,
|
||||||
|
deleted=item.stats.deleted if item.stats else 0,
|
||||||
|
unchanged=item.stats.unchanged if item.stats else 0,
|
||||||
|
fetched=item.stats.fetched if item.stats else 0,
|
||||||
|
)
|
||||||
|
for item in results
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/caldav/sources", response_model=CalendarCalDavSourceListResponse)
|
@router.get("/caldav/sources", response_model=CalendarCalDavSourceListResponse)
|
||||||
@@ -156,6 +411,20 @@ def api_list_caldav_sources(
|
|||||||
return CalendarCalDavSourceListResponse(sources=[_caldav_source_response(source) for source in sources])
|
return CalendarCalDavSourceListResponse(sources=[_caldav_source_response(source) for source in sources])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/caldav/discover", response_model=CalendarCalDavDiscoveryResponse)
|
||||||
|
def api_discover_caldav_calendars(
|
||||||
|
payload: CalendarCalDavDiscoveryRequest,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def api_create_caldav_source(
|
def api_create_caldav_source(
|
||||||
payload: CalendarCalDavSourceCreateRequest,
|
payload: CalendarCalDavSourceCreateRequest,
|
||||||
@@ -170,7 +439,7 @@ def api_create_caldav_source(
|
|||||||
return _caldav_source_response(source)
|
return _caldav_source_response(source)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/caldav/sources/{source_id}", response_model=CalendarCalDavSourceResponse)
|
@router.patch("/caldav/sources/{source_id}", response_model=CalendarCalDavSourceResponse)
|
||||||
@@ -188,7 +457,7 @@ def api_update_caldav_source(
|
|||||||
return _caldav_source_response(source)
|
return _caldav_source_response(source)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/caldav/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/caldav/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -231,7 +500,7 @@ def api_sync_caldav_source(
|
|||||||
return CalendarCalDavSyncResponse.model_validate(caldav_sync_response(source, stats))
|
return CalendarCalDavSyncResponse.model_validate(caldav_sync_response(source, stats))
|
||||||
except (CalendarError, CalDAVError, ICalendarError) as exc:
|
except (CalendarError, CalDAVError, ICalendarError) as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/caldav/sync-due", response_model=CalendarCalDavDueSyncResponse)
|
@router.post("/caldav/sync-due", response_model=CalendarCalDavDueSyncResponse)
|
||||||
@@ -274,6 +543,57 @@ def api_list_events(
|
|||||||
return CalendarEventListResponse(events=[_event_response(event) for event in events])
|
return CalendarEventListResponse(events=[_event_response(event) for event in events])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events/delta", response_model=CalendarEventDeltaResponse)
|
||||||
|
def api_list_events_delta(
|
||||||
|
calendar_id: str | None = None,
|
||||||
|
start_at: datetime | None = Query(default=None),
|
||||||
|
end_at: datetime | None = Query(default=None),
|
||||||
|
since: str | None = None,
|
||||||
|
limit: int = Query(default=500, ge=1, le=1000),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "calendar:event:read")
|
||||||
|
if since is None:
|
||||||
|
return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
|
||||||
|
entries, has_more = _event_delta_entries(session, tenant_id=principal.tenant_id, since=since, limit=limit)
|
||||||
|
if entries is None:
|
||||||
|
return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
|
||||||
|
scoped_entries = [
|
||||||
|
entry
|
||||||
|
for entry in entries
|
||||||
|
if entry.resource_type == CALENDAR_EVENT_RESOURCE and _event_payload_matches_window(entry, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
|
||||||
|
]
|
||||||
|
changed_ids = list(dict.fromkeys(entry.resource_id for entry in scoped_entries if entry.operation != "deleted"))
|
||||||
|
visible_events = _visible_events_for_delta(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
event_ids=changed_ids,
|
||||||
|
calendar_id=calendar_id,
|
||||||
|
start_at=start_at,
|
||||||
|
end_at=end_at,
|
||||||
|
)
|
||||||
|
visible_ids = {event.id for event in visible_events}
|
||||||
|
deleted = [
|
||||||
|
DeltaDeletedItem(
|
||||||
|
id=entry.resource_id,
|
||||||
|
resource_type=entry.resource_type,
|
||||||
|
revision=encode_sequence_watermark(entry.id),
|
||||||
|
deleted_at=entry.created_at if entry.operation == "deleted" else None,
|
||||||
|
)
|
||||||
|
for entry in scoped_entries
|
||||||
|
if entry.resource_id not in visible_ids
|
||||||
|
]
|
||||||
|
watermark = encode_sequence_watermark(entries[-1].id) if has_more and entries else _calendar_event_watermark(session, principal.tenant_id)
|
||||||
|
return CalendarEventDeltaResponse(
|
||||||
|
events=[_event_response(event) for event in visible_events],
|
||||||
|
deleted=deleted,
|
||||||
|
watermark=watermark,
|
||||||
|
has_more=has_more,
|
||||||
|
full=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/events", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/events", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def api_create_event(
|
def api_create_event(
|
||||||
payload: CalendarEventCreateRequest,
|
payload: CalendarEventCreateRequest,
|
||||||
@@ -288,7 +608,7 @@ def api_create_event(
|
|||||||
return _event_response(event)
|
return _event_response(event)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events/{event_id}", response_model=CalendarEventResponse)
|
@router.get("/events/{event_id}", response_model=CalendarEventResponse)
|
||||||
@@ -319,7 +639,7 @@ def api_update_event(
|
|||||||
return _event_response(event)
|
return _event_response(event)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
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)
|
@router.delete("/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -330,7 +650,7 @@ def api_delete_event(
|
|||||||
):
|
):
|
||||||
_require_any_scope(principal, "calendar:event:delete", "calendar:event:write")
|
_require_any_scope(principal, "calendar:event:delete", "calendar:event:write")
|
||||||
try:
|
try:
|
||||||
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id)
|
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id, user_id=principal.user.id)
|
||||||
session.commit()
|
session.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
@@ -362,7 +682,7 @@ def api_import_ics_event(
|
|||||||
return _event_response(event)
|
return _event_response(event)
|
||||||
except (CalendarError, ICalendarError) as exc:
|
except (CalendarError, ICalendarError) as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events/{event_id}/ics")
|
@router.get("/events/{event_id}/ics")
|
||||||
@@ -404,4 +724,4 @@ def api_freebusy(
|
|||||||
)
|
)
|
||||||
return CalendarFreeBusyResponse(start_at=payload.start_at, end_at=payload.end_at, busy=busy)
|
return CalendarFreeBusyResponse(start_at=payload.start_at, end_at=payload.end_at, busy=busy)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||||
|
|
||||||
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
|
|
||||||
|
|
||||||
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
|
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
|
||||||
CalendarVisibility = Literal["private", "tenant", "shared", "public"]
|
CalendarVisibility = Literal["private", "tenant", "shared", "public"]
|
||||||
CalendarDeleteEventAction = Literal["delete", "move"]
|
CalendarDeleteEventAction = Literal["delete", "move"]
|
||||||
CalendarSyncAuthType = Literal["none", "basic", "bearer"]
|
CalendarSyncAuthType = Literal["none", "basic", "bearer"]
|
||||||
CalendarSyncDirection = Literal["inbound", "two_way"]
|
CalendarSyncDirection = Literal["inbound", "two_way"]
|
||||||
|
CalendarSyncSourceKind = Literal["caldav", "ics", "webcal", "graph", "ews"]
|
||||||
CalendarCalDavConflictPolicy = Literal["etag", "overwrite"]
|
CalendarCalDavConflictPolicy = Literal["etag", "overwrite"]
|
||||||
|
|
||||||
|
|
||||||
@@ -70,9 +73,10 @@ class CalendarCollectionListResponse(BaseModel):
|
|||||||
calendars: list[CalendarCollectionResponse] = Field(default_factory=list)
|
calendars: list[CalendarCollectionResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CalendarCalDavSourceCreateRequest(BaseModel):
|
class CalendarSyncSourceCreateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
source_kind: CalendarSyncSourceKind = "caldav"
|
||||||
calendar_id: str
|
calendar_id: str
|
||||||
collection_url: str = Field(min_length=1, max_length=1000)
|
collection_url: str = Field(min_length=1, max_length=1000)
|
||||||
display_name: str | None = Field(default=None, max_length=255)
|
display_name: str | None = Field(default=None, max_length=255)
|
||||||
@@ -88,7 +92,11 @@ class CalendarCalDavSourceCreateRequest(BaseModel):
|
|||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class CalendarCalDavSourceUpdateRequest(BaseModel):
|
class CalendarCalDavSourceCreateRequest(CalendarSyncSourceCreateRequest):
|
||||||
|
source_kind: Literal["caldav"] = "caldav"
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSyncSourceUpdateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
calendar_id: str | None = None
|
calendar_id: str | None = None
|
||||||
@@ -108,7 +116,11 @@ class CalendarCalDavSourceUpdateRequest(BaseModel):
|
|||||||
metadata: dict[str, Any] | None = None
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class CalendarCalDavSourceResponse(BaseModel):
|
class CalendarCalDavSourceUpdateRequest(CalendarSyncSourceUpdateRequest):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSyncSourceResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
calendar_id: str
|
calendar_id: str
|
||||||
@@ -135,11 +147,44 @@ class CalendarCalDavSourceResponse(BaseModel):
|
|||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarCalDavSourceResponse(CalendarSyncSourceResponse):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSyncSourceListResponse(BaseModel):
|
||||||
|
sources: list[CalendarSyncSourceResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CalendarCalDavSourceListResponse(BaseModel):
|
class CalendarCalDavSourceListResponse(BaseModel):
|
||||||
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
|
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CalendarCalDavSyncRequest(BaseModel):
|
class CalendarCalDavDiscoveryRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
url: str = Field(min_length=1, max_length=1000)
|
||||||
|
source_id: str | None = None
|
||||||
|
auth_type: CalendarSyncAuthType | None = None
|
||||||
|
username: str | None = Field(default=None, max_length=255)
|
||||||
|
credential_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
password: SecretStr | None = None
|
||||||
|
bearer_token: SecretStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarCalDavDiscoveryCalendarResponse(BaseModel):
|
||||||
|
collection_url: str
|
||||||
|
href: str
|
||||||
|
display_name: str | None = None
|
||||||
|
color: str | None = None
|
||||||
|
ctag: str | None = None
|
||||||
|
sync_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarCalDavDiscoveryResponse(BaseModel):
|
||||||
|
calendars: list[CalendarCalDavDiscoveryCalendarResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSyncSourceSyncRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
@@ -147,8 +192,12 @@ class CalendarCalDavSyncRequest(BaseModel):
|
|||||||
force_full: bool = False
|
force_full: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CalendarCalDavSyncResponse(BaseModel):
|
class CalendarCalDavSyncRequest(CalendarSyncSourceSyncRequest):
|
||||||
source: CalendarCalDavSourceResponse
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSyncSourceSyncResponse(BaseModel):
|
||||||
|
source: CalendarSyncSourceResponse
|
||||||
created: int = 0
|
created: int = 0
|
||||||
updated: int = 0
|
updated: int = 0
|
||||||
deleted: int = 0
|
deleted: int = 0
|
||||||
@@ -161,7 +210,11 @@ class CalendarCalDavSyncResponse(BaseModel):
|
|||||||
errors: list[str] = Field(default_factory=list)
|
errors: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CalendarCalDavDueSyncItemResponse(BaseModel):
|
class CalendarCalDavSyncResponse(CalendarSyncSourceSyncResponse):
|
||||||
|
source: CalendarCalDavSourceResponse
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSyncDueSyncItemResponse(BaseModel):
|
||||||
source_id: str
|
source_id: str
|
||||||
calendar_id: str
|
calendar_id: str
|
||||||
status: str
|
status: str
|
||||||
@@ -173,6 +226,14 @@ class CalendarCalDavDueSyncItemResponse(BaseModel):
|
|||||||
fetched: int = 0
|
fetched: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarCalDavDueSyncItemResponse(CalendarSyncDueSyncItemResponse):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSyncDueSyncResponse(BaseModel):
|
||||||
|
results: list[CalendarSyncDueSyncItemResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CalendarCalDavDueSyncResponse(BaseModel):
|
class CalendarCalDavDueSyncResponse(BaseModel):
|
||||||
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list)
|
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -283,6 +344,14 @@ class CalendarEventListResponse(BaseModel):
|
|||||||
events: list[CalendarEventResponse] = Field(default_factory=list)
|
events: list[CalendarEventResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEventDeltaResponse(BaseModel):
|
||||||
|
events: list[CalendarEventResponse] = Field(default_factory=list)
|
||||||
|
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||||
|
watermark: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
full: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CalendarFreeBusyRequest(BaseModel):
|
class CalendarFreeBusyRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,11 @@ from celery import shared_task
|
|||||||
|
|
||||||
@shared_task(name="govoplan_calendar.sync_due_caldav_sources")
|
@shared_task(name="govoplan_calendar.sync_due_caldav_sources")
|
||||||
def sync_due_caldav_sources_task(tenant_id: str | None = None, limit: int = 50) -> list[dict[str, object]]:
|
def sync_due_caldav_sources_task(tenant_id: str | None = None, limit: int = 50) -> list[dict[str, object]]:
|
||||||
from govoplan_calendar.backend.service import sync_due_caldav_sources
|
from govoplan_calendar.backend.service import sync_due_sources
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
with get_database().SessionLocal() as session:
|
with get_database().SessionLocal() as session:
|
||||||
results = sync_due_caldav_sources(session, tenant_id=tenant_id, limit=limit)
|
results = sync_due_sources(session, tenant_id=tenant_id, limit=limit)
|
||||||
session.commit()
|
session.commit()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
|
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
|
||||||
from govoplan_calendar.backend.caldav import CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
|
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
|
||||||
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
||||||
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
|
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
|
||||||
from govoplan_calendar.backend.service import (
|
from govoplan_calendar.backend.service import (
|
||||||
@@ -19,7 +19,9 @@ from govoplan_calendar.backend.service import (
|
|||||||
create_calendar,
|
create_calendar,
|
||||||
create_caldav_source,
|
create_caldav_source,
|
||||||
create_event,
|
create_event,
|
||||||
|
delete_calendar,
|
||||||
delete_event,
|
delete_event,
|
||||||
|
list_caldav_sources,
|
||||||
list_freebusy,
|
list_freebusy,
|
||||||
sync_caldav_source,
|
sync_caldav_source,
|
||||||
sync_due_caldav_sources,
|
sync_due_caldav_sources,
|
||||||
@@ -36,12 +38,14 @@ class FakeCalDAVClient:
|
|||||||
list_result: CalDAVReportResult | None = None,
|
list_result: CalDAVReportResult | None = None,
|
||||||
sync_result: CalDAVReportResult | None = None,
|
sync_result: CalDAVReportResult | None = None,
|
||||||
objects: dict[str, str] | None = None,
|
objects: dict[str, str] | None = None,
|
||||||
|
fetch_not_found: set[str] | None = None,
|
||||||
put_etags: list[str | None] | None = None,
|
put_etags: list[str | None] | None = None,
|
||||||
fail_precondition: bool = False,
|
fail_precondition: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.list_result = list_result or CalDAVReportResult()
|
self.list_result = list_result or CalDAVReportResult()
|
||||||
self.sync_result = sync_result
|
self.sync_result = sync_result
|
||||||
self.objects = objects or {}
|
self.objects = objects or {}
|
||||||
|
self.fetch_not_found = fetch_not_found or set()
|
||||||
self.put_etags = put_etags or ['"etag-written"']
|
self.put_etags = put_etags or ['"etag-written"']
|
||||||
self.fail_precondition = fail_precondition
|
self.fail_precondition = fail_precondition
|
||||||
self.fetched: list[str] = []
|
self.fetched: list[str] = []
|
||||||
@@ -61,6 +65,8 @@ class FakeCalDAVClient:
|
|||||||
|
|
||||||
def fetch_object(self, href: str) -> str:
|
def fetch_object(self, href: str) -> str:
|
||||||
self.fetched.append(href)
|
self.fetched.append(href)
|
||||||
|
if href in self.fetch_not_found:
|
||||||
|
raise CalDAVNotFound(f"GET {href} returned HTTP 404")
|
||||||
return self.objects[href]
|
return self.objects[href]
|
||||||
|
|
||||||
def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult:
|
def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult:
|
||||||
@@ -116,6 +122,104 @@ END:VCALENDAR</C:calendar-data>
|
|||||||
self.assertIn("BEGIN:VEVENT", result.objects[0].calendar_data or "")
|
self.assertIn("BEGIN:VEVENT", result.objects[0].calendar_data or "")
|
||||||
self.assertTrue(result.objects[1].deleted)
|
self.assertTrue(result.objects[1].deleted)
|
||||||
|
|
||||||
|
def test_discovery_resolves_nextcloud_dav_root_to_calendar_collections(self) -> None:
|
||||||
|
requests: list[tuple[str, str, str | None]] = []
|
||||||
|
|
||||||
|
def transport(method: str, url: str, headers: dict[str, str], body: bytes | None, timeout: int):
|
||||||
|
requests.append((method, url, headers.get("Authorization")))
|
||||||
|
if url == "https://cloud.example.test/remote.php/dav/":
|
||||||
|
return 207, {}, b"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:multistatus xmlns:D="DAV:">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/remote.php/dav/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:current-user-principal><D:href>/remote.php/dav/principals/users/ada/</D:href></D:current-user-principal>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>"""
|
||||||
|
if url == "https://cloud.example.test/remote.php/dav/principals/users/ada/":
|
||||||
|
return 207, {}, 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>/remote.php/dav/principals/users/ada/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<C:calendar-home-set><D:href>/remote.php/dav/calendars/ada/</D:href></C:calendar-home-set>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>"""
|
||||||
|
if url == "https://cloud.example.test/remote.php/dav/calendars/ada/":
|
||||||
|
return 207, {}, b"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/remote.php/dav/calendars/ada/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
<D:response>
|
||||||
|
<D:href>/remote.php/dav/calendars/ada/personal/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:displayname>Personal</D:displayname>
|
||||||
|
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
|
||||||
|
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
|
||||||
|
<ICAL:calendar-color>#44aa99ff</ICAL:calendar-color>
|
||||||
|
<CS:getctag>ctag-1</CS:getctag>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>"""
|
||||||
|
raise AssertionError(f"Unexpected {method} {url}")
|
||||||
|
|
||||||
|
client = CalDAVClient(collection_url="https://cloud.example.test/remote.php/dav", username="ada", password="secret", transport=transport)
|
||||||
|
calendars = client.discover_calendars()
|
||||||
|
|
||||||
|
self.assertEqual(len(calendars), 1)
|
||||||
|
self.assertEqual(calendars[0].collection_url, "https://cloud.example.test/remote.php/dav/calendars/ada/personal/")
|
||||||
|
self.assertEqual(calendars[0].display_name, "Personal")
|
||||||
|
self.assertEqual(calendars[0].color, "#44aa99ff")
|
||||||
|
self.assertEqual(requests[0][2], "Basic YWRhOnNlY3JldA==")
|
||||||
|
|
||||||
|
def test_discovery_accepts_host_path_url_without_scheme(self) -> None:
|
||||||
|
requested_urls: list[str] = []
|
||||||
|
|
||||||
|
def transport(method: str, url: str, headers: dict[str, str], body: bytes | None, timeout: int):
|
||||||
|
requested_urls.append(url)
|
||||||
|
return 207, {}, 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>/remote.php/dav/calendars/ada/personal/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:displayname>Personal</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>"""
|
||||||
|
|
||||||
|
client = CalDAVClient(collection_url="cloud.example.test/remote.php/dav", transport=transport)
|
||||||
|
calendars = client.discover_calendars()
|
||||||
|
|
||||||
|
self.assertEqual(requested_urls[0], "https://cloud.example.test/remote.php/dav/")
|
||||||
|
self.assertEqual(calendars[0].collection_url, "https://cloud.example.test/remote.php/dav/calendars/ada/personal/")
|
||||||
|
|
||||||
|
def test_discovery_reports_relative_url_as_caldav_error(self) -> None:
|
||||||
|
client = CalDAVClient(collection_url="/remote.php/dav")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(CalDAVError, "unknown url type"):
|
||||||
|
client.discover_calendars()
|
||||||
|
|
||||||
|
|
||||||
class CalDAVSyncTests(unittest.TestCase):
|
class CalDAVSyncTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -153,6 +257,79 @@ class CalDAVSyncTests(unittest.TestCase):
|
|||||||
self.assertEqual(client.username, "ada")
|
self.assertEqual(client.username, "ada")
|
||||||
self.assertEqual(client.password, "secret")
|
self.assertEqual(client.password, "secret")
|
||||||
|
|
||||||
|
def test_delete_calendar_retires_caldav_source_and_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/cal",
|
||||||
|
auth_type="basic",
|
||||||
|
username="ada",
|
||||||
|
password="secret",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
credential = session.query(CalendarSyncCredential).one()
|
||||||
|
self.assertIsNotNone(source.deleted_at)
|
||||||
|
self.assertIsNotNone(credential.deleted_at)
|
||||||
|
self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), [])
|
||||||
|
|
||||||
|
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"))
|
||||||
|
old_calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Old"))
|
||||||
|
old_source = create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(calendar_id=old_calendar.id, collection_url="https://dav.example.test/cal"),
|
||||||
|
)
|
||||||
|
old_calendar.deleted_at = datetime(2026, 7, 7, 12, 0, tzinfo=timezone.utc)
|
||||||
|
new_calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="New"))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
new_source = create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(calendar_id=new_calendar.id, collection_url="https://dav.example.test/cal"),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.assertIsNotNone(old_source.deleted_at)
|
||||||
|
self.assertEqual(new_source.calendar_id, new_calendar.id)
|
||||||
|
self.assertEqual([source.id for source in list_caldav_sources(session, tenant_id="tenant-1")], [new_source.id])
|
||||||
|
|
||||||
|
def test_create_source_reports_active_duplicate_as_calendar_error(self) -> None:
|
||||||
|
session = self.Session()
|
||||||
|
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||||
|
first = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="First"))
|
||||||
|
second = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Second"))
|
||||||
|
create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(calendar_id=first.id, collection_url="https://dav.example.test/cal"),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(CalendarError, "already linked"):
|
||||||
|
create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(calendar_id=second.id, collection_url="https://dav.example.test/cal"),
|
||||||
|
)
|
||||||
|
|
||||||
def test_due_sync_runs_due_sources_and_reschedules(self) -> None:
|
def test_due_sync_runs_due_sources_and_reschedules(self) -> None:
|
||||||
session = self.Session()
|
session = self.Session()
|
||||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||||
@@ -381,6 +558,57 @@ class CalDAVSyncTests(unittest.TestCase):
|
|||||||
self.assertEqual(source.sync_token, "token-2")
|
self.assertEqual(source.sync_token, "token-2")
|
||||||
self.assertIsNotNone(event.deleted_at)
|
self.assertIsNotNone(event.deleted_at)
|
||||||
|
|
||||||
|
def test_missing_resource_during_fetch_is_treated_as_remote_delete(self) -> None:
|
||||||
|
session = self.Session()
|
||||||
|
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
|
||||||
|
session.add(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.sync_token = "token-1"
|
||||||
|
event = CalendarEvent(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
uid="event-1@example.test",
|
||||||
|
recurrence_id=None,
|
||||||
|
summary="Remote",
|
||||||
|
status="CONFIRMED",
|
||||||
|
transparency="OPAQUE",
|
||||||
|
classification="PUBLIC",
|
||||||
|
start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc),
|
||||||
|
all_day=False,
|
||||||
|
attendees=[],
|
||||||
|
categories=[],
|
||||||
|
rdate=[],
|
||||||
|
exdate=[],
|
||||||
|
reminders=[],
|
||||||
|
attachments=[],
|
||||||
|
related_to=[],
|
||||||
|
source_kind="caldav",
|
||||||
|
source_href="https://dav.example.test/cal/event-1.ics",
|
||||||
|
icalendar={},
|
||||||
|
metadata_={"caldav": {"source_id": source.id}},
|
||||||
|
)
|
||||||
|
session.add(event)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
client = FakeCalDAVClient(
|
||||||
|
sync_result=CalDAVReportResult(objects=[CalDAVObject(href="/cal/event-1.ics", etag='"gone"', calendar_data=None)], sync_token="token-2"),
|
||||||
|
fetch_not_found={"/cal/event-1.ics"},
|
||||||
|
)
|
||||||
|
source, stats = sync_caldav_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id, client=client)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.assertTrue(stats.used_sync_token)
|
||||||
|
self.assertEqual(stats.deleted, 1)
|
||||||
|
self.assertEqual(source.last_status, "ok")
|
||||||
|
self.assertEqual(source.sync_token, "token-2")
|
||||||
|
self.assertIsNotNone(event.deleted_at)
|
||||||
|
|
||||||
|
|
||||||
def recurring_resource() -> str:
|
def recurring_resource() -> str:
|
||||||
return """BEGIN:VCALENDAR
|
return """BEGIN:VCALENDAR
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ END:VCALENDAR
|
|||||||
self.assertEqual(event["start_at"], datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc))
|
self.assertEqual(event["start_at"], datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc))
|
||||||
self.assertEqual(event["end_at"], datetime(2026, 7, 8, 8, 30, tzinfo=timezone.utc))
|
self.assertEqual(event["end_at"], datetime(2026, 7, 8, 8, 30, tzinfo=timezone.utc))
|
||||||
self.assertEqual(event["duration_seconds"], 5400)
|
self.assertEqual(event["duration_seconds"], 5400)
|
||||||
|
self.assertTrue(event["icalendar"]["standard"]["uses_duration"])
|
||||||
self.assertEqual(event["description"], "Line one\nLine two")
|
self.assertEqual(event["description"], "Line one\nLine two")
|
||||||
self.assertEqual(event["location"], "Room; 1")
|
self.assertEqual(event["location"], "Room; 1")
|
||||||
self.assertEqual(event["timezone"], "Europe/Berlin")
|
self.assertEqual(event["timezone"], "Europe/Berlin")
|
||||||
@@ -164,6 +165,38 @@ END:VCALENDAR
|
|||||||
self.assertIn("BEGIN:VALARM", ics)
|
self.assertIn("BEGIN:VALARM", ics)
|
||||||
self.assertIn("TRIGGER:-PT15M", ics)
|
self.assertIn("TRIGGER:-PT15M", ics)
|
||||||
|
|
||||||
|
def test_generated_ics_preserves_duration_when_source_used_duration(self) -> None:
|
||||||
|
event = SimpleNamespace(
|
||||||
|
uid="event-duration@example.test",
|
||||||
|
start_at=datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc),
|
||||||
|
end_at=datetime(2026, 7, 8, 8, 30, tzinfo=timezone.utc),
|
||||||
|
duration_seconds=5400,
|
||||||
|
all_day=False,
|
||||||
|
timezone="Europe/Berlin",
|
||||||
|
summary="Duration",
|
||||||
|
sequence=0,
|
||||||
|
status="CONFIRMED",
|
||||||
|
transparency="OPAQUE",
|
||||||
|
classification="PUBLIC",
|
||||||
|
description=None,
|
||||||
|
location=None,
|
||||||
|
categories=[],
|
||||||
|
rrule=None,
|
||||||
|
organizer=None,
|
||||||
|
attendees=[],
|
||||||
|
rdate=[],
|
||||||
|
exdate=[],
|
||||||
|
reminders=[],
|
||||||
|
attachments=[],
|
||||||
|
related_to=[],
|
||||||
|
icalendar={"standard": {"uses_duration": True}},
|
||||||
|
)
|
||||||
|
|
||||||
|
ics = event_to_ics(event)
|
||||||
|
|
||||||
|
self.assertIn("DURATION:PT1H30M", ics)
|
||||||
|
self.assertNotIn("DTEND", ics)
|
||||||
|
|
||||||
def test_expand_event_occurrences_applies_rrule_rdate_and_exdate(self) -> None:
|
def test_expand_event_occurrences_applies_rrule_rdate_and_exdate(self) -> None:
|
||||||
parsed = parse_vevent(
|
parsed = parse_vevent(
|
||||||
"""BEGIN:VCALENDAR
|
"""BEGIN:VCALENDAR
|
||||||
|
|||||||
171
tests/test_sync_sources.py
Normal file
171
tests/test_sync_sources.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
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.db.models import CalendarEvent
|
||||||
|
from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarSyncSourceCreateRequest
|
||||||
|
from govoplan_calendar.backend.service import CalendarError, create_calendar, create_event, create_sync_source, sync_source
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_tenancy.backend.db.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSyncSourceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
|
||||||
|
def session_with_calendar(self):
|
||||||
|
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"))
|
||||||
|
session.flush()
|
||||||
|
return session, calendar
|
||||||
|
|
||||||
|
def test_ics_subscription_sync_imports_events_and_blocks_local_mutation(self) -> None:
|
||||||
|
session, calendar = self.session_with_calendar()
|
||||||
|
source = create_sync_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarSyncSourceCreateRequest(
|
||||||
|
source_kind="ics",
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="https://calendar.example.test/feed.ics",
|
||||||
|
sync_direction="two_way",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ics = """BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:ics-1@example.test
|
||||||
|
DTSTART:20260708T090000Z
|
||||||
|
DTEND:20260708T100000Z
|
||||||
|
SUMMARY:ICS item
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
"""
|
||||||
|
with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {"etag": '"feed-1"'}, ics)):
|
||||||
|
refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
|
||||||
|
|
||||||
|
event = session.query(CalendarEvent).one()
|
||||||
|
self.assertEqual(refreshed.sync_direction, "inbound")
|
||||||
|
self.assertEqual(stats.created, 1)
|
||||||
|
self.assertEqual(event.source_kind, "ics")
|
||||||
|
self.assertEqual(event.summary, "ICS item")
|
||||||
|
with self.assertRaisesRegex(CalendarError, "inbound-only"):
|
||||||
|
create_event(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarEventCreateRequest(
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
summary="Local edit",
|
||||||
|
start_at=datetime(2026, 7, 8, 12, tzinfo=timezone.utc),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_graph_sync_imports_delta_events(self) -> None:
|
||||||
|
session, calendar = self.session_with_calendar()
|
||||||
|
source = create_sync_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarSyncSourceCreateRequest(
|
||||||
|
source_kind="graph",
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="me/calendar",
|
||||||
|
auth_type="bearer",
|
||||||
|
bearer_token="graph-token",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"id": "graph-event-1",
|
||||||
|
"iCalUId": "graph-uid-1",
|
||||||
|
"subject": "Graph item",
|
||||||
|
"start": {"dateTime": "2026-07-08T09:00:00", "timeZone": "UTC"},
|
||||||
|
"end": {"dateTime": "2026-07-08T10:00:00", "timeZone": "UTC"},
|
||||||
|
"@odata.etag": "graph-etag-1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/calendar/events/delta?$deltatoken=next",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {}, json.dumps(payload))) as request:
|
||||||
|
refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
|
||||||
|
|
||||||
|
event = session.query(CalendarEvent).one()
|
||||||
|
self.assertEqual(stats.created, 1)
|
||||||
|
self.assertEqual(refreshed.sync_token, payload["@odata.deltaLink"])
|
||||||
|
self.assertEqual(event.source_kind, "graph")
|
||||||
|
self.assertEqual(event.source_href, "graph-event-1")
|
||||||
|
self.assertEqual(event.summary, "Graph item")
|
||||||
|
self.assertIn("Bearer graph-token", request.call_args.kwargs["headers"]["Authorization"])
|
||||||
|
|
||||||
|
def test_ews_sync_imports_calendar_view_items(self) -> None:
|
||||||
|
session, calendar = self.session_with_calendar()
|
||||||
|
source = create_sync_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarSyncSourceCreateRequest(
|
||||||
|
source_kind="ews",
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="https://exchange.example.test/EWS/Exchange.asmx",
|
||||||
|
auth_type="basic",
|
||||||
|
username="ada",
|
||||||
|
password="secret",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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:CalendarItem>
|
||||||
|
</t:Items>
|
||||||
|
</m:RootFolder>
|
||||||
|
</m:FindItemResponseMessage>
|
||||||
|
</m:ResponseMessages>
|
||||||
|
</m:FindItemResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>"""
|
||||||
|
|
||||||
|
with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {}, response_xml)) as request:
|
||||||
|
_refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
|
||||||
|
|
||||||
|
event = session.query(CalendarEvent).one()
|
||||||
|
self.assertEqual(stats.created, 1)
|
||||||
|
self.assertEqual(event.source_kind, "ews")
|
||||||
|
self.assertEqual(event.source_href, "ews-event-1")
|
||||||
|
self.assertEqual(event.summary, "EWS item")
|
||||||
|
self.assertTrue(request.call_args.kwargs["body"].startswith("<?xml"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.6",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
|||||||
@@ -55,6 +55,117 @@ export type CalendarEvent = {
|
|||||||
|
|
||||||
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
|
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
|
||||||
export type CalendarEventListResponse = { events: CalendarEvent[] };
|
export type CalendarEventListResponse = { events: CalendarEvent[] };
|
||||||
|
export type CalendarDeltaDeletedItem = { id: string; resource_type?: string | null; revision?: string | null; deleted_at?: string | null };
|
||||||
|
export type CalendarEventDeltaResponse = {
|
||||||
|
events: CalendarEvent[];
|
||||||
|
deleted: CalendarDeltaDeletedItem[];
|
||||||
|
watermark?: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarCalDavAuthType = "none" | "basic" | "bearer";
|
||||||
|
export type CalendarCalDavSyncDirection = "inbound" | "two_way";
|
||||||
|
export type CalendarCalDavConflictPolicy = "etag" | "overwrite";
|
||||||
|
export type CalendarSyncSourceKind = "caldav" | "ics" | "webcal" | "graph" | "ews";
|
||||||
|
|
||||||
|
export type CalendarSyncSource = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
calendar_id: string;
|
||||||
|
source_kind: CalendarSyncSourceKind;
|
||||||
|
collection_url: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
auth_type: CalendarCalDavAuthType;
|
||||||
|
username?: string | null;
|
||||||
|
credential_ref?: string | null;
|
||||||
|
has_credential: boolean;
|
||||||
|
sync_enabled: boolean;
|
||||||
|
sync_interval_seconds: number;
|
||||||
|
sync_direction: CalendarCalDavSyncDirection;
|
||||||
|
conflict_policy: CalendarCalDavConflictPolicy;
|
||||||
|
sync_token?: string | null;
|
||||||
|
ctag?: string | null;
|
||||||
|
last_attempt_at?: string | null;
|
||||||
|
last_synced_at?: string | null;
|
||||||
|
next_sync_at?: string | null;
|
||||||
|
last_status?: string | null;
|
||||||
|
last_error?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarCalDavSource = CalendarSyncSource;
|
||||||
|
export type CalendarSyncSourceListResponse = { sources: CalendarSyncSource[] };
|
||||||
|
export type CalendarCalDavSourceListResponse = { sources: CalendarCalDavSource[] };
|
||||||
|
|
||||||
|
export type CalendarCalDavDiscoveryCandidate = {
|
||||||
|
collection_url: string;
|
||||||
|
href: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
ctag?: string | null;
|
||||||
|
sync_token?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarCalDavDiscoveryPayload = {
|
||||||
|
url: string;
|
||||||
|
source_id?: string | null;
|
||||||
|
auth_type?: CalendarCalDavAuthType | null;
|
||||||
|
username?: string | null;
|
||||||
|
credential_ref?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
bearer_token?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarCalDavDiscoveryResponse = { calendars: CalendarCalDavDiscoveryCandidate[] };
|
||||||
|
|
||||||
|
export type CalendarSyncSourceCreatePayload = {
|
||||||
|
source_kind?: CalendarSyncSourceKind;
|
||||||
|
calendar_id: string;
|
||||||
|
collection_url: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
auth_type?: CalendarCalDavAuthType;
|
||||||
|
username?: string | null;
|
||||||
|
credential_ref?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
bearer_token?: string | null;
|
||||||
|
sync_enabled?: boolean;
|
||||||
|
sync_interval_seconds?: number;
|
||||||
|
sync_direction?: CalendarCalDavSyncDirection;
|
||||||
|
conflict_policy?: CalendarCalDavConflictPolicy;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarCalDavSourceCreatePayload = CalendarSyncSourceCreatePayload;
|
||||||
|
|
||||||
|
export type CalendarCalDavSourceUpdatePayload = Partial<CalendarCalDavSourceCreatePayload>;
|
||||||
|
export type CalendarSyncSourceUpdatePayload = Partial<CalendarSyncSourceCreatePayload>;
|
||||||
|
|
||||||
|
export type CalendarSyncSourceSyncPayload = {
|
||||||
|
password?: string | null;
|
||||||
|
bearer_token?: string | null;
|
||||||
|
force_full?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarCalDavSyncPayload = CalendarSyncSourceSyncPayload;
|
||||||
|
|
||||||
|
export type CalendarSyncSourceSyncResponse = {
|
||||||
|
source: CalendarSyncSource;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
deleted: number;
|
||||||
|
unchanged: number;
|
||||||
|
fetched: number;
|
||||||
|
full_sync: boolean;
|
||||||
|
used_sync_token: boolean;
|
||||||
|
sync_token?: string | null;
|
||||||
|
ctag?: string | null;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarCalDavSyncResponse = CalendarSyncSourceSyncResponse;
|
||||||
|
|
||||||
export type CalendarCollectionCreatePayload = {
|
export type CalendarCollectionCreatePayload = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -79,17 +190,34 @@ export type CalendarCollectionDeletePayload = {
|
|||||||
|
|
||||||
export type CalendarEventCreatePayload = {
|
export type CalendarEventCreatePayload = {
|
||||||
calendar_id?: string | null;
|
calendar_id?: string | null;
|
||||||
|
uid?: string | null;
|
||||||
|
recurrence_id?: string | null;
|
||||||
|
sequence?: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
start_at: string;
|
|
||||||
end_at?: string | null;
|
|
||||||
all_day?: boolean;
|
|
||||||
timezone?: string | null;
|
|
||||||
status?: string;
|
status?: string;
|
||||||
transparency?: string;
|
transparency?: string;
|
||||||
classification?: string;
|
classification?: string;
|
||||||
|
start_at: string;
|
||||||
|
end_at?: string | null;
|
||||||
|
duration_seconds?: number | null;
|
||||||
|
all_day?: boolean;
|
||||||
|
timezone?: string | null;
|
||||||
|
organizer?: Record<string, unknown> | null;
|
||||||
|
attendees?: Record<string, unknown>[];
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
|
rrule?: Record<string, unknown> | null;
|
||||||
|
rdate?: Record<string, unknown>[];
|
||||||
|
exdate?: Record<string, unknown>[];
|
||||||
|
reminders?: Record<string, unknown>[];
|
||||||
|
attachments?: Record<string, unknown>[];
|
||||||
|
related_to?: Record<string, unknown>[];
|
||||||
|
source_kind?: string;
|
||||||
|
source_href?: string | null;
|
||||||
|
etag?: string | null;
|
||||||
|
icalendar?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CalendarEventUpdatePayload = Partial<CalendarEventCreatePayload>;
|
export type CalendarEventUpdatePayload = Partial<CalendarEventCreatePayload>;
|
||||||
@@ -110,6 +238,57 @@ export function deleteCalendar(settings: ApiSettings, calendarId: string, payloa
|
|||||||
return apiFetch<void>(settings, `/api/v1/calendar/calendars/${calendarId}`, { method: "DELETE", body: JSON.stringify(payload) });
|
return apiFetch<void>(settings, `/api/v1/calendar/calendars/${calendarId}`, { method: "DELETE", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listSyncSources(settings: ApiSettings, params: { calendar_id?: string; source_kind?: CalendarSyncSourceKind } = {}): Promise<CalendarSyncSourceListResponse> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
|
||||||
|
if (params.source_kind) search.set("source_kind", params.source_kind);
|
||||||
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
|
return apiFetch<CalendarSyncSourceListResponse>(settings, `/api/v1/calendar/sync-sources${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCalDavSources(settings: ApiSettings, params: { calendar_id?: string } = {}): Promise<CalendarCalDavSourceListResponse> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
|
||||||
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
|
return apiFetch<CalendarCalDavSourceListResponse>(settings, `/api/v1/calendar/caldav/sources${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) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSyncSource(settings: ApiSettings, payload: CalendarSyncSourceCreatePayload): Promise<CalendarSyncSource> {
|
||||||
|
return apiFetch<CalendarSyncSource>(settings, "/api/v1/calendar/sync-sources", { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCalDavSource(settings: ApiSettings, payload: CalendarCalDavSourceCreatePayload): Promise<CalendarCalDavSource> {
|
||||||
|
return apiFetch<CalendarCalDavSource>(settings, "/api/v1/calendar/caldav/sources", { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSyncSource(settings: ApiSettings, sourceId: string, payload: CalendarSyncSourceUpdatePayload): Promise<CalendarSyncSource> {
|
||||||
|
return apiFetch<CalendarSyncSource>(settings, `/api/v1/calendar/sync-sources/${sourceId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCalDavSource(settings: ApiSettings, sourceId: string, payload: CalendarCalDavSourceUpdatePayload): Promise<CalendarCalDavSource> {
|
||||||
|
return apiFetch<CalendarCalDavSource>(settings, `/api/v1/calendar/caldav/sources/${sourceId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSyncSource(settings: ApiSettings, sourceId: string): Promise<void> {
|
||||||
|
return apiFetch<void>(settings, `/api/v1/calendar/sync-sources/${sourceId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCalDavSource(settings: ApiSettings, sourceId: string): Promise<void> {
|
||||||
|
return apiFetch<void>(settings, `/api/v1/calendar/caldav/sources/${sourceId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncSyncSource(settings: ApiSettings, sourceId: string, payload: CalendarSyncSourceSyncPayload = {}): Promise<CalendarSyncSourceSyncResponse> {
|
||||||
|
return apiFetch<CalendarSyncSourceSyncResponse>(settings, `/api/v1/calendar/sync-sources/${sourceId}/sync`, { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncCalDavSource(settings: ApiSettings, sourceId: string, payload: CalendarCalDavSyncPayload = {}): Promise<CalendarCalDavSyncResponse> {
|
||||||
|
return apiFetch<CalendarCalDavSyncResponse>(settings, `/api/v1/calendar/caldav/sources/${sourceId}/sync`, { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
export function listCalendarEvents(
|
export function listCalendarEvents(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
|
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
|
||||||
@@ -122,6 +301,20 @@ export function listCalendarEvents(
|
|||||||
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
|
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listCalendarEventsDelta(
|
||||||
|
settings: ApiSettings,
|
||||||
|
params: { calendar_id?: string; start_at?: string; end_at?: string; since?: string | null; limit?: number } = {}
|
||||||
|
): Promise<CalendarEventDeltaResponse> {
|
||||||
|
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.since) search.set("since", params.since);
|
||||||
|
if (params.limit) search.set("limit", String(params.limit));
|
||||||
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
|
return apiFetch<CalendarEventDeltaResponse>(settings, `/api/v1/calendar/events/delta${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
|
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
|
||||||
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
|
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
358
webui/src/i18n/generatedTranslations.ts
Normal file
358
webui/src/i18n/generatedTranslations.ts
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
|
"en": {
|
||||||
|
"i18n:govoplan-calendar.add_calendar.124c55eb": "Add calendar...",
|
||||||
|
"i18n:govoplan-calendar.add_calendar.8fadb5bc": "Add calendar",
|
||||||
|
"i18n:govoplan-calendar.add.61cc55aa": "Add",
|
||||||
|
"i18n:govoplan-calendar.advanced.4d064726": "Advanced",
|
||||||
|
"i18n:govoplan-calendar.agenda.891e9d6d": "Agenda",
|
||||||
|
"i18n:govoplan-calendar.all_day.11457433": "All day",
|
||||||
|
"i18n:govoplan-calendar.attachments_json.d91abf3c": "Attachments JSON",
|
||||||
|
"i18n:govoplan-calendar.attachments.6771ade6": "Attachments",
|
||||||
|
"i18n:govoplan-calendar.attendees_json.aeb487bb": "Attendees JSON",
|
||||||
|
"i18n:govoplan-calendar.attendees.a45a0962": "Attendees",
|
||||||
|
"i18n:govoplan-calendar.authentication.ee1acfa5": "Authentication",
|
||||||
|
"i18n:govoplan-calendar.automatic_sync.084644b2": "Automatic sync",
|
||||||
|
"i18n:govoplan-calendar.basic.aa2c96da": "Basic",
|
||||||
|
"i18n:govoplan-calendar.bearer_token.ffa64bcf": "Bearer token",
|
||||||
|
"i18n:govoplan-calendar.caldav_source.459ed16a": "CalDAV source",
|
||||||
|
"i18n:govoplan-calendar.caldav.64f9720e": "CalDAV",
|
||||||
|
"i18n:govoplan-calendar.calendar_controls.974f4fa1": "Calendar controls",
|
||||||
|
"i18n:govoplan-calendar.calendar_navigation.7ba43cd2": "Calendar navigation",
|
||||||
|
"i18n:govoplan-calendar.calendar_request_failed.ae8a54f4": "Calendar request failed.",
|
||||||
|
"i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type",
|
||||||
|
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
|
||||||
|
"i18n:govoplan-calendar.calendar.adab5090": "Calendar",
|
||||||
|
"i18n:govoplan-calendar.calendars.94445018": "Calendars",
|
||||||
|
"i18n:govoplan-calendar.cancel.77dfd213": "Cancel",
|
||||||
|
"i18n:govoplan-calendar.cancelled.5587b0af": "CANCELLED",
|
||||||
|
"i18n:govoplan-calendar.categories.6ccb6007": "Categories",
|
||||||
|
"i18n:govoplan-calendar.change_end_time.db66ef4b": "Change end time",
|
||||||
|
"i18n:govoplan-calendar.change_start_time.06eea3d3": "Change start time",
|
||||||
|
"i18n:govoplan-calendar.class.41ff354b": "Class",
|
||||||
|
"i18n:govoplan-calendar.color.1d0c8304": "Color",
|
||||||
|
"i18n:govoplan-calendar.confidential.84c9cc88": "CONFIDENTIAL",
|
||||||
|
"i18n:govoplan-calendar.configured.668c5fff": "Configured",
|
||||||
|
"i18n:govoplan-calendar.confirm_delete.c9f2829e": "Confirm delete",
|
||||||
|
"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",
|
||||||
|
"i18n:govoplan-calendar.delete_events_with_this_calendar.cc0e1287": "Delete events with this calendar",
|
||||||
|
"i18n:govoplan-calendar.delete.f6fdbe48": "Delete",
|
||||||
|
"i18n:govoplan-calendar.deleting.2cda36c9": "Deleting",
|
||||||
|
"i18n:govoplan-calendar.description.55f8ebc8": "Description",
|
||||||
|
"i18n:govoplan-calendar.direction.fd8e45ba": "Direction",
|
||||||
|
"i18n:govoplan-calendar.discover.4827ea22": "Discover",
|
||||||
|
"i18n:govoplan-calendar.discovering.1884f689": "Discovering...",
|
||||||
|
"i18n:govoplan-calendar.display_name.c7874aaa": "Display name",
|
||||||
|
"i18n:govoplan-calendar.duration_must_be_greater_than_zero.519292f7": "Duration must be greater than zero.",
|
||||||
|
"i18n:govoplan-calendar.duration_seconds.19d42eeb": "Duration seconds",
|
||||||
|
"i18n:govoplan-calendar.duration.1370004d": "Duration",
|
||||||
|
"i18n:govoplan-calendar.edit_calendar.a47a2a7a": "Edit calendar",
|
||||||
|
"i18n:govoplan-calendar.edit_event.a7028454": "Edit event",
|
||||||
|
"i18n:govoplan-calendar.edit_value.fad75899": "Edit {value0}",
|
||||||
|
"i18n:govoplan-calendar.end_date_and_time_must_be_after_start_date_and_t.daf31831": "End date and time must be after start date and time.",
|
||||||
|
"i18n:govoplan-calendar.end_date_must_be_on_or_after_start_date.a2518865": "End date must be on or after start date.",
|
||||||
|
"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.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.",
|
||||||
|
"i18n:govoplan-calendar.events": "events",
|
||||||
|
"i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504": "Events are stored in GovOPlaN and are not synced to an external calendar source.",
|
||||||
|
"i18n:govoplan-calendar.ews_endpoint.a3273983": "EWS endpoint",
|
||||||
|
"i18n:govoplan-calendar.exchange_web_services_source.53caabf3": "Exchange Web Services source",
|
||||||
|
"i18n:govoplan-calendar.exchange.5b13eac7": "Exchange",
|
||||||
|
"i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON",
|
||||||
|
"i18n:govoplan-calendar.fri.bbd6e32e": "Fri",
|
||||||
|
"i18n:govoplan-calendar.full_sync.21b89c76": "Full sync",
|
||||||
|
"i18n:govoplan-calendar.graph_calendar_url.e59607f3": "Graph calendar URL",
|
||||||
|
"i18n:govoplan-calendar.graph.9a7405eb": "Graph",
|
||||||
|
"i18n:govoplan-calendar.icalendar_json.fb6cc33e": "iCalendar JSON",
|
||||||
|
"i18n:govoplan-calendar.icalendar.f388476d": "iCalendar",
|
||||||
|
"i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63": "ICS/webcal subscription",
|
||||||
|
"i18n:govoplan-calendar.ics_webcal.9c55b570": "ICS/webcal",
|
||||||
|
"i18n:govoplan-calendar.identity.7e5a975b": "Identity",
|
||||||
|
"i18n:govoplan-calendar.inbound_only.bf4269b0": "Inbound only",
|
||||||
|
"i18n:govoplan-calendar.interval.011efcd5": "Interval",
|
||||||
|
"i18n:govoplan-calendar.last_attempt.82aee111": "Last attempt",
|
||||||
|
"i18n:govoplan-calendar.last_sync.ef0ef267": "Last sync",
|
||||||
|
"i18n:govoplan-calendar.loading_calendar.7eb8f548": "Loading calendar...",
|
||||||
|
"i18n:govoplan-calendar.loading_event_count.716ad3c2": "Loading event count...",
|
||||||
|
"i18n:govoplan-calendar.local_calendar.ed3f72f8": "Local calendar",
|
||||||
|
"i18n:govoplan-calendar.local.dc99d54d": "Local",
|
||||||
|
"i18n:govoplan-calendar.location.d219c681": "Location",
|
||||||
|
"i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b": "Make target calendar the default",
|
||||||
|
"i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa": "Managing sync sources requires calendar administration rights.",
|
||||||
|
"i18n:govoplan-calendar.metadata_json.b0e4c283": "Metadata JSON",
|
||||||
|
"i18n:govoplan-calendar.metadata.251edc0e": "Metadata",
|
||||||
|
"i18n:govoplan-calendar.microsoft_graph_source.ec4f1383": "Microsoft Graph source",
|
||||||
|
"i18n:govoplan-calendar.mon.24b2a099": "Mon",
|
||||||
|
"i18n:govoplan-calendar.month.082bc378": "Month",
|
||||||
|
"i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09": "Move events to another calendar",
|
||||||
|
"i18n:govoplan-calendar.name.709a2322": "Name",
|
||||||
|
"i18n:govoplan-calendar.never.80c3052d": "Never",
|
||||||
|
"i18n:govoplan-calendar.new_event.2ef3795c": "New event",
|
||||||
|
"i18n:govoplan-calendar.new.6403f2b7": "New",
|
||||||
|
"i18n:govoplan-calendar.next_sync.88c7af72": "Next sync",
|
||||||
|
"i18n:govoplan-calendar.next.bc981983": "Next",
|
||||||
|
"i18n:govoplan-calendar.no_calendar_collections_found.6453624a": "No calendar collections found.",
|
||||||
|
"i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars",
|
||||||
|
"i18n:govoplan-calendar.no_events.e339ba73": "No events",
|
||||||
|
"i18n:govoplan-calendar.none.6eef6648": "None",
|
||||||
|
"i18n:govoplan-calendar.not_configured.811931bb": "Not configured",
|
||||||
|
"i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled",
|
||||||
|
"i18n:govoplan-calendar.not_synced.4c205136": "Not synced",
|
||||||
|
"i18n:govoplan-calendar.opaque.3e1d0194": "OPAQUE",
|
||||||
|
"i18n:govoplan-calendar.organizer_json.3add6f9f": "Organizer JSON",
|
||||||
|
"i18n:govoplan-calendar.organizer.debd1720": "Organizer",
|
||||||
|
"i18n:govoplan-calendar.overwrite_remote.39625e32": "Overwrite remote",
|
||||||
|
"i18n:govoplan-calendar.participants.cd56e083": "Participants",
|
||||||
|
"i18n:govoplan-calendar.password.8be3c943": "Password",
|
||||||
|
"i18n:govoplan-calendar.previous.50f94286": "Previous",
|
||||||
|
"i18n:govoplan-calendar.private.b0b7ba46": "PRIVATE",
|
||||||
|
"i18n:govoplan-calendar.public.d1785ca2": "PUBLIC",
|
||||||
|
"i18n:govoplan-calendar.raw.da433cd4": "Raw",
|
||||||
|
"i18n:govoplan-calendar.rdate_json.5b51fca4": "RDATE JSON",
|
||||||
|
"i18n:govoplan-calendar.recurrence_id.e0b780ba": "Recurrence ID",
|
||||||
|
"i18n:govoplan-calendar.recurrence.f7ad40f5": "Recurrence",
|
||||||
|
"i18n:govoplan-calendar.refresh.56e3badc": "Refresh",
|
||||||
|
"i18n:govoplan-calendar.related_to_json.2d4e8f59": "Related-To JSON",
|
||||||
|
"i18n:govoplan-calendar.related_to.0e7989ff": "Related-To",
|
||||||
|
"i18n:govoplan-calendar.related.917df91e": "Related",
|
||||||
|
"i18n:govoplan-calendar.reminders_json.ca25e08f": "Reminders JSON",
|
||||||
|
"i18n:govoplan-calendar.reminders.ae8c3939": "Reminders",
|
||||||
|
"i18n:govoplan-calendar.remove_local_events_with_this_calendar.fb8831e1": "Remove local events with this calendar",
|
||||||
|
"i18n:govoplan-calendar.remove.e963907d": "Remove",
|
||||||
|
"i18n:govoplan-calendar.removing.b7d2c38b": "Removing",
|
||||||
|
"i18n:govoplan-calendar.replace_password.3f912c9c": "Replace password",
|
||||||
|
"i18n:govoplan-calendar.replace_token.bbeee6a9": "Replace token",
|
||||||
|
"i18n:govoplan-calendar.require_matching_etag.0ab1ffe1": "Require matching ETag",
|
||||||
|
"i18n:govoplan-calendar.rrule.c7b2f8a3": "RRULE",
|
||||||
|
"i18n:govoplan-calendar.sat.6b782d41": "Sat",
|
||||||
|
"i18n:govoplan-calendar.save.efc007a3": "Save",
|
||||||
|
"i18n:govoplan-calendar.saving.ae7e8875": "Saving...",
|
||||||
|
"i18n:govoplan-calendar.sequence.5c8f4e0e": "Sequence",
|
||||||
|
"i18n:govoplan-calendar.show_value.60e2ce8e": "Show {value0}",
|
||||||
|
"i18n:govoplan-calendar.source_href.819fa147": "Source href",
|
||||||
|
"i18n:govoplan-calendar.source_kind.7eda9bc4": "Source kind",
|
||||||
|
"i18n:govoplan-calendar.source.6da13add": "Source",
|
||||||
|
"i18n:govoplan-calendar.start_date.ff99f5b5": "Start date",
|
||||||
|
"i18n:govoplan-calendar.start_time.88d8206d": "Start time",
|
||||||
|
"i18n:govoplan-calendar.state.a7250206": "State",
|
||||||
|
"i18n:govoplan-calendar.status.bae7d5be": "Status",
|
||||||
|
"i18n:govoplan-calendar.subscription_url.b8b6a1a0": "Subscription URL",
|
||||||
|
"i18n:govoplan-calendar.sun.48c98cab": "Sun",
|
||||||
|
"i18n:govoplan-calendar.sync_now.2b7d938e": "Sync now",
|
||||||
|
"i18n:govoplan-calendar.sync_value.72e4ba66": "Sync {value0}",
|
||||||
|
"i18n:govoplan-calendar.syncing_value.ca80b487": "Syncing {value0}",
|
||||||
|
"i18n:govoplan-calendar.syncing.4ae6fa22": "Syncing",
|
||||||
|
"i18n:govoplan-calendar.syncing.e5c7727a": "Syncing...",
|
||||||
|
"i18n:govoplan-calendar.target_calendar.e533fe1d": "Target calendar",
|
||||||
|
"i18n:govoplan-calendar.tentative.d19f9022": "TENTATIVE",
|
||||||
|
"i18n:govoplan-calendar.the_selected_calendar.67caa12f": "the selected calendar",
|
||||||
|
"i18n:govoplan-calendar.thu.3593ccd9": "Thu",
|
||||||
|
"i18n:govoplan-calendar.timezone.d1f7dc89": "Timezone",
|
||||||
|
"i18n:govoplan-calendar.title.768e0c1c": "Title",
|
||||||
|
"i18n:govoplan-calendar.today.24345a14": "Today",
|
||||||
|
"i18n:govoplan-calendar.transparency.7cb338a9": "Transparency",
|
||||||
|
"i18n:govoplan-calendar.transparent.ea4efcae": "TRANSPARENT",
|
||||||
|
"i18n:govoplan-calendar.tue.529541bb": "Tue",
|
||||||
|
"i18n:govoplan-calendar.two_way.ee50a3e6": "Two-way",
|
||||||
|
"i18n:govoplan-calendar.uid.d946adf5": "UID",
|
||||||
|
"i18n:govoplan-calendar.username.84c29015": "Username",
|
||||||
|
"i18n:govoplan-calendar.value_this_calendar_will_delete_value_value.7869d1f1": "{value0} this calendar will delete {value1} {value2}.",
|
||||||
|
"i18n:govoplan-calendar.value_this_calendar_will_move_value_value_to_val.6c87e1c1": "{value0} this calendar will move {value1} {value2} to {value3}.",
|
||||||
|
"i18n:govoplan-calendar.value_this_calendar_will_remove_value_local_valu.8f21a59e": "{value0} this calendar will remove {value1} local {value2} from GovOPlaN.",
|
||||||
|
"i18n:govoplan-calendar.vevent.9cf5be75": "VEVENT",
|
||||||
|
"i18n:govoplan-calendar.wed.23408b19": "Wed",
|
||||||
|
"i18n:govoplan-calendar.week.f82be68a": "Week",
|
||||||
|
"i18n:govoplan-calendar.whole_day.951c82d1": "Whole day",
|
||||||
|
"i18n:govoplan-calendar.working.049ac820": "Working...",
|
||||||
|
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"i18n:govoplan-calendar.add_calendar.124c55eb": "Add calendar...",
|
||||||
|
"i18n:govoplan-calendar.add_calendar.8fadb5bc": "Add calendar",
|
||||||
|
"i18n:govoplan-calendar.add.61cc55aa": "Hinzufügen",
|
||||||
|
"i18n:govoplan-calendar.advanced.4d064726": "Advanced",
|
||||||
|
"i18n:govoplan-calendar.agenda.891e9d6d": "Agenda",
|
||||||
|
"i18n:govoplan-calendar.all_day.11457433": "Ganztägig",
|
||||||
|
"i18n:govoplan-calendar.attachments_json.d91abf3c": "Attachments JSON",
|
||||||
|
"i18n:govoplan-calendar.attachments.6771ade6": "Attachments",
|
||||||
|
"i18n:govoplan-calendar.attendees_json.aeb487bb": "Attendees JSON",
|
||||||
|
"i18n:govoplan-calendar.attendees.a45a0962": "Attendees",
|
||||||
|
"i18n:govoplan-calendar.authentication.ee1acfa5": "Authentication",
|
||||||
|
"i18n:govoplan-calendar.automatic_sync.084644b2": "Automatic sync",
|
||||||
|
"i18n:govoplan-calendar.basic.aa2c96da": "Basic",
|
||||||
|
"i18n:govoplan-calendar.bearer_token.ffa64bcf": "Bearer token",
|
||||||
|
"i18n:govoplan-calendar.caldav_source.459ed16a": "CalDAV source",
|
||||||
|
"i18n:govoplan-calendar.caldav.64f9720e": "CalDAV",
|
||||||
|
"i18n:govoplan-calendar.calendar_controls.974f4fa1": "Calendar controls",
|
||||||
|
"i18n:govoplan-calendar.calendar_navigation.7ba43cd2": "Calendar navigation",
|
||||||
|
"i18n:govoplan-calendar.calendar_request_failed.ae8a54f4": "Calendar request failed.",
|
||||||
|
"i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type",
|
||||||
|
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
|
||||||
|
"i18n:govoplan-calendar.calendar.adab5090": "Kalender",
|
||||||
|
"i18n:govoplan-calendar.calendars.94445018": "Calendars",
|
||||||
|
"i18n:govoplan-calendar.cancel.77dfd213": "Abbrechen",
|
||||||
|
"i18n:govoplan-calendar.cancelled.5587b0af": "CANCELLED",
|
||||||
|
"i18n:govoplan-calendar.categories.6ccb6007": "Categories",
|
||||||
|
"i18n:govoplan-calendar.change_end_time.db66ef4b": "Change end time",
|
||||||
|
"i18n:govoplan-calendar.change_start_time.06eea3d3": "Change start time",
|
||||||
|
"i18n:govoplan-calendar.class.41ff354b": "Class",
|
||||||
|
"i18n:govoplan-calendar.color.1d0c8304": "Color",
|
||||||
|
"i18n:govoplan-calendar.confidential.84c9cc88": "CONFIDENTIAL",
|
||||||
|
"i18n:govoplan-calendar.configured.668c5fff": "Configured",
|
||||||
|
"i18n:govoplan-calendar.confirm_delete.c9f2829e": "Confirm delete",
|
||||||
|
"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",
|
||||||
|
"i18n:govoplan-calendar.delete_events_with_this_calendar.cc0e1287": "Delete events with this calendar",
|
||||||
|
"i18n:govoplan-calendar.delete.f6fdbe48": "Löschen",
|
||||||
|
"i18n:govoplan-calendar.deleting.2cda36c9": "Deleting",
|
||||||
|
"i18n:govoplan-calendar.description.55f8ebc8": "Beschreibung",
|
||||||
|
"i18n:govoplan-calendar.direction.fd8e45ba": "Direction",
|
||||||
|
"i18n:govoplan-calendar.discover.4827ea22": "Discover",
|
||||||
|
"i18n:govoplan-calendar.discovering.1884f689": "Discovering...",
|
||||||
|
"i18n:govoplan-calendar.display_name.c7874aaa": "Anzeigename",
|
||||||
|
"i18n:govoplan-calendar.duration_must_be_greater_than_zero.519292f7": "Duration must be greater than zero.",
|
||||||
|
"i18n:govoplan-calendar.duration_seconds.19d42eeb": "Duration seconds",
|
||||||
|
"i18n:govoplan-calendar.duration.1370004d": "Duration",
|
||||||
|
"i18n:govoplan-calendar.edit_calendar.a47a2a7a": "Edit calendar",
|
||||||
|
"i18n:govoplan-calendar.edit_event.a7028454": "Termin bearbeiten",
|
||||||
|
"i18n:govoplan-calendar.edit_value.fad75899": "Edit {value0}",
|
||||||
|
"i18n:govoplan-calendar.end_date_and_time_must_be_after_start_date_and_t.daf31831": "End date and time must be after start date and time.",
|
||||||
|
"i18n:govoplan-calendar.end_date_must_be_on_or_after_start_date.a2518865": "End date must be on or after start date.",
|
||||||
|
"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.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.",
|
||||||
|
"i18n:govoplan-calendar.events": "events",
|
||||||
|
"i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504": "Events are stored in GovOPlaN and are not synced to an external calendar source.",
|
||||||
|
"i18n:govoplan-calendar.ews_endpoint.a3273983": "EWS endpoint",
|
||||||
|
"i18n:govoplan-calendar.exchange_web_services_source.53caabf3": "Exchange Web Services source",
|
||||||
|
"i18n:govoplan-calendar.exchange.5b13eac7": "Exchange",
|
||||||
|
"i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON",
|
||||||
|
"i18n:govoplan-calendar.fri.bbd6e32e": "Fri",
|
||||||
|
"i18n:govoplan-calendar.full_sync.21b89c76": "Full sync",
|
||||||
|
"i18n:govoplan-calendar.graph_calendar_url.e59607f3": "Graph calendar URL",
|
||||||
|
"i18n:govoplan-calendar.graph.9a7405eb": "Graph",
|
||||||
|
"i18n:govoplan-calendar.icalendar_json.fb6cc33e": "iCalendar JSON",
|
||||||
|
"i18n:govoplan-calendar.icalendar.f388476d": "iCalendar",
|
||||||
|
"i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63": "ICS/webcal subscription",
|
||||||
|
"i18n:govoplan-calendar.ics_webcal.9c55b570": "ICS/webcal",
|
||||||
|
"i18n:govoplan-calendar.identity.7e5a975b": "Identity",
|
||||||
|
"i18n:govoplan-calendar.inbound_only.bf4269b0": "Inbound only",
|
||||||
|
"i18n:govoplan-calendar.interval.011efcd5": "Interval",
|
||||||
|
"i18n:govoplan-calendar.last_attempt.82aee111": "Last attempt",
|
||||||
|
"i18n:govoplan-calendar.last_sync.ef0ef267": "Last sync",
|
||||||
|
"i18n:govoplan-calendar.loading_calendar.7eb8f548": "Loading calendar...",
|
||||||
|
"i18n:govoplan-calendar.loading_event_count.716ad3c2": "Loading event count...",
|
||||||
|
"i18n:govoplan-calendar.local_calendar.ed3f72f8": "Local calendar",
|
||||||
|
"i18n:govoplan-calendar.local.dc99d54d": "Local",
|
||||||
|
"i18n:govoplan-calendar.location.d219c681": "Ort",
|
||||||
|
"i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b": "Make target calendar the default",
|
||||||
|
"i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa": "Managing sync sources requires calendar administration rights.",
|
||||||
|
"i18n:govoplan-calendar.metadata_json.b0e4c283": "Metadata JSON",
|
||||||
|
"i18n:govoplan-calendar.metadata.251edc0e": "Metadata",
|
||||||
|
"i18n:govoplan-calendar.microsoft_graph_source.ec4f1383": "Microsoft Graph source",
|
||||||
|
"i18n:govoplan-calendar.mon.24b2a099": "Mon",
|
||||||
|
"i18n:govoplan-calendar.month.082bc378": "Monat",
|
||||||
|
"i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09": "Move events to another calendar",
|
||||||
|
"i18n:govoplan-calendar.name.709a2322": "Name",
|
||||||
|
"i18n:govoplan-calendar.never.80c3052d": "Never",
|
||||||
|
"i18n:govoplan-calendar.new_event.2ef3795c": "New event",
|
||||||
|
"i18n:govoplan-calendar.new.6403f2b7": "New",
|
||||||
|
"i18n:govoplan-calendar.next_sync.88c7af72": "Next sync",
|
||||||
|
"i18n:govoplan-calendar.next.bc981983": "Weiter",
|
||||||
|
"i18n:govoplan-calendar.no_calendar_collections_found.6453624a": "No calendar collections found.",
|
||||||
|
"i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars",
|
||||||
|
"i18n:govoplan-calendar.no_events.e339ba73": "No events",
|
||||||
|
"i18n:govoplan-calendar.none.6eef6648": "Keine",
|
||||||
|
"i18n:govoplan-calendar.not_configured.811931bb": "Nicht konfiguriert",
|
||||||
|
"i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled",
|
||||||
|
"i18n:govoplan-calendar.not_synced.4c205136": "Not synced",
|
||||||
|
"i18n:govoplan-calendar.opaque.3e1d0194": "OPAQUE",
|
||||||
|
"i18n:govoplan-calendar.organizer_json.3add6f9f": "Organizer JSON",
|
||||||
|
"i18n:govoplan-calendar.organizer.debd1720": "Organizer",
|
||||||
|
"i18n:govoplan-calendar.overwrite_remote.39625e32": "Overwrite remote",
|
||||||
|
"i18n:govoplan-calendar.participants.cd56e083": "Participants",
|
||||||
|
"i18n:govoplan-calendar.password.8be3c943": "Passwort",
|
||||||
|
"i18n:govoplan-calendar.previous.50f94286": "Previous",
|
||||||
|
"i18n:govoplan-calendar.private.b0b7ba46": "PRIVATE",
|
||||||
|
"i18n:govoplan-calendar.public.d1785ca2": "PUBLIC",
|
||||||
|
"i18n:govoplan-calendar.raw.da433cd4": "Raw",
|
||||||
|
"i18n:govoplan-calendar.rdate_json.5b51fca4": "RDATE JSON",
|
||||||
|
"i18n:govoplan-calendar.recurrence_id.e0b780ba": "Recurrence ID",
|
||||||
|
"i18n:govoplan-calendar.recurrence.f7ad40f5": "Wiederholung",
|
||||||
|
"i18n:govoplan-calendar.refresh.56e3badc": "Refresh",
|
||||||
|
"i18n:govoplan-calendar.related_to_json.2d4e8f59": "Related-To JSON",
|
||||||
|
"i18n:govoplan-calendar.related_to.0e7989ff": "Related-To",
|
||||||
|
"i18n:govoplan-calendar.related.917df91e": "Related",
|
||||||
|
"i18n:govoplan-calendar.reminders_json.ca25e08f": "Reminders JSON",
|
||||||
|
"i18n:govoplan-calendar.reminders.ae8c3939": "Reminders",
|
||||||
|
"i18n:govoplan-calendar.remove_local_events_with_this_calendar.fb8831e1": "Remove local events with this calendar",
|
||||||
|
"i18n:govoplan-calendar.remove.e963907d": "Remove",
|
||||||
|
"i18n:govoplan-calendar.removing.b7d2c38b": "Removing",
|
||||||
|
"i18n:govoplan-calendar.replace_password.3f912c9c": "Replace password",
|
||||||
|
"i18n:govoplan-calendar.replace_token.bbeee6a9": "Replace token",
|
||||||
|
"i18n:govoplan-calendar.require_matching_etag.0ab1ffe1": "Require matching ETag",
|
||||||
|
"i18n:govoplan-calendar.rrule.c7b2f8a3": "RRULE",
|
||||||
|
"i18n:govoplan-calendar.sat.6b782d41": "Sat",
|
||||||
|
"i18n:govoplan-calendar.save.efc007a3": "Speichern",
|
||||||
|
"i18n:govoplan-calendar.saving.ae7e8875": "Saving...",
|
||||||
|
"i18n:govoplan-calendar.sequence.5c8f4e0e": "Sequence",
|
||||||
|
"i18n:govoplan-calendar.show_value.60e2ce8e": "Show {value0}",
|
||||||
|
"i18n:govoplan-calendar.source_href.819fa147": "Source href",
|
||||||
|
"i18n:govoplan-calendar.source_kind.7eda9bc4": "Source kind",
|
||||||
|
"i18n:govoplan-calendar.source.6da13add": "Quelle",
|
||||||
|
"i18n:govoplan-calendar.start_date.ff99f5b5": "Start date",
|
||||||
|
"i18n:govoplan-calendar.start_time.88d8206d": "Start time",
|
||||||
|
"i18n:govoplan-calendar.state.a7250206": "State",
|
||||||
|
"i18n:govoplan-calendar.status.bae7d5be": "Status",
|
||||||
|
"i18n:govoplan-calendar.subscription_url.b8b6a1a0": "Subscription URL",
|
||||||
|
"i18n:govoplan-calendar.sun.48c98cab": "Sun",
|
||||||
|
"i18n:govoplan-calendar.sync_now.2b7d938e": "Sync now",
|
||||||
|
"i18n:govoplan-calendar.sync_value.72e4ba66": "Sync {value0}",
|
||||||
|
"i18n:govoplan-calendar.syncing_value.ca80b487": "Syncing {value0}",
|
||||||
|
"i18n:govoplan-calendar.syncing.4ae6fa22": "Syncing",
|
||||||
|
"i18n:govoplan-calendar.syncing.e5c7727a": "Syncing...",
|
||||||
|
"i18n:govoplan-calendar.target_calendar.e533fe1d": "Target calendar",
|
||||||
|
"i18n:govoplan-calendar.tentative.d19f9022": "TENTATIVE",
|
||||||
|
"i18n:govoplan-calendar.the_selected_calendar.67caa12f": "the selected calendar",
|
||||||
|
"i18n:govoplan-calendar.thu.3593ccd9": "Thu",
|
||||||
|
"i18n:govoplan-calendar.timezone.d1f7dc89": "Timezone",
|
||||||
|
"i18n:govoplan-calendar.title.768e0c1c": "Title",
|
||||||
|
"i18n:govoplan-calendar.today.24345a14": "Heute",
|
||||||
|
"i18n:govoplan-calendar.transparency.7cb338a9": "Transparency",
|
||||||
|
"i18n:govoplan-calendar.transparent.ea4efcae": "TRANSPARENT",
|
||||||
|
"i18n:govoplan-calendar.tue.529541bb": "Tue",
|
||||||
|
"i18n:govoplan-calendar.two_way.ee50a3e6": "Two-way",
|
||||||
|
"i18n:govoplan-calendar.uid.d946adf5": "UID",
|
||||||
|
"i18n:govoplan-calendar.username.84c29015": "Benutzername",
|
||||||
|
"i18n:govoplan-calendar.value_this_calendar_will_delete_value_value.7869d1f1": "{value0} this calendar will delete {value1} {value2}.",
|
||||||
|
"i18n:govoplan-calendar.value_this_calendar_will_move_value_value_to_val.6c87e1c1": "{value0} this calendar will move {value1} {value2} to {value3}.",
|
||||||
|
"i18n:govoplan-calendar.value_this_calendar_will_remove_value_local_valu.8f21a59e": "{value0} this calendar will remove {value1} local {value2} from GovOPlaN.",
|
||||||
|
"i18n:govoplan-calendar.vevent.9cf5be75": "VEVENT",
|
||||||
|
"i18n:govoplan-calendar.wed.23408b19": "Wed",
|
||||||
|
"i18n:govoplan-calendar.week.f82be68a": "Woche",
|
||||||
|
"i18n:govoplan-calendar.whole_day.951c82d1": "Whole day",
|
||||||
|
"i18n:govoplan-calendar.working.049ac820": "Working...",
|
||||||
|
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,21 +1,27 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
import "./styles/calendar.css";
|
import "./styles/calendar.css";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
|
||||||
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
|
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
|
||||||
|
|
||||||
const eventRead = ["calendar:event:read"];
|
const eventRead = ["calendar:event:read"];
|
||||||
|
const translations = {
|
||||||
|
en: generatedTranslations.en,
|
||||||
|
de: generatedTranslations.de
|
||||||
|
};
|
||||||
|
|
||||||
export const calendarModule: PlatformWebModule = {
|
export const calendarModule: PlatformWebModule = {
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
label: "Calendar",
|
label: "i18n:govoplan-calendar.calendar.adab5090",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
|
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
|
||||||
navItems: [{ to: "/calendar", label: "Calendar", iconName: "calendar", anyOf: eventRead, order: 55 }],
|
translations,
|
||||||
|
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }
|
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }]
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default calendarModule;
|
export default calendarModule;
|
||||||
@@ -17,6 +17,11 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-loading-frame {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-shell {
|
.calendar-shell {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -101,59 +106,13 @@
|
|||||||
|
|
||||||
.calendar-mode-switch {
|
.calendar-mode-switch {
|
||||||
flex: 0 1 520px;
|
flex: 0 1 520px;
|
||||||
display: inline-grid;
|
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
width: min(520px, 100%);
|
width: min(520px, 100%);
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid #c9c3b9;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #c9c3b9;
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-mode-tab {
|
.calendar-mode-switch .segmented-control-option {
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
background: linear-gradient(#ffffff, #f1efeb);
|
|
||||||
color: #4f4a43;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 800;
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
|
||||||
transition: background .18s ease, box-shadow .18s ease, color .18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-mode-tab + .calendar-mode-tab {
|
|
||||||
border-left: 1px solid #c9c3b9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-mode-tab:first-child {
|
|
||||||
border-radius: 7px 0 0 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-mode-tab:last-child {
|
|
||||||
border-radius: 0 7px 7px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-mode-tab:hover,
|
|
||||||
.calendar-mode-tab:focus-visible {
|
|
||||||
background: linear-gradient(#ffffff, #e8e5df);
|
|
||||||
color: #4f4a43;
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-mode-tab:focus-visible {
|
|
||||||
outline: 3px solid rgba(82, 130, 177, .22);
|
|
||||||
outline-offset: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-mode-tab.is-active {
|
|
||||||
background: var(--line);
|
|
||||||
color: var(--text-strong);
|
|
||||||
box-shadow: inset 0 2px 5px rgba(0,0,0,.12), inset 0 -1px 0 rgba(255,255,255,.45);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-sidebar {
|
.calendar-sidebar {
|
||||||
@@ -216,7 +175,7 @@
|
|||||||
.calendar-list-row {
|
.calendar-list-row {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 42px minmax(0, 1fr) 30px;
|
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
@@ -230,6 +189,10 @@
|
|||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-list-row.is-syncing {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-visibility-switch {
|
.calendar-visibility-switch {
|
||||||
--calendar-list-color: var(--green);
|
--calendar-list-color: var(--green);
|
||||||
width: 34px;
|
width: 34px;
|
||||||
@@ -286,6 +249,14 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-list-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-row-icon-button.btn {
|
.calendar-row-icon-button.btn {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
@@ -306,44 +277,41 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-create-form input[type="color"] {
|
.calendar-row-icon-button.btn.is-syncing,
|
||||||
width: 24px;
|
.calendar-row-icon-button.btn.is-syncing:disabled {
|
||||||
min-width: 24px;
|
border-color: var(--green);
|
||||||
height: 24px;
|
background: var(--surface);
|
||||||
padding: 0;
|
color: var(--green);
|
||||||
overflow: hidden;
|
opacity: 1;
|
||||||
border: 1px solid var(--line-dark);
|
cursor: progress;
|
||||||
border-radius: 4px;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-create-form input:disabled {
|
.calendar-sync-spin {
|
||||||
opacity: .55;
|
animation: calendar-sync-spin .85s linear infinite;
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-create-form {
|
@keyframes calendar-sync-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.calendar-sync-spin {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-create-action {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 28px 36px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-create-form input[type="text"],
|
.calendar-create-action .btn {
|
||||||
.calendar-create-form input:not([type]) {
|
width: 100%;
|
||||||
min-width: 0;
|
justify-content: center;
|
||||||
height: 34px;
|
|
||||||
border: 1px solid var(--line-dark);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
padding: 6px 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-agenda {
|
.calendar-agenda {
|
||||||
@@ -931,7 +899,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-event-dialog {
|
.calendar-event-dialog {
|
||||||
width: min(560px, 100%);
|
width: min(680px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-vevent-dialog {
|
||||||
|
width: min(920px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-dialog-form {
|
.calendar-dialog-form {
|
||||||
@@ -974,14 +946,205 @@
|
|||||||
|
|
||||||
.calendar-dialog-name-color-row {
|
.calendar-dialog-name-color-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 86px;
|
grid-template-columns: minmax(0, 1fr) 142px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-dialog-color-label input[type="color"] {
|
.calendar-source-switch {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-source-switch .segmented-control-option {
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-source-pane {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-source-pane h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-source-pane-heading,
|
||||||
|
.calendar-sync-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-dialog-grid-two,
|
||||||
|
.calendar-dialog-grid-three,
|
||||||
|
.calendar-caldav-setup,
|
||||||
|
.calendar-sync-settings {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-dialog-grid-three {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-dialog-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-caldav-setup {
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-discovery-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 3px;
|
}
|
||||||
|
|
||||||
|
.calendar-discovery-actions .btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-discovery-actions span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-discovery-actions.is-readonly {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-advanced-settings {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-advanced-settings summary {
|
||||||
|
width: fit-content;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-advanced-settings[open] {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-advanced-settings[open] summary {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-vevent-details {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-vevent-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-vevent-section:first-of-type {
|
||||||
|
border-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-vevent-section h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-vevent-section textarea {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-sync-settings {
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-sync-status {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid var(--line-dark);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-sync-status.is-error {
|
||||||
|
border-color: #f0b8b2;
|
||||||
|
background: #fff2f0;
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-sync-status.is-syncing {
|
||||||
|
border-color: var(--green);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-sync-button.btn.is-syncing,
|
||||||
|
.calendar-sync-button.btn.is-syncing:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-sync-status-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-sync-status-panel dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-sync-status-panel dl div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-sync-status-panel dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-sync-status-panel dd {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-form-error {
|
.calendar-form-error {
|
||||||
@@ -1056,6 +1219,15 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-delete-dialog {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-delete-dialog-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-event-dialog-footer {
|
.calendar-event-dialog-footer {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1114,7 +1286,12 @@
|
|||||||
|
|
||||||
.calendar-dialog-row,
|
.calendar-dialog-row,
|
||||||
.calendar-dialog-date-row,
|
.calendar-dialog-date-row,
|
||||||
.calendar-dialog-name-color-row {
|
.calendar-dialog-name-color-row,
|
||||||
|
.calendar-dialog-grid-two,
|
||||||
|
.calendar-dialog-grid-three,
|
||||||
|
.calendar-caldav-setup,
|
||||||
|
.calendar-sync-settings,
|
||||||
|
.calendar-sync-status-panel dl {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user