fix: 포트 충돌 회피 — note_bridge 8098, intent_service 8099
Jellyfin(8096), OrbStack(8097) 포트 충돌으로 변경. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""icalendar-searcher: Search and filter iCalendar components.
|
||||
|
||||
This package provides the Searcher class for filtering, sorting, and
|
||||
expanding calendar components (VEVENT, VTODO, VJOURNAL).
|
||||
"""
|
||||
|
||||
## for python 3.9 support
|
||||
from __future__ import annotations
|
||||
|
||||
from .collation import Collation
|
||||
from .searcher import Searcher
|
||||
|
||||
__all__ = ["Searcher", "Collation"]
|
||||
|
||||
# Version is set by poetry-dynamic-versioning at build time
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = version("icalendar-searcher")
|
||||
except Exception:
|
||||
__version__ = "unknown"
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Text collation support for string comparisons.
|
||||
|
||||
This module provides collation (text comparison) functionality with optional
|
||||
PyICU support for advanced Unicode collation. Falls back to simple binary
|
||||
and case-insensitive comparisons when PyICU is not available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
|
||||
# Try to import PyICU for advanced collation support
|
||||
try:
|
||||
from icu import Collator as ICUCollator
|
||||
from icu import Locale as ICULocale
|
||||
|
||||
HAS_PYICU = True
|
||||
except ImportError:
|
||||
HAS_PYICU = False
|
||||
|
||||
|
||||
class Collation(str, Enum):
|
||||
"""Text comparison collation strategies.
|
||||
|
||||
For most users, use case_sensitive parameter in add_property_filter()
|
||||
instead of working with Collation directly.
|
||||
|
||||
Examples:
|
||||
# Simple API (recommended for most users):
|
||||
searcher.add_property_filter("SUMMARY", "meeting", case_sensitive=False)
|
||||
|
||||
# Advanced API (for power users):
|
||||
searcher.add_property_filter("SUMMARY", "Müller",
|
||||
collation=Collation.LOCALE,
|
||||
locale="de_DE")
|
||||
"""
|
||||
|
||||
SIMPLE = "simple"
|
||||
"""Simple Python-based collation (no PyICU required).
|
||||
|
||||
- case_sensitive=True: Byte-for-byte comparison
|
||||
- case_sensitive=False: Python's str.lower() comparison
|
||||
"""
|
||||
|
||||
UNICODE = "unicode"
|
||||
"""Unicode Collation Algorithm (UCA) root collation.
|
||||
|
||||
- case_sensitive=True: ICU TERTIARY strength (distinguishes case)
|
||||
- case_sensitive=False: ICU SECONDARY strength (ignores case)
|
||||
|
||||
Requires PyICU to be installed."""
|
||||
|
||||
LOCALE = "locale"
|
||||
"""Locale-aware collation using CLDR rules.
|
||||
|
||||
- case_sensitive=True: ICU TERTIARY strength (distinguishes case)
|
||||
- case_sensitive=False: ICU SECONDARY strength (ignores case)
|
||||
|
||||
Requires PyICU to be installed and locale parameter."""
|
||||
|
||||
|
||||
class CollationError(Exception):
|
||||
"""Raised when collation operation cannot be performed."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def get_collation_function(
|
||||
collation: Collation = Collation.SIMPLE,
|
||||
case_sensitive: bool = True,
|
||||
locale: str | None = None,
|
||||
) -> Callable[[str, str], bool]:
|
||||
"""Get a collation function for substring matching.
|
||||
|
||||
Args:
|
||||
collation: The collation strategy to use
|
||||
case_sensitive: Whether comparison should be case-sensitive
|
||||
locale: Locale string (e.g., "de_DE", "en_US") for LOCALE collation
|
||||
|
||||
Returns:
|
||||
A function that takes (needle, haystack) and returns True if needle
|
||||
is found in haystack according to the collation rules.
|
||||
|
||||
Raises:
|
||||
CollationError: If PyICU is required but not available, or if
|
||||
invalid parameters are provided.
|
||||
|
||||
Examples:
|
||||
>>> match_fn = get_collation_function(Collation.SIMPLE, case_sensitive=False)
|
||||
>>> match_fn("test", "This is a TEST")
|
||||
True
|
||||
"""
|
||||
if collation == Collation.SIMPLE:
|
||||
if case_sensitive:
|
||||
return _binary_contains
|
||||
else:
|
||||
return _case_insensitive_contains
|
||||
|
||||
elif collation in (Collation.UNICODE, Collation.LOCALE):
|
||||
if not HAS_PYICU:
|
||||
raise CollationError(
|
||||
f"Collation '{collation}' requires PyICU to be installed. "
|
||||
"Install with: pip install 'icalendar-searcher[collation]'"
|
||||
)
|
||||
|
||||
if collation == Collation.LOCALE:
|
||||
if not locale:
|
||||
raise CollationError("LOCALE collation requires a locale parameter")
|
||||
return _get_icu_contains(locale, case_sensitive)
|
||||
else:
|
||||
# UNICODE collation uses root locale
|
||||
return _get_icu_contains(None, case_sensitive)
|
||||
|
||||
else:
|
||||
raise CollationError(f"Unknown collation: {collation}")
|
||||
|
||||
|
||||
def get_sort_key_function(
|
||||
collation: Collation = Collation.SIMPLE,
|
||||
case_sensitive: bool = True,
|
||||
locale: str | None = None,
|
||||
) -> Callable[[str], bytes]:
|
||||
"""Get a collation function for generating sort keys.
|
||||
|
||||
Args:
|
||||
collation: The collation strategy to use
|
||||
case_sensitive: Whether comparison should be case-sensitive
|
||||
locale: Locale string (e.g., "de_DE", "en_US") for LOCALE collation
|
||||
|
||||
Returns:
|
||||
A function that takes a string and returns a sort key (bytes) that
|
||||
can be used for sorting according to the collation rules.
|
||||
|
||||
Raises:
|
||||
CollationError: If PyICU is required but not available, or if
|
||||
invalid parameters are provided.
|
||||
|
||||
Examples:
|
||||
>>> sort_key_fn = get_sort_key_function(Collation.SIMPLE, case_sensitive=False)
|
||||
>>> sorted(["Zebra", "apple", "Banana"], key=sort_key_fn)
|
||||
['apple', 'Banana', 'Zebra']
|
||||
"""
|
||||
if collation == Collation.SIMPLE:
|
||||
if case_sensitive:
|
||||
return lambda s: s.encode("utf-8")
|
||||
else:
|
||||
return lambda s: s.lower().encode("utf-8")
|
||||
|
||||
elif collation in (Collation.UNICODE, Collation.LOCALE):
|
||||
if not HAS_PYICU:
|
||||
raise CollationError(
|
||||
f"Collation '{collation}' requires PyICU to be installed. "
|
||||
"Install with: pip install 'icalendar-searcher[collation]'"
|
||||
)
|
||||
|
||||
if collation == Collation.LOCALE:
|
||||
if not locale:
|
||||
raise CollationError("LOCALE collation requires a locale parameter")
|
||||
return _get_icu_sort_key(locale, case_sensitive)
|
||||
else:
|
||||
# UNICODE collation uses root locale
|
||||
return _get_icu_sort_key(None, case_sensitive)
|
||||
|
||||
else:
|
||||
raise CollationError(f"Unknown collation: {collation}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Internal implementation functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _binary_contains(needle: str, haystack: str) -> bool:
|
||||
"""Binary (case-sensitive) substring match."""
|
||||
return needle in haystack
|
||||
|
||||
|
||||
def _case_insensitive_contains(needle: str, haystack: str) -> bool:
|
||||
"""Case-insensitive substring match."""
|
||||
return needle.lower() in haystack.lower()
|
||||
|
||||
|
||||
def _get_icu_contains(locale: str | None, case_sensitive: bool) -> Callable[[str, str], bool]:
|
||||
"""Get ICU-based substring matcher.
|
||||
|
||||
Note: This is a simplified implementation. PyICU doesn't expose ICU's
|
||||
StringSearch API which would be needed for proper substring matching with
|
||||
collation. For now, we use Python's built-in matching.
|
||||
|
||||
Future enhancement: Implement proper collation-aware substring matching.
|
||||
"""
|
||||
|
||||
def icu_contains(needle: str, haystack: str) -> bool:
|
||||
"""Check if needle is in haystack.
|
||||
|
||||
This is a fallback implementation until proper ICU StringSearch support
|
||||
is added. It provides reasonable behavior for most use cases.
|
||||
"""
|
||||
# TODO: Use ICU StringSearch for proper collation-aware substring matching
|
||||
# For now, fall back to Python's built-in contains
|
||||
if case_sensitive:
|
||||
return needle in haystack
|
||||
else:
|
||||
return needle.lower() in haystack.lower()
|
||||
|
||||
return icu_contains
|
||||
|
||||
|
||||
def _get_icu_sort_key(locale: str | None, case_sensitive: bool) -> Callable[[str], bytes]:
|
||||
"""Get ICU-based sort key function.
|
||||
|
||||
Creates a collator instance and returns a function that generates sort keys.
|
||||
The collator strength is configured based on case_sensitive parameter.
|
||||
"""
|
||||
icu_locale = ICULocale(locale) if locale else ICULocale.getRoot()
|
||||
collator = ICUCollator.createInstance(icu_locale)
|
||||
|
||||
# Set strength based on case sensitivity:
|
||||
# PRIMARY = base character differences only
|
||||
# SECONDARY = base + accent differences (case-insensitive)
|
||||
# TERTIARY = base + accent + case differences (case-sensitive, default)
|
||||
if case_sensitive:
|
||||
collator.setStrength(ICUCollator.TERTIARY)
|
||||
else:
|
||||
collator.setStrength(ICUCollator.SECONDARY)
|
||||
|
||||
def icu_sort_key(s: str) -> bytes:
|
||||
"""Generate ICU collation sort key."""
|
||||
return collator.getSortKey(s)
|
||||
|
||||
return icu_sort_key
|
||||
456
.venv/lib/python3.9/site-packages/icalendar_searcher/filters.py
Normal file
456
.venv/lib/python3.9/site-packages/icalendar_searcher/filters.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""Filtering logic for icalendar components."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from icalendar import Component, error
|
||||
from icalendar.prop import vCategory, vText
|
||||
from recurring_ical_events import DATE_MAX_DT, DATE_MIN_DT
|
||||
|
||||
from .collation import Collation, get_collation_function
|
||||
from .utils import _normalize_dt
|
||||
|
||||
|
||||
class FilterMixin:
|
||||
"""Mixin class providing filtering methods for calendar components.
|
||||
|
||||
This class is meant to be mixed into the Searcher dataclass.
|
||||
It expects the following attributes to be available on self:
|
||||
- start, end: datetime range filters
|
||||
- alarm_start, alarm_end: alarm range filters
|
||||
- include_completed: bool for filtering completed todos
|
||||
- _property_filters: dict of property filters
|
||||
- _property_operator: dict of property operators
|
||||
- _property_collation: dict of property collations
|
||||
- _property_locale: dict of property locales
|
||||
"""
|
||||
|
||||
def _check_range(self, component: Component) -> bool:
|
||||
"""Check if a component falls within the time range specified by self.start and self.end.
|
||||
|
||||
Implements RFC4791 section 9.9 time-range filtering logic for VEVENT, VTODO, and VJOURNAL.
|
||||
|
||||
:param component: A single calendar component (VEVENT, VTODO, or VJOURNAL)
|
||||
:return: True if the component matches the time range, False otherwise
|
||||
"""
|
||||
comp_name = component.name
|
||||
|
||||
## The logic below should correspond neatly with RFC4791 section 9.9
|
||||
|
||||
## fetch comp_end and comp_start
|
||||
## note that comp_end is DTSTART + DURATION if DURATION is given.
|
||||
## note that for tasks, comp_end is set to DUE
|
||||
## This logic is all handled by the start/end properties in the
|
||||
## icalendar library, and makes the logic here less complex
|
||||
|
||||
try:
|
||||
comp_end = _normalize_dt(component.end)
|
||||
except error.IncompleteComponent:
|
||||
comp_end = None
|
||||
|
||||
try:
|
||||
comp_start = _normalize_dt(component.start)
|
||||
except error.IncompleteComponent:
|
||||
if component.name == "VEVENT":
|
||||
## for events, DTSTART is mandatory
|
||||
raise
|
||||
comp_start = None
|
||||
|
||||
if comp_name == "VEVENT":
|
||||
## comp_start is always set.
|
||||
if not comp_end and isinstance(comp_start, datetime):
|
||||
## if comp_end is not set and comp_start is a datetime,
|
||||
## consider zero duration
|
||||
comp_end = comp_start
|
||||
elif not comp_end:
|
||||
## if comp_end is not set and comp_start is a datetime,
|
||||
## consider one day duration
|
||||
comp_end = comp_start + timedelta(day=1)
|
||||
## TODO: What time of the day does the day change?
|
||||
## Time zones are difficult! TODO: as for now,
|
||||
## self.start is a datetime, but in the future dates
|
||||
## should be allowed as well. Then the time zone
|
||||
## problem for full-day events is at least simplified.
|
||||
|
||||
elif comp_name == "VTODO":
|
||||
## There is a long matrix for VTODO in the RFC, and it
|
||||
## may seem complicated, but it isn't that bad:
|
||||
|
||||
## * A task with DTSTART and DURATION is equivalent with a
|
||||
## task with DTSTART and DUE. This complexity is
|
||||
## already handled by the icalendar library, so all rows
|
||||
## in the matrix where VTODO has the DURATION property?"
|
||||
## is Y may be removed.
|
||||
##
|
||||
## * If either DUE or DTSTART is set, use it.
|
||||
if comp_end and not comp_start:
|
||||
comp_start = comp_end
|
||||
if comp_start and not comp_end:
|
||||
comp_end = comp_start
|
||||
|
||||
## * If both created/completed is set and
|
||||
## comp_start/comp_end is not set, then use those instead
|
||||
if not comp_start:
|
||||
if "CREATED" in component:
|
||||
comp_start = _normalize_dt(component["CREATED"].dt)
|
||||
if "COMPLETED" in component:
|
||||
comp_end = _normalize_dt(component["COMPLETED"].dt)
|
||||
|
||||
## * A task may have a DUE before the DTSTART. The
|
||||
## complicated OR-logic in the table may be eliminated
|
||||
## by swapping start/end if necessary:
|
||||
if comp_end and comp_start and comp_end < comp_start:
|
||||
tmp = comp_start
|
||||
comp_start = comp_end
|
||||
comp_end = tmp
|
||||
|
||||
## * A task with no timestamps is considered to be done "at any or all days".
|
||||
if not comp_end and not comp_start:
|
||||
comp_start = _normalize_dt(DATE_MIN_DT)
|
||||
comp_end = _normalize_dt(DATE_MAX_DT)
|
||||
|
||||
elif comp_name == "VJOURNAL":
|
||||
if not comp_start:
|
||||
## Journal without DTSTART doesn't match time ranges
|
||||
return False
|
||||
if isinstance(comp_start, datetime):
|
||||
comp_end = comp_start
|
||||
else:
|
||||
comp_end = comp_start + timedelta(days=1)
|
||||
|
||||
if comp_start == comp_end:
|
||||
## Now the match requirement is start <= comp_end
|
||||
## while otherwise the match requirement is start < comp_end
|
||||
## minor detail, we'll work around it:
|
||||
comp_end += timedelta(seconds=1)
|
||||
|
||||
## After the logic above, all rows in the matrix boils down to
|
||||
## this: (we could reduce it even more by defaulting
|
||||
## self.start and self.end to DATE_MIN_DT etc)
|
||||
if self.start and self.end and comp_end:
|
||||
return self.start < comp_end and self.end > comp_start
|
||||
elif self.end:
|
||||
return self.end > comp_start
|
||||
elif self.start and comp_end:
|
||||
return self.start < comp_end
|
||||
return True
|
||||
|
||||
def _check_completed_filter(self, component: Component) -> bool:
|
||||
"""Check if a component should be included based on the include_completed filter.
|
||||
|
||||
:param component: A single calendar component
|
||||
:return: True if the component should be included, False if it should be filtered out
|
||||
"""
|
||||
if self.include_completed:
|
||||
return True
|
||||
|
||||
## If include_completed is False, exclude completed/cancelled VTODOs
|
||||
## Include everything that is not a VTODO, or VTODOs that are not completed/cancelled
|
||||
if component.name != "VTODO":
|
||||
return True
|
||||
|
||||
## For VTODOs, exclude if STATUS is COMPLETED or CANCELLED, or if COMPLETED property is set
|
||||
status = component.get("STATUS", "NEEDS-ACTION")
|
||||
if status in ("COMPLETED", "CANCELLED"):
|
||||
return False
|
||||
if "COMPLETED" in component:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
## DISCLAIMER: partly AI-generated code. Refactored a bit by human hands
|
||||
## Should be refactored more, there is quite some code duplication here.
|
||||
## Code duplication is bad, IMO.
|
||||
def _check_property_filters(self, component: Component) -> bool:
|
||||
"""Check if a component matches all property filters.
|
||||
|
||||
:param component: A single calendar component
|
||||
:return: True if the component matches all property filters, False otherwise
|
||||
"""
|
||||
for key, operator in self._property_operator.items():
|
||||
filter_value = self._property_filters.get(key)
|
||||
|
||||
# Map "category" (singular) to "CATEGORIES" (plural) in the component
|
||||
if key in ("categories", "category"):
|
||||
comp_key = "categories"
|
||||
comp_value = set([str(x) for x in component.categories])
|
||||
else:
|
||||
comp_key = key
|
||||
comp_value = component.get(comp_key)
|
||||
|
||||
# Get collation settings for this property
|
||||
collation = self._property_collation.get(key, Collation.SIMPLE)
|
||||
locale = self._property_locale.get(key)
|
||||
case_sensitive = self._property_case_sensitive.get(key, True)
|
||||
|
||||
## "categories" (plural) needs special preprocessing - split on commas
|
||||
if key == "categories" and comp_value is not None and filter_value is not None:
|
||||
if isinstance(filter_value, vCategory):
|
||||
## TODO: This special case, handling one element different from several, is a bit bad indeed
|
||||
if len(filter_value.cats) == 1:
|
||||
filter_value = str(filter_value.cats[0])
|
||||
if "," in filter_value:
|
||||
filter_value = set(filter_value.split(","))
|
||||
else:
|
||||
filter_value = set([str(x) for x in filter_value.cats])
|
||||
elif isinstance(filter_value, str) or isinstance(filter_value, vText):
|
||||
## TODO: probably this is irrelevant dead code
|
||||
filter_value = str(filter_value)
|
||||
if "," in filter_value:
|
||||
filter_value = set(filter_value.split(","))
|
||||
elif isinstance(filter_value, Iterable):
|
||||
## TODO: probably this is irrelevant dead code
|
||||
# Convert iterable to set, splitting on commas if strings contain them
|
||||
result_set = set()
|
||||
for item in filter_value:
|
||||
item_str = str(item)
|
||||
if "," in item_str:
|
||||
result_set.update(item_str.split(","))
|
||||
else:
|
||||
result_set.add(item_str)
|
||||
filter_value = result_set
|
||||
if operator == "undef":
|
||||
## Property should NOT be defined
|
||||
if comp_key in component:
|
||||
return False
|
||||
elif operator == "contains":
|
||||
## Property should contain the filter value (substring match)
|
||||
if comp_key not in component:
|
||||
return False
|
||||
if key == "category":
|
||||
# "category" (singular) does substring matching within category names
|
||||
# comp_value is a vCategory object
|
||||
if comp_value is not None:
|
||||
filter_str = str(filter_value)
|
||||
# Check if filter_str is a substring of any category
|
||||
collation_fn = get_collation_function(collation, case_sensitive, locale)
|
||||
for cat in comp_value:
|
||||
if collation_fn(filter_str, cat):
|
||||
return True
|
||||
return False
|
||||
if key == "categories":
|
||||
# For categories, "contains" means filter categories is a subset of component categories
|
||||
# filter_value can be a string (single category) or set (multiple categories)
|
||||
if isinstance(filter_value, str):
|
||||
# Single category: check if it's in component categories
|
||||
if not case_sensitive:
|
||||
return any(filter_value.lower() == cv.lower() for cv in comp_value)
|
||||
else:
|
||||
return filter_value in comp_value
|
||||
else:
|
||||
# Multiple categories (set): check if all are in component categories (subset check)
|
||||
assert isinstance(filter_value, set), (
|
||||
f"Expected set but got {type(filter_value)}"
|
||||
)
|
||||
for fv in filter_value:
|
||||
if not case_sensitive:
|
||||
if not any(fv.lower() == cv.lower() for cv in comp_value):
|
||||
return False
|
||||
else:
|
||||
if fv not in comp_value:
|
||||
return False
|
||||
return True
|
||||
|
||||
## Convert to string for substring matching
|
||||
comp_str = str(comp_value)
|
||||
filter_str = str(filter_value)
|
||||
|
||||
# Use collation function for text matching
|
||||
collation_fn = get_collation_function(collation, case_sensitive, locale)
|
||||
if not collation_fn(filter_str, comp_str):
|
||||
return False
|
||||
elif operator == "==":
|
||||
## Property should exactly match the filter value
|
||||
if comp_key not in component:
|
||||
return False
|
||||
|
||||
## For "category" (singular), check exact match to at least one category name
|
||||
if key == "category":
|
||||
if comp_value is not None:
|
||||
filter_str = str(filter_value)
|
||||
# Check if filter_str exactly matches any category
|
||||
for cat in comp_value:
|
||||
if not case_sensitive:
|
||||
if filter_str.lower() == cat.lower():
|
||||
return True
|
||||
else:
|
||||
if filter_str == cat:
|
||||
return True
|
||||
return False
|
||||
|
||||
## For categories, check exact set equality with collation support
|
||||
if key == "categories":
|
||||
# filter_value can be a string (single category) or set (multiple categories)
|
||||
assert isinstance(comp_value, set), f"Expected set but got {type(comp_value)}"
|
||||
|
||||
if isinstance(filter_value, str):
|
||||
# Single category with "==" operator: component must have exactly that one category
|
||||
if len(comp_value) != 1:
|
||||
return False
|
||||
if not case_sensitive:
|
||||
return filter_value.lower() == list(comp_value)[0].lower()
|
||||
else:
|
||||
return filter_value in comp_value
|
||||
else:
|
||||
# Multiple categories (set): check exact equality with collation
|
||||
assert isinstance(filter_value, set), (
|
||||
f"Expected set but got {type(filter_value)}"
|
||||
)
|
||||
if len(filter_value) != len(comp_value):
|
||||
return False
|
||||
# Check if all filter categories have a matching component category
|
||||
for fv in filter_value:
|
||||
found = False
|
||||
for cv in comp_value:
|
||||
if not case_sensitive:
|
||||
if fv.lower() == cv.lower():
|
||||
found = True
|
||||
break
|
||||
else:
|
||||
if fv == cv:
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
return False
|
||||
return True
|
||||
|
||||
## Compare the values This is tricky, as the values
|
||||
## may have different types. TODO: we should add more
|
||||
## logic for the different property types. Maybe get
|
||||
## it into the icalendar library.
|
||||
if comp_value == filter_value:
|
||||
return True
|
||||
if isinstance(filter_value, str) and isinstance(comp_value, set):
|
||||
return filter_value in comp_value
|
||||
|
||||
# For text properties, use collation for exact match comparison
|
||||
if isinstance(filter_value, (str, vText)) and isinstance(comp_value, (str, vText)):
|
||||
comp_str = str(comp_value)
|
||||
filter_str = str(filter_value)
|
||||
|
||||
# Use collation-specific comparison
|
||||
if collation == Collation.SIMPLE:
|
||||
if case_sensitive:
|
||||
return comp_str == filter_str
|
||||
else:
|
||||
return comp_str.lower() == filter_str.lower()
|
||||
elif collation in (Collation.UNICODE, Collation.LOCALE):
|
||||
# For UNICODE/LOCALE collations, use sort keys for comparison
|
||||
# Two strings are equal if they have the same sort key
|
||||
from .collation import get_sort_key_function
|
||||
|
||||
sort_key_fn = get_sort_key_function(collation, case_sensitive, locale)
|
||||
return sort_key_fn(comp_str) == sort_key_fn(filter_str)
|
||||
|
||||
return False
|
||||
else:
|
||||
## This shouldn't happen as add_property_filter validates operators
|
||||
raise NotImplementedError(f"Operator {operator} not implemented")
|
||||
|
||||
return True
|
||||
|
||||
## DISCLAIMER: Mostly AI-generated code, with a touch of human polishing
|
||||
## and bugfixing. Alarms are a bit complex.
|
||||
def _check_alarm_range(self, component: Component) -> bool:
|
||||
"""Check if a component has alarms that fire within the alarm time range.
|
||||
|
||||
Implements RFC 4791 section 9.9 alarm time-range filtering.
|
||||
|
||||
:param component: A single calendar component (VEVENT, VTODO, or VJOURNAL)
|
||||
:return: True if any alarm fires within the alarm range, False otherwise
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
## Get all VALARM subcomponents
|
||||
alarms = [x for x in component.subcomponents if x.name == "VALARM"]
|
||||
|
||||
if not alarms:
|
||||
## No alarms - doesn't match alarm search
|
||||
return False
|
||||
|
||||
## Get component start/end for relative trigger calculations
|
||||
## Use try/except because .start/.end may raise IncompleteComponent
|
||||
## For VTODO, RFC 5545 says TRIGGER is relative to DUE if present, else DTSTART
|
||||
comp_start = None
|
||||
comp_end = None
|
||||
try:
|
||||
comp_start = _normalize_dt(component.start)
|
||||
except error.IncompleteComponent:
|
||||
pass
|
||||
try:
|
||||
comp_end = _normalize_dt(component.end)
|
||||
except error.IncompleteComponent:
|
||||
pass
|
||||
|
||||
## For each alarm, calculate when it fires
|
||||
for alarm in alarms:
|
||||
if "TRIGGER" not in alarm:
|
||||
continue
|
||||
|
||||
trigger = alarm["TRIGGER"]
|
||||
|
||||
## The icalendar library stores trigger values in .dt attribute
|
||||
## which can be either datetime (absolute) or timedelta (relative)
|
||||
if not hasattr(trigger, "dt"):
|
||||
continue
|
||||
|
||||
trigger_value = trigger.dt
|
||||
|
||||
## Check if trigger is absolute (datetime) or relative (timedelta)
|
||||
if isinstance(trigger_value, timedelta):
|
||||
## Relative trigger - timedelta from start or end
|
||||
trigger_delta = trigger_value
|
||||
|
||||
## Check TRIGGER's RELATED parameter (default is START)
|
||||
## For VTODO, default anchor is DUE if present, else DTSTART
|
||||
related = "START" # Default per RFC 5545
|
||||
if hasattr(trigger, "params") and "RELATED" in trigger.params:
|
||||
related = trigger.params["RELATED"]
|
||||
|
||||
## Calculate alarm time based on RELATED and component type
|
||||
if related == "END" and comp_end:
|
||||
alarm_time = comp_end + trigger_delta
|
||||
elif comp_start:
|
||||
alarm_time = comp_start + trigger_delta
|
||||
else:
|
||||
## No start, end, or due to relate to
|
||||
continue
|
||||
else:
|
||||
## Absolute trigger - direct datetime
|
||||
alarm_time = _normalize_dt(trigger_value)
|
||||
|
||||
## Check for REPEAT and DURATION (repeating alarms/snooze functionality)
|
||||
## Check all repetitions, not just the first alarm
|
||||
if "REPEAT" in alarm and "DURATION" in alarm:
|
||||
repeat_count = alarm["REPEAT"]
|
||||
duration = alarm["DURATION"].dt if hasattr(alarm["DURATION"], "dt") else None
|
||||
|
||||
if duration:
|
||||
## Check each repetition
|
||||
for i in range(int(repeat_count) + 1):
|
||||
repeat_time = alarm_time + (duration * i)
|
||||
## Check if this repetition fires within the alarm range
|
||||
if self.alarm_start and self.alarm_end:
|
||||
if self.alarm_start <= repeat_time < self.alarm_end:
|
||||
return True
|
||||
elif self.alarm_start:
|
||||
if repeat_time >= self.alarm_start:
|
||||
return True
|
||||
elif self.alarm_end:
|
||||
if repeat_time < self.alarm_end:
|
||||
return True
|
||||
## None of the repetitions matched
|
||||
continue
|
||||
|
||||
## Check if this alarm (first occurrence) fires within the alarm range
|
||||
if self.alarm_start and self.alarm_end:
|
||||
if self.alarm_start <= alarm_time < self.alarm_end:
|
||||
return True
|
||||
elif self.alarm_start:
|
||||
if alarm_time >= self.alarm_start:
|
||||
return True
|
||||
elif self.alarm_end:
|
||||
if alarm_time < self.alarm_end:
|
||||
return True
|
||||
|
||||
return False
|
||||
886
.venv/lib/python3.9/site-packages/icalendar_searcher/searcher.py
Normal file
886
.venv/lib/python3.9/site-packages/icalendar_searcher/searcher.py
Normal file
@@ -0,0 +1,886 @@
|
||||
"""Main Searcher class for icalendar component filtering and sorting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import recurring_ical_events
|
||||
from icalendar import Calendar, Component, Timezone
|
||||
from recurring_ical_events import DATE_MAX_DT, DATE_MIN_DT
|
||||
|
||||
from .collation import Collation, get_sort_key_function
|
||||
from .filters import FilterMixin
|
||||
from .utils import _iterable_or_false, _normalize_dt, types_factory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from caldav.calendarobjectresource import CalendarObjectResource
|
||||
|
||||
|
||||
@dataclass
|
||||
class Searcher(FilterMixin):
|
||||
"""This class will:
|
||||
|
||||
* Allow to build up a calendar search query.
|
||||
* Help filterering or sorting a list of calendar components
|
||||
|
||||
Primarily VJOURNAL, VTODO and VEVENT Calendar components are
|
||||
intended to be supported, but we should consider if there is any
|
||||
point supporting FREEBUSY as well.
|
||||
|
||||
This class is a bit stubbed as of 2025-11, many things not working
|
||||
yet. This class was split out from the CalDAV library and is
|
||||
intended for generic search logic not related to the CalDAV
|
||||
protocol. As of 2025-11, the first priority is to make the bare
|
||||
minimum of support needed for supporting refactorings of the
|
||||
CalDAV library.
|
||||
|
||||
Long-term plan is to also allow for adding subqueries that can be
|
||||
bound together with logical AND or OR. (Will AND every be needed?
|
||||
After all, add different criterias to one Searcher object, and all
|
||||
of them has to match)
|
||||
|
||||
Properties (like SUMMARY, CATEGORIES, etc) are not meant to be
|
||||
sent through the constructor, use the :func:`icalendar_searcher.Searcher.add_property_filter`
|
||||
method. Same goes with sort keys, they can be added through the
|
||||
`func:icalendar_searcher.Searcher.add_sort_key` method.
|
||||
|
||||
The ``todo``, ``event`` and ``journal`` parameters are booleans
|
||||
for filtering the component type. If i.e. both todo and
|
||||
journal is set to True, everything but events should be returned.
|
||||
If none is given (the default), all objects should be returned.
|
||||
|
||||
If ``todo`` is given ``include_completed`` defaults to False,
|
||||
which means completed tasks will be tfiltered out.
|
||||
|
||||
``start`` and ``end`` is giving a time range. RFC4791, section
|
||||
9.9 gives a very clear and sane definitions of what should be
|
||||
returned when searching a CalDAV calendar for contents over a time
|
||||
span. While this package is not related to CalDAV pper se, the
|
||||
logic seems sane, so we will stick to that one. Timestamps should
|
||||
ideally be with a time zone, if not given the local time zone will
|
||||
be assumed. All-day events may be tricky to get correct when
|
||||
timestamps are given and calendar data covers multiple time zones.
|
||||
|
||||
``alarm_start`` and ``alarm_end`` is similar for alarm searching
|
||||
|
||||
If ``expand`` is set to True, recurring objects will be expanded
|
||||
into reccurence objects for the time period specified by ``start``
|
||||
and ``end``. Generators are used, so ``expand`` may even work
|
||||
without ``start`` and ``end`` set if care is taken
|
||||
(i.e. converting the generator to a list may cause problems)
|
||||
|
||||
Unless you now what you are doing, ``expand`` should probably be
|
||||
set to true, and start and end should be given and shouldn't be
|
||||
too far away from each other. Currently the sorting algorithm
|
||||
will blow up if the expand yields infinite number of events. It
|
||||
may probably blow up even if expand yields millions of events. Be
|
||||
careful.
|
||||
|
||||
For filtering an icalendar instance ``mycal`` containing a
|
||||
VCALENDAR with multiple independent events, one may do
|
||||
``searcher.filter([mycal])``. The CalDAV library contains a
|
||||
CalDAVSearcher class inheritating this class and including a
|
||||
``.search(caldav_calendar)`` method. The idea is that it may be
|
||||
done in the same way also for searching other calendars or
|
||||
calendar-like systems using other protocols.
|
||||
|
||||
The filtering and sorting methods should accept both wrapper
|
||||
objects (for the CalDAV library, those are called
|
||||
CalendarObjectResource and contains extra information like the URL
|
||||
and the client object) and ``icalendar.Calendar`` objects from the
|
||||
icalendar library. Wrapper objects should have the
|
||||
``icalendar.Calendar`` object available through a property
|
||||
``icalendar_instance``. Even for methods expecting a simple
|
||||
component, a ``Calendar`` should be given. This is of
|
||||
imporantance for recurrences with exceptions (like, "daily meeting
|
||||
is held at 10:00, except today the meeting is postponed until
|
||||
12:00" may be represented through a calendar with two
|
||||
subcomponents)
|
||||
|
||||
Methods expecting multiple components will always expect a list of
|
||||
calendars (CalDAV-style) - but the algorithms should also be
|
||||
robust enough to handle multiple independent components embedded
|
||||
in a single Calendar.
|
||||
|
||||
Other ideas that one may consider implementing:
|
||||
* limit, offset.
|
||||
* fuzzy matching
|
||||
|
||||
"""
|
||||
|
||||
todo: bool = None
|
||||
event: bool = None
|
||||
journal: bool = None
|
||||
start: datetime = None
|
||||
end: datetime = None
|
||||
alarm_start: datetime = None
|
||||
alarm_end: datetime = None
|
||||
include_completed: bool = None
|
||||
|
||||
expand: bool = False
|
||||
|
||||
_sort_keys: list = field(default_factory=list)
|
||||
_sort_collation: dict = field(default_factory=dict)
|
||||
_sort_locale: dict = field(default_factory=dict)
|
||||
_sort_case_sensitive: dict = field(default_factory=dict)
|
||||
_property_filters: dict = field(default_factory=dict)
|
||||
_property_operator: dict = field(default_factory=dict)
|
||||
_property_collation: dict = field(default_factory=dict)
|
||||
_property_locale: dict = field(default_factory=dict)
|
||||
_property_case_sensitive: dict = field(default_factory=dict)
|
||||
|
||||
def add_property_filter(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
operator: str = "contains",
|
||||
case_sensitive: bool = True,
|
||||
collation: Collation | None = None,
|
||||
locale: str | None = None,
|
||||
) -> None:
|
||||
"""Adds a filter for some specific iCalendar property.
|
||||
|
||||
Examples of valid iCalendar properties: SUMMARY,
|
||||
LOCATION, DESCRIPTION, DTSTART, STATUS, CLASS, etc
|
||||
|
||||
:param key: must be an icalendar property, i.e. SUMMARY
|
||||
Special virtual property "category" (singular) is also supported
|
||||
for substring matching within category names
|
||||
:param value: should adhere to the type defined in the RFC
|
||||
:param operator: Comparision operator ("contains", "==", etc)
|
||||
:param case_sensitive: If False, text comparisons are case-insensitive.
|
||||
:param collation: Advanced collation strategy for text comparison.
|
||||
If specified, overrides case_sensitive parameter.
|
||||
Only needed by power users for locale-aware collation.
|
||||
:param locale: Locale string (e.g., "de_DE") for locale-aware collation.
|
||||
Only used with collation=Collation.LOCALE.
|
||||
|
||||
The case_sensitive parameter only applies to text properties.
|
||||
The default has been made for case sensitive searches. This
|
||||
is contrary to the CalDAV standard, where case insensitive
|
||||
searches are considered the default, but it's in accordance
|
||||
with the CalDAV library, where case sensitive searches has
|
||||
been the default.
|
||||
|
||||
**Special handling for categories:**
|
||||
|
||||
- **"categories"** (plural): Exact category name matching
|
||||
- "contains": subset check (all filter categories must be in component)
|
||||
- "==": exact set equality (same categories, order doesn't matter)
|
||||
- Commas in filter values split into multiple categories
|
||||
|
||||
- **"category"** (singular): Substring matching within category names
|
||||
- "contains": substring match (e.g., "out" matches "outdoor")
|
||||
- "==": exact match to at least one category name
|
||||
- Commas in filter values treated as literal characters
|
||||
|
||||
For the operator, the following is (planned to be) supported:
|
||||
|
||||
* contains - will do a substring match (A search for "summary"
|
||||
"contains" "rain" will return both events with summary
|
||||
"Training session" and "Singing in the rain")
|
||||
|
||||
* == - exact match is required
|
||||
|
||||
* ~ - regexp match
|
||||
|
||||
* <, >, <=, >= - comparision
|
||||
|
||||
* <> or != - inqueality, both supported
|
||||
|
||||
* def, undef - will match if the property is (not) defined. value can be set to None, the value will be ignored.
|
||||
|
||||
Examples:
|
||||
# Case-insensitive search (simple API)
|
||||
searcher.add_property_filter("SUMMARY", "meeting", case_sensitive=False)
|
||||
|
||||
# Case-sensitive search (default)
|
||||
searcher.add_property_filter("SUMMARY", "Meeting")
|
||||
|
||||
# Advanced: locale-aware collation (requires PyICU)
|
||||
searcher.add_property_filter("SUMMARY", "Müller",
|
||||
collation=Collation.LOCALE,
|
||||
locale="de_DE")
|
||||
|
||||
"""
|
||||
## Special handling of property "category" (singular) vs "categories" (plural).
|
||||
## "categories" (plural): list of categories with exact matching (no substring)
|
||||
## - "contains": subset check (all filter categories must be in component)
|
||||
## - "==": exact set equality (same categories, order doesn't matter)
|
||||
## - Commas split into multiple categories
|
||||
## "category" (singular): substring matching within category names
|
||||
## - "contains": substring match (e.g., "out" matches "outdoor")
|
||||
## - "==": exact match to at least one category name
|
||||
## - Commas NOT split, treated as literal part of category name
|
||||
key = key.lower()
|
||||
if operator not in ("contains", "undef", "=="):
|
||||
raise NotImplementedError(f"The operator {operator} is not supported yet.")
|
||||
if operator != "undef":
|
||||
## Map "category" to "categories" for types_factory lookup
|
||||
property_key = "categories" if key == "category" else key
|
||||
|
||||
## Special treatment for "categories" (plural): split on commas
|
||||
if key == "categories" and isinstance(value, str):
|
||||
## If someone asks for FAMILY,FINANCE, they want a match on anything
|
||||
## having both those categories set, not a category literally named "FAMILY,FINANCE"
|
||||
fact = types_factory.for_property(property_key)
|
||||
self._property_filters[key] = fact(fact.from_ical(value))
|
||||
elif key == "category":
|
||||
## For "category" (singular), store as string (no comma splitting)
|
||||
## This allows substring matching within category names
|
||||
self._property_filters[key] = value
|
||||
else:
|
||||
self._property_filters[key] = types_factory.for_property(property_key)(value)
|
||||
self._property_operator[key] = operator
|
||||
|
||||
# Determine collation strategy
|
||||
if collation is not None:
|
||||
# Power user specified explicit collation
|
||||
self._property_collation[key] = collation
|
||||
self._property_locale[key] = locale
|
||||
self._property_case_sensitive[key] = case_sensitive
|
||||
else:
|
||||
# Simple API: use SIMPLE collation with case_sensitive parameter
|
||||
self._property_collation[key] = Collation.SIMPLE
|
||||
self._property_locale[key] = None
|
||||
self._property_case_sensitive[key] = case_sensitive
|
||||
|
||||
def add_sort_key(
|
||||
self,
|
||||
key: str,
|
||||
reversed: bool = None,
|
||||
case_sensitive: bool = True,
|
||||
collation: Collation | None = None,
|
||||
locale: str | None = None,
|
||||
) -> None:
|
||||
"""Add a sort key for sorting components.
|
||||
|
||||
Special keys "isnt_overdue" and "hasnt_started" is
|
||||
supported, those will compare the DUE (for a task) or the
|
||||
DTSTART with the current wall clock and return a bool.
|
||||
|
||||
Except for that, the sort key should be an icalendar property.
|
||||
|
||||
:param key: The property name to sort by
|
||||
:param reversed: If True, sort in reverse order
|
||||
:param case_sensitive: If False, text sorting is case-insensitive.
|
||||
Only applies to text properties. Default is True.
|
||||
:param collation: Advanced collation strategy for text sorting.
|
||||
If specified, overrides case_sensitive parameter.
|
||||
:param locale: Locale string (e.g., "de_DE") for locale-aware sorting.
|
||||
Only used with collation=Collation.LOCALE.
|
||||
|
||||
Examples:
|
||||
# Case-insensitive sorting (simple API)
|
||||
searcher.add_sort_key("SUMMARY", case_sensitive=False)
|
||||
|
||||
# Case-sensitive sorting (default)
|
||||
searcher.add_sort_key("SUMMARY")
|
||||
|
||||
# Advanced: locale-aware sorting (requires PyICU)
|
||||
searcher.add_sort_key("SUMMARY", collation=Collation.LOCALE, locale="de_DE")
|
||||
"""
|
||||
key = key.lower()
|
||||
assert key in types_factory.types_map or key in (
|
||||
"isnt_overdue",
|
||||
"hasnt_started",
|
||||
)
|
||||
self._sort_keys.append((key, reversed))
|
||||
|
||||
# Determine collation strategy for sorting
|
||||
if collation is not None:
|
||||
# Power user specified explicit collation
|
||||
self._sort_collation[key] = collation
|
||||
self._sort_locale[key] = locale
|
||||
self._sort_case_sensitive[key] = case_sensitive
|
||||
else:
|
||||
# Simple API
|
||||
self._sort_collation[key] = Collation.SIMPLE
|
||||
self._sort_locale[key] = None
|
||||
self._sort_case_sensitive[key] = case_sensitive
|
||||
|
||||
def check_component(
|
||||
self,
|
||||
component: Calendar | Component | CalendarObjectResource,
|
||||
expand_only: bool = False,
|
||||
_ignore_rrule_and_time: bool = False,
|
||||
) -> Iterable[Component]:
|
||||
"""Checks if one component (or recurrence set) matches the
|
||||
filters. If the component parameter is a calendar containing
|
||||
several independent components, an Exception may be raised,
|
||||
though recurrence sets should be suppored.
|
||||
|
||||
* If there is no match, ``None`` or ``False`` will be returned
|
||||
(the exact return value is currently not clearly defined,
|
||||
but ``bool(return_value)`` should yield False).
|
||||
|
||||
* If a time specification is given and the component given is
|
||||
a recurring component, it will be expanded internally to
|
||||
check if it matches the given time specification.
|
||||
|
||||
* If there is a match, the component should be returned
|
||||
(wrapped in a tuple) - unless ``expand`` is set, in which
|
||||
case all matching recurrences will be returned (as a
|
||||
generator object).
|
||||
|
||||
:param component: Todo, Event, Calendar or such
|
||||
:param expand_only: Don't do any filtering, just expand
|
||||
|
||||
"""
|
||||
## For consistant and predictable return value, we need to
|
||||
## transform the component in the very start. We need to make
|
||||
## it into a list so we can iterate on it without throwing
|
||||
## away data. TODO: This will break badly if the
|
||||
## component already is a generator with infinite amount of
|
||||
## recurrences. It should be possible to fix this in a smart
|
||||
## way. I.e., make a new wrappable class ListLikeGenerator
|
||||
## that stores the elements we've taken out from the
|
||||
## generator. Such a class would also eliminate the need of
|
||||
## _generator_or_false.
|
||||
orig_recurrence_set = self._validate_and_normalize_component(component)
|
||||
|
||||
## Early return if no work needed
|
||||
if expand_only and not self.expand:
|
||||
return orig_recurrence_set
|
||||
|
||||
## Ensure timezone is set. Ensure start and end are datetime objects.
|
||||
for attr in ("start", "end", "alarm_start", "alarm_end"):
|
||||
value = getattr(self, attr)
|
||||
if value:
|
||||
if not isinstance(value, datetime):
|
||||
logging.warning(
|
||||
"Date-range searches not well supported yet; use datetime rather than dates"
|
||||
)
|
||||
setattr(self, attr, _normalize_dt(value))
|
||||
|
||||
## recurrence_set is our internal generator/iterator containing
|
||||
## everything that hasn't been filtered out yet (in most
|
||||
## cases, the generator will yield either one or zero
|
||||
## components - but recurrences are tricky). orig_recurrence_set
|
||||
## is a list, so iterations on recurrence_set will not affect
|
||||
## orig_recurrence_set
|
||||
recurrence_set = orig_recurrence_set
|
||||
|
||||
## Recurrences may be a night-mare.
|
||||
## I'm not sure if this is very well thought through, but my thoughts now are:
|
||||
|
||||
## 1) we need to check if the not-expanded base element
|
||||
## matches any non-date-related search-filters before doing
|
||||
## anything else. We do this with a recursive call with an
|
||||
## internal parameter _ignore_rrule_and_time set.
|
||||
|
||||
## 2) If the base element does not match, we may probably skip
|
||||
## expansion (only problem I can see with it is some filtering
|
||||
## like "show me all events happening on a Monday" - which is
|
||||
## anyway not supported as for now). It's important to skip
|
||||
## expansion, otherwise we may end up doing an infinite (or
|
||||
## very-huge) expansion just to verify that we can return
|
||||
## None/False
|
||||
|
||||
## 3) if the base element does not match, there may still be
|
||||
## special cases matching. We solve this by running the
|
||||
## filter logics on all but the
|
||||
|
||||
## 4) if the base element matches, we may need to expand it
|
||||
first = orig_recurrence_set[0]
|
||||
if not expand_only and "RRULE" in first and not _ignore_rrule_and_time:
|
||||
## TODO: implement logic above
|
||||
base_element_match = self.check_component(first, _ignore_rrule_and_time=True)
|
||||
if not base_element_match:
|
||||
## Base element is to be ignored. recurrence_set is still a list
|
||||
recurrence_set = recurrence_set[1:] # Remove first element (base), keep exceptions
|
||||
|
||||
## self.include_completed should default to False if todo is explicity set,
|
||||
## otherwise True
|
||||
if self.include_completed is None:
|
||||
self.include_completed = not self.todo
|
||||
|
||||
## Component type flags are a bit difficult. In the CalDAV library,
|
||||
## if all of them are None, everything should be returned. If only
|
||||
## one of them is True, then only this kind of component type is
|
||||
## returned. In any other case, no guarantees of correctness are given.
|
||||
|
||||
## Let's skip the last remark and try to make a generic and
|
||||
## correct solution from the start: 1) if any flags are True,
|
||||
## then consider flags set as None as False. 2) if any flags
|
||||
## are still None, then consider those to be True. 3) List
|
||||
## the flags that are True as acceptable component types:
|
||||
|
||||
comptypesl = ("todo", "event", "journal")
|
||||
if any(getattr(self, x) for x in comptypesl):
|
||||
for x in comptypesl:
|
||||
if getattr(self, x) is None:
|
||||
setattr(self, x, False)
|
||||
else:
|
||||
for x in comptypesl:
|
||||
if getattr(self, x) is None:
|
||||
setattr(self, x, True)
|
||||
|
||||
comptypesu = set([f"V{x.upper()}" for x in comptypesl if getattr(self, x)])
|
||||
|
||||
## if expand_only, expand all comptypes, otherwise only the comptypes specified in the filters
|
||||
comptypes_for_expansion = ["VTODO", "VEVENT", "VJOURNAL"] if expand_only else comptypesu
|
||||
|
||||
if not _ignore_rrule_and_time and "RRULE" in first:
|
||||
recurrence_set = self._expand_recurrences(recurrence_set, comptypes_for_expansion)
|
||||
|
||||
if not expand_only:
|
||||
## OPTIMIZATION TODO: If the object was recurring, we should
|
||||
## probably trust recur.between to do the right thing?
|
||||
if not _ignore_rrule_and_time and (self.start or self.end):
|
||||
recurrence_set = (x for x in recurrence_set if self._check_range(x))
|
||||
|
||||
## This if is just to save some few CPU cycles - skip filtering if it's not needed
|
||||
if not all(getattr(self, x) for x in comptypesl):
|
||||
recurrence_set = (x for x in recurrence_set if x.name in comptypesu)
|
||||
|
||||
## Filter based on include_completed setting
|
||||
recurrence_set = (x for x in recurrence_set if self._check_completed_filter(x))
|
||||
|
||||
## Apply property filters
|
||||
if self._property_filters or self._property_operator:
|
||||
recurrence_set = (x for x in recurrence_set if self._check_property_filters(x))
|
||||
|
||||
## Apply alarm filters
|
||||
if not _ignore_rrule_and_time and (self.alarm_start or self.alarm_end):
|
||||
recurrence_set = (x for x in recurrence_set if self._check_alarm_range(x))
|
||||
|
||||
if self.expand:
|
||||
## TODO: fix wrapping, if needed
|
||||
return _iterable_or_false(recurrence_set)
|
||||
else:
|
||||
if next(recurrence_set, None):
|
||||
return orig_recurrence_set
|
||||
else:
|
||||
return None
|
||||
|
||||
def filter(
|
||||
self, components: list[Calendar | Component], split_expanded: bool = False
|
||||
) -> list[Calendar | Component]:
|
||||
"""Filter components according to the search criteria, optionally expanding recurrences.
|
||||
|
||||
This method does not modify the input list. It returns a new list with
|
||||
filtered components.
|
||||
|
||||
:param components: List of Calendar or Component objects to filter
|
||||
:param split_expanded: If True and recurrences are expanded, return each
|
||||
recurrence as a separate Calendar object. If False, all matching
|
||||
recurrences from a single input will be returned in one Calendar.
|
||||
:return: New list containing only components that match the filter criteria
|
||||
|
||||
Examples:
|
||||
searcher = Searcher(event=True, start=datetime(2025, 1, 1))
|
||||
searcher.add_property_filter("SUMMARY", "meeting", operator="contains")
|
||||
filtered = searcher.filter(calendars) # Returns matching calendars
|
||||
|
||||
# With split_expanded=True, each recurrence becomes a separate Calendar
|
||||
searcher.expand = True
|
||||
split_results = searcher.filter(calendars, split_expanded=True)
|
||||
"""
|
||||
results: list[Calendar | Component] = []
|
||||
|
||||
for component in components:
|
||||
# check_component returns an iterable of matching components (possibly expanded)
|
||||
matched = self.check_component(component)
|
||||
|
||||
# Convert to list to check if we got any results
|
||||
if matched:
|
||||
matched_list = list(matched)
|
||||
if not matched_list:
|
||||
continue
|
||||
|
||||
if split_expanded and len(matched_list) > 1:
|
||||
# Split expanded recurrences into separate Calendar objects
|
||||
# Each recurrence becomes its own Calendar
|
||||
for comp in matched_list:
|
||||
if isinstance(comp, Timezone):
|
||||
continue
|
||||
|
||||
# Create new Calendar for this recurrence
|
||||
new_cal = Calendar()
|
||||
|
||||
# If original was a Calendar, copy its properties
|
||||
if isinstance(component, Calendar):
|
||||
for key, value in component.items():
|
||||
new_cal[key] = value
|
||||
|
||||
# Preserve timezone components
|
||||
timezones = [
|
||||
tz for tz in component.subcomponents if isinstance(tz, Timezone)
|
||||
]
|
||||
for tz in timezones:
|
||||
from copy import deepcopy
|
||||
|
||||
new_cal.add_component(deepcopy(tz))
|
||||
|
||||
# Add the matched component
|
||||
from copy import deepcopy
|
||||
|
||||
new_cal.add_component(deepcopy(comp))
|
||||
results.append(new_cal)
|
||||
else:
|
||||
# Return as-is, or create a Calendar with all matched components
|
||||
if isinstance(component, Calendar):
|
||||
# Create new Calendar with matched components
|
||||
new_cal = Calendar()
|
||||
for key, value in component.items():
|
||||
new_cal[key] = value
|
||||
|
||||
# Preserve timezone components
|
||||
timezones = [
|
||||
tz for tz in component.subcomponents if isinstance(tz, Timezone)
|
||||
]
|
||||
for tz in timezones:
|
||||
from copy import deepcopy
|
||||
|
||||
new_cal.add_component(deepcopy(tz))
|
||||
|
||||
# Add all matched components
|
||||
for comp in matched_list:
|
||||
if not isinstance(comp, Timezone):
|
||||
from copy import deepcopy
|
||||
|
||||
new_cal.add_component(deepcopy(comp))
|
||||
|
||||
results.append(new_cal)
|
||||
else:
|
||||
# Component was passed directly, return matched components
|
||||
for comp in matched_list:
|
||||
from copy import deepcopy
|
||||
|
||||
results.append(deepcopy(comp))
|
||||
|
||||
return results
|
||||
|
||||
def filter_calendar(self, calendar: Calendar) -> Calendar:
|
||||
"""Filter subcomponents within a Calendar object according to search criteria.
|
||||
|
||||
This method does not modify the input Calendar. It returns a new Calendar
|
||||
containing only the subcomponents that match the filter criteria.
|
||||
|
||||
:param calendar: Calendar object containing multiple subcomponents to filter
|
||||
:return: New Calendar object with only matching subcomponents, or None if no matches
|
||||
|
||||
Examples:
|
||||
searcher = Searcher(event=True, start=datetime(2025, 1, 1))
|
||||
searcher.add_property_filter("SUMMARY", "meeting", operator="contains")
|
||||
filtered_cal = searcher.filter_calendar(calendar) # Returns Calendar with matching events
|
||||
"""
|
||||
from copy import deepcopy
|
||||
|
||||
# Separate timezone components from other components
|
||||
timezones = [comp for comp in calendar.subcomponents if isinstance(comp, Timezone)]
|
||||
other_components = [
|
||||
comp for comp in calendar.subcomponents if not isinstance(comp, Timezone)
|
||||
]
|
||||
|
||||
# Filter each component
|
||||
matching_components = []
|
||||
for comp in other_components:
|
||||
matched = self.check_component(comp)
|
||||
if matched:
|
||||
matched_list = list(matched)
|
||||
if matched_list:
|
||||
# Add all matching occurrences (expanded or not)
|
||||
matching_components.extend(matched_list)
|
||||
|
||||
# If no matches, return None or empty calendar
|
||||
if not matching_components:
|
||||
return None
|
||||
|
||||
# Create new calendar with matching components
|
||||
new_calendar = Calendar()
|
||||
|
||||
# Copy calendar-level properties
|
||||
for key, value in calendar.items():
|
||||
new_calendar[key] = value
|
||||
|
||||
# Add timezone components first
|
||||
for tz in timezones:
|
||||
new_calendar.add_component(deepcopy(tz))
|
||||
|
||||
# Add matching components
|
||||
for comp in matching_components:
|
||||
if not isinstance(comp, Timezone):
|
||||
new_calendar.add_component(deepcopy(comp))
|
||||
|
||||
return new_calendar
|
||||
|
||||
def sort(
|
||||
self, components: list[Component | CalendarObjectResource]
|
||||
) -> list[Component | CalendarObjectResource]:
|
||||
"""Sort calendar objects according to configured sort keys.
|
||||
|
||||
This method does not modify the input list. It returns a new sorted list.
|
||||
|
||||
(the idea with this is that the input may be a generator)
|
||||
|
||||
:param components: List of Calendar or CalendarObjectResource objects to sort
|
||||
:return: New sorted list
|
||||
|
||||
Examples:
|
||||
searcher = Searcher()
|
||||
searcher.add_sort_key("DTSTART")
|
||||
sorted_events = searcher.sort(events) # Returns new sorted list
|
||||
"""
|
||||
if self._sort_keys:
|
||||
return sorted(components, key=self.sorting_value)
|
||||
else:
|
||||
return components.copy()
|
||||
|
||||
def sort_calendar(self, calendar: Calendar) -> Calendar:
|
||||
"""Sort subcomponents within a Calendar object according to configured sort keys.
|
||||
|
||||
This method does not modify the input Calendar. It returns a new Calendar
|
||||
with sorted subcomponents (excluding VTIMEZONE components).
|
||||
|
||||
:param calendar: Calendar object containing multiple subcomponents to sort
|
||||
:return: New Calendar object with sorted subcomponents
|
||||
|
||||
Examples:
|
||||
searcher = Searcher()
|
||||
searcher.add_sort_key("DTSTART")
|
||||
sorted_cal = searcher.sort_calendar(calendar) # Returns new Calendar with sorted events
|
||||
"""
|
||||
if not self._sort_keys:
|
||||
# No sorting needed, return a copy
|
||||
from copy import deepcopy
|
||||
|
||||
return deepcopy(calendar)
|
||||
|
||||
# Separate timezone components from other components
|
||||
from icalendar import Timezone
|
||||
|
||||
timezones = [comp for comp in calendar.subcomponents if isinstance(comp, Timezone)]
|
||||
other_components = [
|
||||
comp for comp in calendar.subcomponents if not isinstance(comp, Timezone)
|
||||
]
|
||||
|
||||
# Sort the non-timezone components
|
||||
sorted_components = sorted(other_components, key=self.sorting_value)
|
||||
|
||||
# Create new calendar with sorted components
|
||||
from copy import deepcopy
|
||||
|
||||
new_calendar = Calendar()
|
||||
|
||||
# Copy calendar-level properties
|
||||
for key, value in calendar.items():
|
||||
new_calendar[key] = value
|
||||
|
||||
# Add timezone components first
|
||||
for tz in timezones:
|
||||
new_calendar.add_component(deepcopy(tz))
|
||||
|
||||
# Add sorted components
|
||||
for comp in sorted_components:
|
||||
new_calendar.add_component(deepcopy(comp))
|
||||
|
||||
return new_calendar
|
||||
|
||||
def sorting_value(self, component: Component | CalendarObjectResource) -> tuple:
|
||||
"""Returns a sortable value from the component, based on the sort keys
|
||||
|
||||
The component may be an icalendar.Calendar, an
|
||||
icalendar.Component (i.e. icalendar.Event) or an
|
||||
caldav.CalendarObjectResource (i.e. caldav.Event).
|
||||
"""
|
||||
ret = []
|
||||
## TODO: this logic has been moved more or less as-is from the
|
||||
## caldav library. It may need some rethinking and QA work.
|
||||
|
||||
## TODO: we disregard any complexity wrg of recurring events
|
||||
component = self._unwrap(component)
|
||||
if isinstance(component, Calendar):
|
||||
not_tz_components = (x for x in component.subcomponents if not isinstance(x, Timezone))
|
||||
comp = next(not_tz_components)
|
||||
else:
|
||||
comp = component
|
||||
|
||||
defaults = {
|
||||
## TODO: all possible non-string sort attributes needs to be listed here, otherwise we will get type errors when comparing objects with the property defined vs undefined (or maybe we should make an "undefined" object that always will compare below any other type? Perhaps there exists such an object already?)
|
||||
"due": "2050-01-01",
|
||||
"dtstart": "1970-01-01",
|
||||
"priority": 0,
|
||||
"status": {
|
||||
"VTODO": "NEEDS-ACTION",
|
||||
"VJOURNAL": "FINAL",
|
||||
"VEVENT": "TENTATIVE",
|
||||
}[comp.name],
|
||||
"category": "",
|
||||
## Usage of strftime is a simple way to ensure there won't be
|
||||
## problems if comparing dates with timestamps
|
||||
"isnt_overdue": not (
|
||||
"due" in comp
|
||||
and comp["due"].dt.strftime("%F%H%M%S") < datetime.now().strftime("%F%H%M%S")
|
||||
),
|
||||
"hasnt_started": (
|
||||
"dtstart" in comp
|
||||
and comp["dtstart"].dt.strftime("%F%H%M%S") > datetime.now().strftime("%F%H%M%S")
|
||||
),
|
||||
}
|
||||
for sort_key, reverse in self._sort_keys:
|
||||
if sort_key == "categories":
|
||||
val = comp.categories
|
||||
else:
|
||||
val = comp.get(sort_key, None)
|
||||
if val is None:
|
||||
ret.append(defaults.get(sort_key, ""))
|
||||
continue
|
||||
|
||||
# Track if this is a text property (for collation)
|
||||
# Apply collation BEFORE datetime/category conversion
|
||||
is_text_property = isinstance(val, str) and sort_key in self._sort_collation
|
||||
|
||||
if hasattr(val, "dt"):
|
||||
val = val.dt
|
||||
if hasattr(val, "strftime"):
|
||||
val = val.strftime("%F%H%M%S")
|
||||
|
||||
## TODO: I don't have time to fix test code for this at
|
||||
## the moment (but the bug in v1.0.0 was caught by cyrus
|
||||
## test code in caldav library, cyrus splits the
|
||||
## categories field, and this is allowed according to RFC
|
||||
## 7986, section 5.6) TODO: we should fix tests not only
|
||||
## for sorting lists and categories, but also filtering on
|
||||
## lists and multi-line categories.
|
||||
if isinstance(val, list):
|
||||
## sorting lists may be difficult. As I understand
|
||||
## the standard, the order of the list is not
|
||||
## significant - the order of the elements may be
|
||||
## reshuffled, and the icalendar component will
|
||||
## semantically be equivalent. To get deterministic
|
||||
## sorting of semantically equivalent components, I've
|
||||
## decided to sort the list (TODO: collation support?)
|
||||
val = sorted(val)
|
||||
|
||||
## TODO: what if the list contains numbers?
|
||||
val = ",".join([str(x) for x in val])
|
||||
|
||||
# Apply collation only to text properties (not datetime strings)
|
||||
if is_text_property and isinstance(val, str):
|
||||
collation = self._sort_collation[sort_key]
|
||||
locale = self._sort_locale.get(sort_key)
|
||||
case_sensitive = self._sort_case_sensitive.get(sort_key, True)
|
||||
sort_key_fn = get_sort_key_function(collation, case_sensitive, locale)
|
||||
val = sort_key_fn(val)
|
||||
|
||||
if reverse:
|
||||
if isinstance(val, (str, bytes)):
|
||||
if isinstance(val, str):
|
||||
val = val.encode()
|
||||
val = bytes(b ^ 0xFF for b in val)
|
||||
else:
|
||||
val = -val
|
||||
ret.append(val)
|
||||
|
||||
return ret
|
||||
|
||||
def _unwrap(self, component: Calendar | CalendarObjectResource) -> Calendar:
|
||||
"""
|
||||
To support the caldav library (and possibly other libraries where the
|
||||
icalendar component is wrapped)
|
||||
"""
|
||||
try:
|
||||
component = component.icalendar_instance
|
||||
except AttributeError:
|
||||
pass
|
||||
if isinstance(component, Component) and not isinstance(component, Calendar):
|
||||
cal = Calendar()
|
||||
cal.add_component(component)
|
||||
component = cal
|
||||
return component
|
||||
|
||||
def _validate_and_normalize_component(
|
||||
self, component: Calendar | Component | CalendarObjectResource
|
||||
) -> list[Component]:
|
||||
"""This method serves two purposes:
|
||||
|
||||
1) Be liberal in what "component" it accepts and return
|
||||
something well-defined. For instance, coponent may be a
|
||||
wrapped object (caldav.Event), an icalendar.Calendar or an
|
||||
icalendar.Event. The return value will always be a list of
|
||||
icalendar components (i.e. Event), and Timezone components will
|
||||
be removed.
|
||||
|
||||
2) Do some verification that the "component" is as expected
|
||||
and raise a ValueError if not. The "component" should either
|
||||
be one single component or a recurrence set. A recurrence set
|
||||
should conform to those rules:
|
||||
|
||||
2.1) All components in the recurrence set should have the same UID
|
||||
|
||||
2.2) First element ("master") of the recurrence set may have the RRULE
|
||||
property set
|
||||
|
||||
2.3) Any following elements of a recurrence set ("exception
|
||||
recurrences") should have the RECURRENCE-ID property set.
|
||||
|
||||
2.4) (there are more properties that may only be set in the
|
||||
master or only in the recurrences, but currently we don't do
|
||||
more checking than this)
|
||||
|
||||
As for now, we do not support component to be a generator or a
|
||||
list, and things will blow up if component.subcomponents is a
|
||||
generator yielding infinite or too many subcomponents.
|
||||
|
||||
"""
|
||||
|
||||
component = self._unwrap(component)
|
||||
components = [x for x in component.subcomponents if not isinstance(x, Timezone)]
|
||||
|
||||
## We shouldn't get here. There should always be a valid component.
|
||||
if not len(components):
|
||||
raise ValueError("Empty component?")
|
||||
first = components[0]
|
||||
|
||||
## A recurrence set should always be one "master" with
|
||||
## rrule-id set, followed by zero or more objects without
|
||||
## rrule-id but with recurrence-id set
|
||||
if len(components) > 1:
|
||||
if (
|
||||
("RRULE" not in components[0] and "RECURRENCE-ID" not in components[0])
|
||||
or not all("recurrence-id" in x for x in components[1:])
|
||||
or any("RRULE" in x for x in components[1:])
|
||||
):
|
||||
raise ValueError(
|
||||
"Expected a valid recurrence set, either with one master component followed with special recurrences or with only occurrences"
|
||||
)
|
||||
|
||||
## components should typically be a list with only one component.
|
||||
## if there are more components, it should be a recurrence set
|
||||
## one of the things identifying a recurrence set is that the
|
||||
## uid is the same for all components in the set
|
||||
if any(x for x in components if x["uid"] != first["uid"]):
|
||||
raise ValueError(
|
||||
"Input parameter component is supposed to contain a single component or a recurrence set - but multiple UIDs found"
|
||||
)
|
||||
return components
|
||||
|
||||
def _expand_recurrences(
|
||||
self, recurrence_set: list[Component], comptypesu: set[str]
|
||||
) -> Iterable[Component]:
|
||||
"""Expand recurring events within the searcher's time range.
|
||||
|
||||
Ensures expanded occurrences comply with RFC 5545:
|
||||
- Each occurrence has RECURRENCE-ID set
|
||||
- Each occurrence does NOT have RRULE (RRULE and RECURRENCE-ID are mutually exclusive)
|
||||
|
||||
:param recurrence_set: List of calendar components to expand
|
||||
:param comptypesu: Set of component type strings (e.g., {"VEVENT", "VTODO"})
|
||||
:return: Iterable of expanded component instances
|
||||
"""
|
||||
cal = Calendar()
|
||||
for x in recurrence_set:
|
||||
cal.add_component(x)
|
||||
recur = recurring_ical_events.of(cal, components=comptypesu)
|
||||
|
||||
# Use local variables for start/end to avoid modifying searcher state
|
||||
start = self.start if self.start else _normalize_dt(DATE_MIN_DT)
|
||||
end = self.end if self.end else _normalize_dt(DATE_MAX_DT)
|
||||
|
||||
return recur.between(start, end)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Utility functions for icalendar-searcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Iterator
|
||||
from datetime import date, datetime
|
||||
from itertools import tee
|
||||
|
||||
from icalendar.prop import TypesFactory
|
||||
|
||||
## We need an instance of the icalendar.prop.TypesFactory class.
|
||||
## We'll make a global instance rather than instantiate it for
|
||||
## every loop iteration
|
||||
types_factory = TypesFactory()
|
||||
|
||||
|
||||
## Helper to normalize date/datetime for comparison
|
||||
## (I feel this one is duplicated over many projects ...)
|
||||
def _normalize_dt(dt_value: date | datetime) -> datetime:
|
||||
"""Convert date to datetime for comparison, or return datetime as-is with timezone."""
|
||||
if dt_value is None:
|
||||
return None
|
||||
## If it's a date (not datetime), convert to datetime at midnight
|
||||
if hasattr(dt_value, "year") and not hasattr(dt_value, "hour"):
|
||||
from datetime import time
|
||||
|
||||
return datetime.combine(dt_value, time.min).astimezone()
|
||||
## TODO: we should probably do some research on the default calendar timezone,
|
||||
## which may not be the same as the local timezone ... uh ... timezones are
|
||||
## difficult.
|
||||
return dt_value.astimezone()
|
||||
|
||||
|
||||
## Helper - generators are generally more neat than lists,
|
||||
## but bool(x) will always return True. I'd like to verify
|
||||
## that a generator is not empty, without side effects.
|
||||
def _iterable_or_false(g: Iterable, _debug_print_peek: bool = False) -> bool | Iterable:
|
||||
"""This method will return False if it's not possible to get an
|
||||
item from the iterable (which can only be done by utilizing
|
||||
`next`). It will then return a new iterator that behaves like
|
||||
the original iterator (like if `next` wasn't used).
|
||||
|
||||
Uses itertools.tee to create two independent iterators: one for
|
||||
checking if the iterator is empty, and one to return.
|
||||
"""
|
||||
if not isinstance(g, Iterator):
|
||||
return bool(g) and g
|
||||
|
||||
# Create two independent iterators from the input
|
||||
check_it, result_it = tee(g)
|
||||
|
||||
try:
|
||||
my_value = next(check_it)
|
||||
if _debug_print_peek:
|
||||
print(my_value)
|
||||
return result_it # Return the untouched iterator
|
||||
except StopIteration:
|
||||
return False
|
||||
Reference in New Issue
Block a user