From 252be6d4b7eb0de80f399bdcc8e040f1a566554e Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 29 Nov 2025 06:02:04 -0800 Subject: [PATCH] feat: Add text normalization for improved search functionality in CalibreOPDSClient --- abogen/integrations/calibre_opds.py | 44 ++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/abogen/integrations/calibre_opds.py b/abogen/integrations/calibre_opds.py index 8aba614..e5aa104 100644 --- a/abogen/integrations/calibre_opds.py +++ b/abogen/integrations/calibre_opds.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses import html import re +import unicodedata from collections import deque from dataclasses import dataclass, field from pathlib import PurePosixPath @@ -922,6 +923,14 @@ class CalibreOPDSClient: return link.title.strip() return "" + @staticmethod + def _normalize_text(text: str) -> str: + if not text: + return "" + # Normalize unicode characters to their base form (e.g. é -> e) + normalized = unicodedata.normalize('NFKD', text).encode('ASCII', 'ignore').decode('utf-8') + return normalized.lower().strip() + @staticmethod def _alphabet_letter_for_entry(entry: OPDSEntry, mode: str) -> Optional[str]: source = CalibreOPDSClient._alphabet_source(entry, mode) @@ -929,45 +938,54 @@ class CalibreOPDSClient: return None if mode == "title": source = CalibreOPDSClient._strip_leading_article(source) - source = re.sub(r"^[^0-9A-Za-z]+", "", source) - if not source: + + # Normalize to handle accents (É -> E) + normalized_source = unicodedata.normalize('NFKD', source).encode('ASCII', 'ignore').decode('utf-8') + + cleaned = re.sub(r"^[^0-9A-Za-z]+", "", normalized_source) + if not cleaned: return "#" - initial = source[0] + initial = cleaned[0] if initial.isalpha(): return initial.upper() if initial.isdigit(): return "#" - normalized = initial.upper() - return normalized if normalized.isalpha() else "#" + return "#" @staticmethod def _entry_matches_query(entry: OPDSEntry, tokens: List[str]) -> bool: if not tokens: return True search_fragments: List[str] = [] + if entry.title: - search_fragments.append(entry.title.strip().lower()) + search_fragments.append(CalibreOPDSClient._normalize_text(entry.title)) if entry.series: - search_fragments.append(entry.series.strip().lower()) + search_fragments.append(CalibreOPDSClient._normalize_text(entry.series)) + for author in entry.authors: cleaned = (author or "").strip() if not cleaned: continue - lower_value = cleaned.lower() - search_fragments.append(lower_value) - for part in re.split(r"[\s,]+", lower_value): + normalized_author = CalibreOPDSClient._normalize_text(cleaned) + search_fragments.append(normalized_author) + for part in re.split(r"[\s,]+", normalized_author): part = part.strip() if part: search_fragments.append(part) + if not search_fragments: return False + + # Check if all tokens match at least one fragment + # Tokens are already normalized in _filter_feed_entries return all(any(token in fragment for fragment in search_fragments) for token in tokens) def _filter_feed_entries(self, feed: OPDSFeed, query: str) -> OPDSFeed: - normalized = (query or "").strip().lower() - if not normalized: + normalized_query = CalibreOPDSClient._normalize_text(query) + if not normalized_query: return feed - tokens = [token for token in re.split(r"\s+", normalized) if token] + tokens = [token for token in re.split(r"\s+", normalized_query) if token] if not tokens: return feed filtered = [entry for entry in feed.entries if self._entry_matches_query(entry, tokens)]