6 Commits
Author SHA1 Message Date
zemion 39d2c28ab1 Release v0.1.7 2026-07-11 02:34:56 +02:00
zemion 9fcb812ddb Create scope tables in calendar backend tests 2026-07-11 00:13:13 +02:00
zemion 70bd067001 Add shared GovOPlaN gitignore 2026-07-10 21:57:21 +02:00
zemion 15aa4f3df3 Use capability-based module boundaries 2026-07-10 17:33:53 +02:00
zemion 15d5613f9b chore: sync GovOPlaN module split state 2026-07-10 12:51:18 +02:00
zemion c3c867391c Release v0.1.6 2026-07-07 15:55:37 +02:00
23 changed files with 5001 additions and 912 deletions
+267
View File
@@ -8,3 +8,270 @@ __pycache__/
node_modules/ node_modules/
dist/ dist/
webui/dist/ webui/dist/
# GovOPlaN shared ignore rules from govoplan-core
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
# ---> Python
# Byte-compiled / optimized / DLL files
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
# PyPI configuration file
.pypirc
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
*.db
# GovOPlaN local runtime state
runtime/
# GovOPlaN WebUI test output
webui/.module-test-build/
webui/.component-test-build/
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/calendar-webui", "name": "@govoplan/calendar-webui",
"version": "0.1.5", "version": "0.1.7",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,8 +19,8 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.5", "@govoplan/core-webui": "^0.1.7",
"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",
+3 -3
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-calendar" name = "govoplan-calendar"
version = "0.1.5" version = "0.1.7"
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration." description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.5", "govoplan-core>=0.1.7",
"govoplan-access>=0.1.5", "govoplan-access>=0.1.7",
"icalendar>=7.2", "icalendar>=7.2",
"python-dateutil>=2.9", "python-dateutil>=2.9",
] ]
+200 -1
View File
@@ -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
+8 -8
View File
@@ -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(String(36), 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(String(36), 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(String(36), 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(String(36), 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)
+15 -1
View File
@@ -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:
+3 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -87,8 +88,8 @@ def _calendar_router(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="calendar", id="calendar",
name="Calendar", name="Calendar",
version="0.1.5", version="0.1.7",
dependencies=("access",), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"), optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
permissions=PERMISSIONS, permissions=PERMISSIONS,
route_factory=_calendar_router, route_factory=_calendar_router,
@@ -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,14 +11,19 @@ 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
def _scope_fk_target(tables: set[str]) -> str:
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
def upgrade() -> None: def upgrade() -> None:
inspector = sa.inspect(op.get_bind()) inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names()) tables = set(inspector.get_table_names())
scope_fk_target = _scope_fk_target(tables)
if "calendar_collections" not in tables: if "calendar_collections" not in tables:
op.create_table( op.create_table(
"calendar_collections", "calendar_collections",
@@ -38,8 +43,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"], [scope_fk_target], name=op.f("fk_calendar_collections_tenant_id_scopes"), 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 +105,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"], [scope_fk_target], name=op.f("fk_calendar_events_tenant_id_scopes"), 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"),
) )
@@ -16,11 +16,16 @@ branch_labels = None
depends_on = None depends_on = None
def _scope_fk_target(tables: set[str]) -> str:
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
def upgrade() -> None: def upgrade() -> None:
inspector = sa.inspect(op.get_bind()) inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names()) tables = set(inspector.get_table_names())
if "calendar_sync_sources" in tables: if "calendar_sync_sources" in tables:
return return
scope_fk_target = _scope_fk_target(tables)
op.create_table( op.create_table(
"calendar_sync_sources", "calendar_sync_sources",
sa.Column("id", sa.String(length=36), nullable=False), sa.Column("id", sa.String(length=36), nullable=False),
@@ -42,7 +47,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"], [scope_fk_target], name=op.f("fk_calendar_sync_sources_tenant_id_scopes"), 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"):
@@ -16,10 +16,15 @@ branch_labels = None
depends_on = None depends_on = None
def _scope_fk_target(tables: set[str]) -> str:
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
def upgrade() -> None: def upgrade() -> None:
bind = op.get_bind() bind = op.get_bind()
inspector = sa.inspect(bind) inspector = sa.inspect(bind)
tables = set(inspector.get_table_names()) tables = set(inspector.get_table_names())
scope_fk_target = _scope_fk_target(tables)
if "calendar_sync_sources" in tables: if "calendar_sync_sources" in tables:
columns = {column["name"] for column in inspector.get_columns("calendar_sync_sources")} columns = {column["name"] for column in inspector.get_columns("calendar_sync_sources")}
add_column_if_missing(columns, "sync_enabled", sa.Column("sync_enabled", sa.Boolean(), server_default=sa.true(), nullable=False)) add_column_if_missing(columns, "sync_enabled", sa.Column("sync_enabled", sa.Boolean(), server_default=sa.true(), nullable=False))
@@ -47,8 +52,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"], [scope_fk_target], name=op.f("fk_calendar_sync_credentials_tenant_id_scopes"), 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"):
+331 -11
View File
@@ -5,10 +5,15 @@ from datetime import datetime
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status
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_core.auth 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
+76 -7
View File
@@ -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
+2 -2
View File
@@ -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 [
{ {
+231 -1
View File
@@ -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,13 +19,16 @@ 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,
update_event, update_event,
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_tenancy.backend.db.models import Tenant from govoplan_tenancy.backend.db.models import Tenant
@@ -36,12 +39,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 +66,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,10 +123,109 @@ 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:
self.engine = create_engine("sqlite:///:memory:") self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine) Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine) self.Session = sessionmaker(bind=self.engine)
@@ -153,6 +259,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 +560,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
+33
View File
@@ -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
+173
View File
@@ -0,0 +1,173 @@
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_core.tenancy.scope import create_scope_tables
from govoplan_tenancy.backend.db.models import Tenant
class CalendarSyncSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
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()
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/calendar-webui", "name": "@govoplan/calendar-webui",
"version": "0.1.5", "version": "0.1.7",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,8 +14,8 @@
"./styles/calendar.css": "./src/styles/calendar.css" "./styles/calendar.css": "./src/styles/calendar.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.5", "@govoplan/core-webui": "^0.1.7",
"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",
+197 -4
View File
@@ -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
View 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"
}
};
+11 -5
View File
@@ -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;
+258 -81
View File
@@ -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;
} }
} }