From a5645392a5a993d60acc5bce8920087c001bd576 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:16:57 -0700 Subject: [PATCH] Fix: accept Whoosh-era abbreviated relative-date units (yrs, mos, wks, etc) in search queries (#13486) --- src/documents/search/_translate.py | 38 +++++++++++-- src/documents/tests/search/test_translate.py | 60 ++++++++++++++++++++ 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/documents/search/_translate.py b/src/documents/search/_translate.py index a3ec729fd..f020f1c89 100644 --- a/src/documents/search/_translate.py +++ b/src/documents/search/_translate.py @@ -391,14 +391,40 @@ _NOW_COMPACT_RE = regex.compile( regex.IGNORECASE, ) -# Matches "±N " Whoosh-style offsets (e.g. -7 days, -1 week, +3 hours) -# Unit is singular or plural; sign prefix is mandatory. +# Matches "±N " Whoosh-style offsets (e.g. -7 days, -1 week, +3 hours). +# Whoosh's own date parser (qparser.dateparse.PlusMinus) additionally accepted +# abbreviated unit spellings (e.g. "yrs", "yr", "y", "mos", "wks", "hrs", "mins", +# "secs"); saved views/searches created under the old Whoosh backend can still +# contain those tokens (e.g. "-999yrs"), so they are accepted here too and +# normalized to a canonical unit via _UNIT_ALIASES below. _NOW_SPACED_RE = regex.compile( r"^(?P[+-])(?P\d+)\s*" - r"(?Psecond|minute|hour|day|week|month|year)s?$", + r"(?Pyears|year|yrs|yr|ys|y" + r"|months|month|mons|mon|mos|mo" + r"|weeks|week|wks|wk|ws|w" + r"|days|day|dys|dy|ds|d" + r"|hours|hour|hrs|hr|hs|h" + r"|minutes|minute|mins|min|ms|m" + r"|seconds|second|secs|sec|s)$", regex.IGNORECASE, ) +# Maps every accepted unit spelling (including Whoosh-era abbreviations) to the +# canonical unit name used as a key into the delta map in _resolve_relative_bound. +_UNIT_ALIASES: dict[str, str] = { + alias: canonical + for canonical, aliases in { + "year": ("years", "year", "yrs", "yr", "ys", "y"), + "month": ("months", "month", "mons", "mon", "mos", "mo"), + "week": ("weeks", "week", "wks", "wk", "ws", "w"), + "day": ("days", "day", "dys", "dy", "ds", "d"), + "hour": ("hours", "hour", "hrs", "hr", "hs", "h"), + "minute": ("minutes", "minute", "mins", "min", "ms", "m"), + "second": ("seconds", "second", "secs", "sec", "s"), + }.items() + for alias in aliases +} + def _resolve_relative_bound(token: str) -> datetime | None: """ @@ -407,7 +433,9 @@ def _resolve_relative_bound(token: str) -> datetime | None: Supported forms: - ``now`` -> current UTC instant - ``now+/-d/h/m`` -> now +/- timedelta (d=days, h=hours, m=minutes) - - ``±N `` -> now +/- delta; month/year use relativedelta + - ``±N `` -> now +/- delta; month/year use relativedelta; + unit also accepts Whoosh-era abbreviations + (e.g. "yrs", "mos", "wks", "hrs", "mins", "secs") """ stripped = token.strip() low = stripped.lower() @@ -435,7 +463,7 @@ def _resolve_relative_bound(token: str) -> datetime | None: if m: sign = 1 if m.group("sign") == "+" else -1 n = int(m.group("n")) - unit = m.group("unit").lower() + unit = _UNIT_ALIASES[m.group("unit").lower()] delta_map: dict[str, timedelta | relativedelta] = { "second": timedelta(seconds=n), "minute": timedelta(minutes=n), diff --git a/src/documents/tests/search/test_translate.py b/src/documents/tests/search/test_translate.py index 70611aecc..e319564d1 100644 --- a/src/documents/tests/search/test_translate.py +++ b/src/documents/tests/search/test_translate.py @@ -488,6 +488,66 @@ class TestRelativeRanges: index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS) +@pytest.mark.search +class TestWhooshUnitAbbreviations: + """ + Whoosh's PlusMinus date grammar accepted abbreviated unit spellings + (e.g. "yrs", "mos", "wks", "hrs", "mins", "secs"); saved views/searches + created under the old Whoosh backend can contain those tokens (see + https://github.com/paperless-ngx/paperless-ngx/issues/13482), so the + Tantivy translator must still accept them. + """ + + @time_machine.travel(_FROZEN_NOW, tick=False) + def test_minus_999_yrs(self) -> None: + assert translate_query("created:[-999yrs to now]", UTC) == ( + "created:[1027-03-28T12:00:00Z TO 2026-03-28T12:00:00Z]" + ) + + @pytest.mark.parametrize( + ("token", "expected_lo"), + [ + ("-1y", "2025-03-28T12:00:00Z"), + ("-1yr", "2025-03-28T12:00:00Z"), + ("-3mos", "2025-12-28T12:00:00Z"), + ("-3mo", "2025-12-28T12:00:00Z"), + ("-2wks", "2026-03-14T12:00:00Z"), + ("-2wk", "2026-03-14T12:00:00Z"), + ("-5dys", "2026-03-23T12:00:00Z"), + ("-5dy", "2026-03-23T12:00:00Z"), + ("-1hrs", "2026-03-28T11:00:00Z"), + ("-1hr", "2026-03-28T11:00:00Z"), + ("-10mins", "2026-03-28T11:50:00Z"), + ("-10min", "2026-03-28T11:50:00Z"), + ("-30secs", "2026-03-28T11:59:30Z"), + ("-30sec", "2026-03-28T11:59:30Z"), + ], + ) + @time_machine.travel(_FROZEN_NOW, tick=False) + def test_abbreviated_units(self, token: str, expected_lo: str) -> None: + assert translate_query(f"added:[{token} to now]", UTC) == ( + f"added:[{expected_lo} TO 2026-03-28T12:00:00Z]" + ) + + @pytest.mark.parametrize( + "raw", + [ + "created:[-999yrs to now]", + "added:[-1y to now]", + "created:[-3mos to now]", + "added:[-2wks to now]", + "added:[-5dys to now]", + "added:[-1hrs to now]", + "added:[-10mins to now]", + "added:[-30secs to now]", + ], + ) + @time_machine.travel(_FROZEN_NOW, tick=False) + def test_parse_acceptance(self, index: tantivy.Index, raw: str) -> None: + translated = translate_query(raw, UTC) + index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS) + + @pytest.mark.search class TestOperatorNormalization: """Post-render operator normalization in translate_query."""