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/signals/handlers.py b/src/documents/signals/handlers.py
index bce376f76..0e53c9357 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -396,6 +396,7 @@ class CannotMoveFilesException(Exception):
@receiver(models.signals.post_save, sender=CustomFieldInstance, weak=False)
@receiver(models.signals.m2m_changed, sender=Document.tags.through, weak=False)
@receiver(models.signals.post_save, sender=Document, weak=False)
+@shared_task
def update_filename_and_move_files(
sender,
instance: Document | CustomFieldInstance,
@@ -559,7 +560,7 @@ def check_paths_and_prune_custom_fields(sender, instance: CustomField, **kwargs)
cf_instance.save(update_fields=["value_select"])
# Update the filename and move files if necessary
- update_filename_and_move_files(sender, cf_instance)
+ update_filename_and_move_files.delay(sender, cf_instance)
@receiver(models.signals.post_delete, sender=CustomField)
diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py
index 31dd14b88..b6e6c1342 100644
--- a/src/documents/tests/test_api_custom_fields.py
+++ b/src/documents/tests/test_api_custom_fields.py
@@ -4,6 +4,7 @@ from unittest.mock import ANY
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
+from django.test import override_settings
from rest_framework import status
from rest_framework.test import APITestCase
@@ -211,6 +212,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
],
)
+ @override_settings(CELERY_TASK_ALWAYS_EAGER=True)
def test_custom_field_select_options_pruned(self):
"""
GIVEN:
diff --git a/src/documents/tests/test_file_handling.py b/src/documents/tests/test_file_handling.py
index c0070aa81..117a964ba 100644
--- a/src/documents/tests/test_file_handling.py
+++ b/src/documents/tests/test_file_handling.py
@@ -569,7 +569,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertEqual(generate_filename(doc), Path("document_apple.pdf"))
# handler should not have been called
- self.assertEqual(m.call_count, 0)
+ self.assertEqual(m.delay.call_count, 0)
cf.extra_data = {
"select_options": [
{"label": "aubergine", "id": "abc123"},
@@ -579,8 +579,8 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
}
cf.save()
self.assertEqual(generate_filename(doc), Path("document_aubergine.pdf"))
- # handler should have been called
- self.assertEqual(m.call_count, 1)
+ # handler should have been called via delay
+ self.assertEqual(m.delay.call_count, 1)
class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
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)
diff --git a/src/paperless_mail/templates/package-lock.json b/src/paperless_mail/templates/package-lock.json
index e6c2db15a..38817d673 100644
--- a/src/paperless_mail/templates/package-lock.json
+++ b/src/paperless_mail/templates/package-lock.json
@@ -429,23 +429,21 @@
}
},
"node_modules/glob": {
- "version": "10.4.1",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
- "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
- "engines": {
- "node": ">=16 || 14 >=14.18"
- },
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
@@ -696,6 +694,12 @@
"node": ">= 6"
}
},
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true
+ },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -1694,15 +1698,16 @@
"dev": true
},
"glob": {
- "version": "10.4.1",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
- "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"requires": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
}
},
@@ -1875,6 +1880,12 @@
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true
},
+ "package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true
+ },
"path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",