1
0
Fork 0
mirror of https://github.com/Kozea/Radicale.git synced 2025-09-12 20:30:57 +00:00

- Capture previous version of event pre-overwrite for use in notification hooks

- Use previous version of event in email hooks to determine added/deleted/updated email type
This commit is contained in:
Nate Harris 2025-07-14 00:16:19 -06:00
parent e4b337d3ff
commit 80dc4995cf
10 changed files with 274 additions and 110 deletions

View file

@ -28,7 +28,7 @@ import json
import xml.etree.ElementTree as ET
from hashlib import sha256
from typing import (Callable, ContextManager, Iterable, Iterator, Mapping,
Optional, Sequence, Set, Tuple, Union, overload)
Optional, Sequence, Set, Tuple, Union, overload, Dict, List)
import vobject
@ -175,8 +175,11 @@ class BaseCollection:
return False
def upload(self, href: str, item: "radicale_item.Item") -> (
"radicale_item.Item"):
"""Upload a new or replace an existing item."""
"radicale_item.Item", Optional["radicale_item.Item"]):
"""Upload a new or replace an existing item.
Return the uploaded item and the old item if it was replaced.
"""
raise NotImplementedError
def delete(self, href: Optional[str] = None) -> None:
@ -328,7 +331,8 @@ class BaseStorage:
def create_collection(
self, href: str,
items: Optional[Iterable["radicale_item.Item"]] = None,
props: Optional[Mapping[str, str]] = None) -> BaseCollection:
props: Optional[Mapping[str, str]] = None) -> (
Tuple)[BaseCollection, Dict[str, "radicale_item.Item"], List[str]]:
"""Create a collection.
``href`` is the sanitized path.

View file

@ -19,7 +19,7 @@
import os
from tempfile import TemporaryDirectory
from typing import Iterable, Optional, cast
from typing import Iterable, Optional, cast, List, Tuple, Dict
import radicale.item as radicale_item
from radicale import pathutils
@ -30,9 +30,37 @@ from radicale.storage.multifilesystem.base import StorageBase
class StoragePartCreateCollection(StorageBase):
def _discover_existing_items_pre_overwrite(self,
tmp_collection: "multifilesystem.Collection",
dst_path: str) -> Tuple[Dict[str, radicale_item.Item], List[str]]:
"""Discover existing items in the collection before overwriting them."""
existing_items = {}
new_item_hrefs = []
existing_collection = self._collection_class(
cast(multifilesystem.Storage, self),
pathutils.unstrip_path(dst_path, True))
existing_item_hrefs = set(existing_collection._list())
tmp_collection_hrefs = set(tmp_collection._list())
for item_href in tmp_collection_hrefs:
if item_href not in existing_item_hrefs:
# Item in temporary collection does not exist in the existing collection (is new)
new_item_hrefs.append(item_href)
continue
# Item exists in both collections, grab the existing item for reference
try:
item = existing_collection._get(item_href, verify_href=False)
if item is not None:
existing_items[item_href] = item
except Exception:
# TODO: Log exception?
continue
return existing_items, new_item_hrefs
def create_collection(self, href: str,
items: Optional[Iterable[radicale_item.Item]] = None,
props=None) -> "multifilesystem.Collection":
props=None) -> Tuple["multifilesystem.Collection", Dict[str, radicale_item.Item], List[str]]:
folder = self._get_collection_root_folder()
# Path should already be sanitized
@ -44,11 +72,14 @@ class StoragePartCreateCollection(StorageBase):
self._makedirs_synced(filesystem_path)
return self._collection_class(
cast(multifilesystem.Storage, self),
pathutils.unstrip_path(sane_path, True))
pathutils.unstrip_path(sane_path, True)), {}, []
parent_dir = os.path.dirname(filesystem_path)
self._makedirs_synced(parent_dir)
replaced_items: Dict[str, radicale_item.Item] = {}
new_item_hrefs: List[str] = []
# Create a temporary directory with an unsafe name
try:
with TemporaryDirectory(prefix=".Radicale.tmp-", dir=parent_dir
@ -68,14 +99,20 @@ class StoragePartCreateCollection(StorageBase):
col._upload_all_nonatomic(items, suffix=".vcf")
if os.path.lexists(filesystem_path):
replaced_items, new_item_hrefs = self._discover_existing_items_pre_overwrite(
tmp_collection=col,
dst_path=sane_path)
pathutils.rename_exchange(tmp_filesystem_path, filesystem_path)
else:
# If the destination path does not exist, obviously all items are new
new_item_hrefs = list(col._list())
os.rename(tmp_filesystem_path, filesystem_path)
self._sync_directory(parent_dir)
except Exception as e:
raise ValueError("Failed to create collection %r as %r %s" %
(href, filesystem_path, e)) from e
# TODO: Return new-old pairs and just-new items (new vs updated)
return self._collection_class(
cast(multifilesystem.Storage, self),
pathutils.unstrip_path(sane_path, True))
pathutils.unstrip_path(sane_path, True)), replaced_items, new_item_hrefs

View file

@ -21,7 +21,7 @@ import errno
import os
import pickle
import sys
from typing import Iterable, Iterator, TextIO, cast
from typing import Iterable, Iterator, TextIO, cast, Optional, Tuple
import radicale.item as radicale_item
from radicale import pathutils
@ -36,10 +36,11 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache,
CollectionPartHistory, CollectionBase):
def upload(self, href: str, item: radicale_item.Item
) -> radicale_item.Item:
) -> Tuple[radicale_item.Item, Optional[radicale_item.Item]]:
if not pathutils.is_safe_filesystem_path_component(href):
raise pathutils.UnsafePathError(href)
path = pathutils.path_to_filesystem(self._filesystem_path, href)
old_item = self._get(href, verify_href=False)
try:
with self._atomic_write(path, newline="") as fo: # type: ignore
f = cast(TextIO, fo)
@ -67,7 +68,7 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache,
uploaded_item = self._get(href, verify_href=False)
if uploaded_item is None:
raise RuntimeError("Storage modified externally")
return uploaded_item
return uploaded_item, old_item
def _upload_all_nonatomic(self, items: Iterable[radicale_item.Item],
suffix: str = "") -> None: