diff --git a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html index 9b243d907..74b49bbdb 100644 --- a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html +++ b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html @@ -26,7 +26,7 @@ i18n-placeholder (change)="onSetCreatedRelativeDate($event)"> -
{{ item.name }}{{ item.date | customDate:'mediumDate' }} – now
+
@@ -102,7 +102,7 @@ i18n-placeholder (change)="onSetAddedRelativeDate($event)"> -
{{ item.name }}{{ item.date | customDate:'mediumDate' }} – now
+
@@ -158,3 +158,16 @@ + + +
+ {{ item.name }} + + @if (item.dateEnd) { + {{ item.date | customDate:'MMM d' }} – {{ item.dateEnd | customDate:'mediumDate' }} + } @else { + {{ item.date | customDate:'mediumDate' }} – now + } + +
+
diff --git a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts index 501b43fab..e07b08959 100644 --- a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts +++ b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts @@ -1,4 +1,4 @@ -import { NgClass } from '@angular/common' +import { NgClass, NgTemplateOutlet } from '@angular/common' import { Component, EventEmitter, @@ -42,6 +42,10 @@ export enum RelativeDate { THIS_MONTH = 6, TODAY = 7, YESTERDAY = 8, + PREVIOUS_WEEK = 9, + PREVIOUS_MONTH = 10, + PREVIOUS_QUARTER = 11, + PREVIOUS_YEAR = 12, } @Component({ @@ -59,6 +63,7 @@ export enum RelativeDate { FormsModule, ReactiveFormsModule, NgClass, + NgTemplateOutlet, ], }) export class DatesDropdownComponent implements OnInit, OnDestroy { @@ -111,6 +116,46 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { name: $localize`Yesterday`, date: new Date().setDate(new Date().getDate() - 1), }, + { + id: RelativeDate.PREVIOUS_WEEK, + name: $localize`Previous week`, + date: new Date( + new Date().getFullYear(), + new Date().getMonth(), + new Date().getDate() - new Date().getDay() - 6 + ), + dateEnd: new Date( + new Date().getFullYear(), + new Date().getMonth(), + new Date().getDate() - new Date().getDay() + ), + }, + { + id: RelativeDate.PREVIOUS_MONTH, + name: $localize`Previous month`, + date: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1), + dateEnd: new Date(new Date().getFullYear(), new Date().getMonth(), 0), + }, + { + id: RelativeDate.PREVIOUS_QUARTER, + name: $localize`Previous quarter`, + date: new Date( + new Date().getFullYear(), + Math.floor(new Date().getMonth() / 3) * 3 - 3, + 1 + ), + dateEnd: new Date( + new Date().getFullYear(), + Math.floor(new Date().getMonth() / 3) * 3, + 0 + ), + }, + { + id: RelativeDate.PREVIOUS_YEAR, + name: $localize`Previous year`, + date: new Date('1/1/' + (new Date().getFullYear() - 1)), + dateEnd: new Date('12/31/' + (new Date().getFullYear() - 1)), + }, ] datePlaceHolder: string diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts index 9ffcc380b..16b65c84d 100644 --- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts +++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts @@ -173,6 +173,22 @@ const RELATIVE_DATE_QUERYSTRINGS = [ relativeDate: RelativeDate.YESTERDAY, dateQuery: 'yesterday', }, + { + relativeDate: RelativeDate.PREVIOUS_WEEK, + dateQuery: 'previous week', + }, + { + relativeDate: RelativeDate.PREVIOUS_MONTH, + dateQuery: 'previous month', + }, + { + relativeDate: RelativeDate.PREVIOUS_QUARTER, + dateQuery: 'previous quarter', + }, + { + relativeDate: RelativeDate.PREVIOUS_YEAR, + dateQuery: 'previous year', + }, ] const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [ diff --git a/src/documents/index.py b/src/documents/index.py index 9446c7db1..6b994ac8c 100644 --- a/src/documents/index.py +++ b/src/documents/index.py @@ -13,6 +13,7 @@ from shutil import rmtree from typing import TYPE_CHECKING from typing import Literal +from dateutil.relativedelta import relativedelta from django.conf import settings from django.utils import timezone as django_timezone from django.utils.timezone import get_current_timezone @@ -533,32 +534,84 @@ def get_permissions_criterias(user: User | None = None) -> list: def rewrite_natural_date_keywords(query_string: str) -> str: """ Rewrites natural date keywords (e.g. added:today or added:"yesterday") to UTC range syntax for Whoosh. + This resolves timezone issues with date parsing in Whoosh as well as adding support for more + natural date keywords. """ tz = get_current_timezone() local_now = now().astimezone(tz) - today = local_now.date() - yesterday = today - timedelta(days=1) - ranges = { - "today": ( - datetime.combine(today, time.min, tzinfo=tz), - datetime.combine(today, time.max, tzinfo=tz), - ), - "yesterday": ( - datetime.combine(yesterday, time.min, tzinfo=tz), - datetime.combine(yesterday, time.max, tzinfo=tz), - ), - } - - pattern = r"(\b(?:added|created))\s*:\s*[\"']?(today|yesterday)[\"']?" + # all supported Keywords + pattern = r"(\b(?:added|created|modified))\s*:\s*[\"']?(today|yesterday|this month|previous month|previous week|previous quarter|this year|previous year)[\"']?" def repl(m): - field, keyword = m.group(1), m.group(2) - start, end = ranges[keyword] + field = m.group(1) + keyword = m.group(2).lower() + + match keyword: + case "today": + start = datetime.combine(today, time.min, tzinfo=tz) + end = datetime.combine(today, time.max, tzinfo=tz) + + case "yesterday": + yesterday = today - timedelta(days=1) + start = datetime.combine(yesterday, time.min, tzinfo=tz) + end = datetime.combine(yesterday, time.max, tzinfo=tz) + + case "this month": + start = datetime(local_now.year, local_now.month, 1, 0, 0, 0, tzinfo=tz) + end = start + relativedelta(months=1) - timedelta(seconds=1) + + case "previous month": + this_month_start = datetime( + local_now.year, + local_now.month, + 1, + 0, + 0, + 0, + tzinfo=tz, + ) + start = this_month_start - relativedelta(months=1) + end = this_month_start - timedelta(seconds=1) + + case "this year": + start = datetime(local_now.year, 1, 1, 0, 0, 0, tzinfo=tz) + end = datetime.combine(today, time.max, tzinfo=tz) + + case "previous week": + days_since_monday = local_now.weekday() + this_week_start = datetime.combine( + today - timedelta(days=days_since_monday), + time.min, + tzinfo=tz, + ) + start = this_week_start - timedelta(days=7) + end = this_week_start - timedelta(seconds=1) + + case "previous quarter": + current_quarter = (local_now.month - 1) // 3 + 1 + this_quarter_start_month = (current_quarter - 1) * 3 + 1 + this_quarter_start = datetime( + local_now.year, + this_quarter_start_month, + 1, + 0, + 0, + 0, + tzinfo=tz, + ) + start = this_quarter_start - relativedelta(months=3) + end = this_quarter_start - timedelta(seconds=1) + + case "previous year": + start = datetime(local_now.year - 1, 1, 1, 0, 0, 0, tzinfo=tz) + end = datetime(local_now.year - 1, 12, 31, 23, 59, 59, tzinfo=tz) + + # Convert to UTC and format start_str = start.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S") end_str = end.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S") return f"{field}:[{start_str} TO {end_str}]" - return re.sub(pattern, repl, query_string) + return re.sub(pattern, repl, query_string, flags=re.IGNORECASE) diff --git a/src/documents/tests/test_index.py b/src/documents/tests/test_index.py index 2a41542e9..f216feedb 100644 --- a/src/documents/tests/test_index.py +++ b/src/documents/tests/test_index.py @@ -2,6 +2,7 @@ from datetime import datetime from unittest import mock from django.contrib.auth.models import User +from django.test import SimpleTestCase from django.test import TestCase from django.test import override_settings from django.utils.timezone import get_current_timezone @@ -127,3 +128,126 @@ class TestAutoComplete(DirectoriesMixin, TestCase): response = self.client.get("/api/documents/?query=added:yesterday") results = response.json()["results"] self.assertEqual(len(results), 0) + + +@override_settings(TIME_ZONE="UTC") +class TestRewriteNaturalDateKeywords(SimpleTestCase): + """ + Unit tests for rewrite_natural_date_keywords function. + """ + + def _rewrite_with_now(self, query: str, now_dt: datetime) -> str: + with mock.patch("documents.index.now", return_value=now_dt): + return index.rewrite_natural_date_keywords(query) + + def _assert_rewrite_contains( + self, + query: str, + now_dt: datetime, + *expected_fragments: str, + ) -> str: + result = self._rewrite_with_now(query, now_dt) + for fragment in expected_fragments: + self.assertIn(fragment, result) + return result + + def test_range_keywords(self): + """ + Test various different range keywords + """ + cases = [ + ( + "added:today", + datetime(2025, 7, 20, 15, 30, 45, tzinfo=timezone.utc), + ("added:[20250720", "TO 20250720"), + ), + ( + "added:yesterday", + datetime(2025, 7, 20, 15, 30, 45, tzinfo=timezone.utc), + ("added:[20250719", "TO 20250719"), + ), + ( + "added:this month", + datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + ("added:[20250701", "TO 20250731"), + ), + ( + "added:previous month", + datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + ("added:[20250601", "TO 20250630"), + ), + ( + "added:this year", + datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + ("added:[20250101", "TO 20250715"), + ), + ( + "added:previous year", + datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + ("added:[20240101", "TO 20241231"), + ), + # Previous quarter from July 15, 2025 is April-June. + ( + "added:previous quarter", + datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + ("added:[20250401", "TO 20250630"), + ), + # July 20, 2025 is a Sunday (weekday 6) so previous week is July 7-13. + ( + "added:previous week", + datetime(2025, 7, 20, 12, 0, 0, tzinfo=timezone.utc), + ("added:[20250707", "TO 20250713"), + ), + ] + + for query, now_dt, fragments in cases: + with self.subTest(query=query): + self._assert_rewrite_contains(query, now_dt, *fragments) + + def test_additional_fields(self): + fixed_now = datetime(2025, 7, 20, 15, 30, 45, tzinfo=timezone.utc) + # created + self._assert_rewrite_contains("created:today", fixed_now, "created:[20250720") + # modified + self._assert_rewrite_contains("modified:today", fixed_now, "modified:[20250720") + + def test_basic_syntax_variants(self): + """ + Test that quoting, casing, and multi-clause queries are parsed. + """ + fixed_now = datetime(2025, 7, 20, 15, 30, 45, tzinfo=timezone.utc) + + # quoted keywords + result1 = self._rewrite_with_now('added:"today"', fixed_now) + result2 = self._rewrite_with_now("added:'today'", fixed_now) + self.assertIn("added:[20250720", result1) + self.assertIn("added:[20250720", result2) + + # case insensitivity + for query in ("added:TODAY", "added:Today", "added:ToDaY"): + with self.subTest(case_variant=query): + self._assert_rewrite_contains(query, fixed_now, "added:[20250720") + + # multiple clauses + result = self._rewrite_with_now("added:today created:yesterday", fixed_now) + self.assertIn("added:[20250720", result) + self.assertIn("created:[20250719", result) + + def test_no_match(self): + """ + Test that queries without keywords are unchanged. + """ + query = "title:test content:example" + result = index.rewrite_natural_date_keywords(query) + self.assertEqual(query, result) + + @override_settings(TIME_ZONE="Pacific/Auckland") + def test_timezone_awareness(self): + """ + Test timezone conversion. + """ + # July 20, 2025 1:00 AM NZST = July 19, 2025 13:00 UTC + fixed_now = datetime(2025, 7, 20, 1, 0, 0, tzinfo=get_current_timezone()) + result = self._rewrite_with_now("added:today", fixed_now) + # Should convert to UTC properly + self.assertIn("added:[20250719", result)