]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: add more relative dates, support modified (#11411)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Wed, 19 Nov 2025 16:54:24 +0000 (08:54 -0800)
committerGitHub <noreply@github.com>
Wed, 19 Nov 2025 16:54:24 +0000 (16:54 +0000)
src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html
src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
src/documents/index.py
src/documents/tests/test_index.py

index 9b243d90714be439942276e471ff87fb706572ea..74b49bbdbaccddf3ee5d4f423060804b5c5be933 100644 (file)
@@ -26,7 +26,7 @@
           i18n-placeholder
           (change)="onSetCreatedRelativeDate($event)">
           <ng-template ng-option-tmp let-item="item">
-            <div class="d-flex">{{ item.name }}<span class="ms-auto text-muted small">{{ item.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container></span></div>
+            <ng-container [ngTemplateOutlet]="relativeDateOptionTemplate" [ngTemplateOutletContext]="{ $implicit: item }"></ng-container>
           </ng-template>
           </ng-select>
         </div>
             i18n-placeholder
             (change)="onSetAddedRelativeDate($event)">
             <ng-template ng-option-tmp let-item="item">
-              <div class="d-flex">{{ item.name }}<span class="ms-auto text-muted small">{{ item.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container></span></div>
+              <ng-container [ngTemplateOutlet]="relativeDateOptionTemplate" [ngTemplateOutletContext]="{ $implicit: item }"></ng-container>
             </ng-template>
           </ng-select>
         </div>
     </div>
   </div>
 </div>
+
+<ng-template #relativeDateOptionTemplate let-item>
+  <div class="d-flex">
+    {{ item.name }}
+    <span class="ms-auto text-muted small">
+      @if (item.dateEnd) {
+        {{ item.date | customDate:'MMM d' }} &ndash; {{ item.dateEnd | customDate:'mediumDate' }}
+      } @else {
+        {{ item.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
+      }
+    </span>
+  </div>
+</ng-template>
index 501b43fabac00c56fe25a213c934f8420211d243..e07b0895917cd7cdbbcc27ad94bc694ad7c35b1a 100644 (file)
@@ -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
index 9ffcc380b9478902aaf79195d5bd731e2875449c..16b65c84d1bcc082098f6e72d5fa8038987993bc 100644 (file)
@@ -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 = [
index 9446c7db17d99f2c8bb6a30618d829f6ec2b2d12..6b994ac8c31a6976b56fc120a2e3521c876925a9 100644 (file)
@@ -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)
index 2a41542e9099f895615f7ce954fb18b98dd45d27..f216feedb7ac636e6e54c804ea464add2d22c8f4 100644 (file)
@@ -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)