Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.2.0] - 2025-11-24

Text filtering can be done both case-sensitive and case-insensitive - we need to support both of them. And we also may need to support various collations. DISCLAIMER: While I could easily add case insensitive/sensitive filtering myself, as soon as collations was tossed into the equation I considered the AI could do it better and faster than me. So 0.2.0 is AI-generated code, but curated by a human.

### Added
- **Collation support for text searches and sorting**: Added comprehensive support for both case-sensitive and case-insensitive text comparisons
- New `case_sensitive` parameter in `add_property_filter()` and `add_sort_key()` methods for simple API
- New `Collation` enum for power users with support for `BINARY`, `CASE_INSENSITIVE`, `UNICODE`, and `LOCALE` collation strategies
- Optional PyICU integration for advanced Unicode and locale-aware collation (install with `pip install 'icalendar-searcher[collation]'`)
- Collation support for all text properties (SUMMARY, LOCATION, DESCRIPTION, etc.)
- Collation support for CATEGORIES property with special handling
- Graceful fallback when PyICU is not installed

### Changed
- **BREAKING**: Default text search behavior is now case-sensitive (was case-insensitive)
- Category searches are now case-sensitive by default (was implicitly case-sensitive, now explicit)

### Fixed
- Improved type handling in property filters to correctly distinguish between text properties and other types

## [0.1.10] - Previous Release
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ packages = [{include = "icalendar_searcher", from = "src"}]
icalendar = ">=6.0"
recurring-ical-events = ">=3.8.0"

[tool.poetry.extras]
collation = ["PyICU"]

[tool.poetry-dynamic-versioning]
enable = true
vcs = "git"
Expand All @@ -43,7 +46,8 @@ build-backend = "poetry_dynamic_versioning.backend"
dev = [
"ruff",
"pre-commit",
"pytest (>=8.0.0,<10.0.0)"
"pytest (>=8.0.0,<10.0.0)",
"PyICU"
]

[tool.ruff]
Expand Down
3 changes: 2 additions & 1 deletion src/icalendar_searcher/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
## for python 3.9 support
from __future__ import annotations

from .collation import Collation
from .searcher import Searcher

__all__ = ["Searcher"]
__all__ = ["Searcher", "Collation"]

# Version is set by poetry-dynamic-versioning at build time
try:
Expand Down
206 changes: 206 additions & 0 deletions src/icalendar_searcher/collation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""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")
"""

BINARY = "binary"
"""Exact byte-for-byte comparison (case-sensitive)."""

CASE_INSENSITIVE = "case_insensitive"
"""Case-insensitive comparison using Python's str.lower()."""

UNICODE = "unicode"
"""Unicode Collation Algorithm (UCA) root collation.
Requires PyICU to be installed."""

LOCALE = "locale"
"""Locale-aware collation using CLDR rules.
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.BINARY,
locale: str | None = None,
) -> Callable[[str, str], bool]:
"""Get a collation function for substring matching.

Args:
collation: The collation strategy to use
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.CASE_INSENSITIVE)
>>> match_fn("test", "This is a TEST")
True
"""
if collation == Collation.BINARY:
return _binary_contains

elif collation == Collation.CASE_INSENSITIVE:
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)
else:
# UNICODE collation uses root locale
return _get_icu_contains(None)

else:
raise CollationError(f"Unknown collation: {collation}")


def get_sort_key_function(
collation: Collation = Collation.BINARY,
locale: str | None = None,
) -> Callable[[str], bytes]:
"""Get a collation function for generating sort keys.

Args:
collation: The collation strategy to use
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.CASE_INSENSITIVE)
>>> sorted(["Zebra", "apple", "Banana"], key=sort_key_fn)
['apple', 'Banana', 'Zebra']
"""
if collation == Collation.BINARY:
return lambda s: s.encode("utf-8")

elif collation == Collation.CASE_INSENSITIVE:
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)
else:
# UNICODE collation uses root locale
return _get_icu_sort_key(None)

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) -> 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 case-insensitive matching as an approximation.

Future enhancement: Implement proper collation-aware substring matching.
"""

def icu_contains(needle: str, haystack: str) -> bool:
"""Check if needle is in haystack using case-insensitive matching.

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 case-insensitive as a reasonable approximation
return needle.lower() in haystack.lower()

return icu_contains


def _get_icu_sort_key(locale: str | None) -> Callable[[str], bytes]:
"""Get ICU-based sort key function.

Creates a collator instance and returns a function that generates sort keys.
"""
icu_locale = ICULocale(locale) if locale else ICULocale.getRoot()
collator = ICUCollator.createInstance(icu_locale)

def icu_sort_key(s: str) -> bytes:
"""Generate ICU collation sort key."""
return collator.getSortKey(s)

return icu_sort_key
23 changes: 20 additions & 3 deletions src/icalendar_searcher/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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


Expand All @@ -20,6 +21,8 @@ class FilterMixin:
- 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:
Expand Down Expand Up @@ -166,6 +169,10 @@ def _check_property_filters(self, component: Component) -> bool:
filter_value = self._property_filters.get(key)
comp_value = component.get(key)

# Get collation settings for this property
collation = self._property_collation.get(key, Collation.BINARY)
locale = self._property_locale.get(key)

## Category needs some special handling
if key.lower() == "categories" and comp_value is not None and filter_value is not None:
if isinstance(filter_value, vCategory):
Expand Down Expand Up @@ -194,15 +201,25 @@ def _check_property_filters(self, component: Component) -> bool:
if key not in component:
return False
if key.lower() == "categories":
# Get collation function for category matching
collation_fn = get_collation_function(collation, locale)
if isinstance(filter_value, str):
return any(filter_value in x for x in comp_value)
# Check if filter_value is in any category using collation
return any(collation_fn(filter_value, x) for x in comp_value)
elif isinstance(filter_value, set):
return not filter_value - comp_value
# Check if all filter values are in comp_value using collation
for fv in filter_value:
if not any(collation_fn(fv, cv) for cv in comp_value):
return False
return True

## Convert to string for substring matching
comp_str = str(comp_value)
filter_str = str(filter_value)
if filter_str.lower() not in comp_str.lower():

# Use collation function for text matching
collation_fn = get_collation_function(collation, locale)
if not collation_fn(filter_str, comp_str):
return False
elif operator == "==":
## Property should exactly match the filter value
Expand Down
Loading
Loading