mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-11-21 04:36:53 -06:00
Enhancement: add more relative dates, support modified (#11411)
This commit is contained in:
@@ -26,7 +26,7 @@
|
|||||||
i18n-placeholder
|
i18n-placeholder
|
||||||
(change)="onSetCreatedRelativeDate($event)">
|
(change)="onSetCreatedRelativeDate($event)">
|
||||||
<ng-template ng-option-tmp let-item="item">
|
<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' }} – <ng-container i18n>now</ng-container></span></div>
|
<ng-container [ngTemplateOutlet]="relativeDateOptionTemplate" [ngTemplateOutletContext]="{ $implicit: item }"></ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-select>
|
</ng-select>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
i18n-placeholder
|
i18n-placeholder
|
||||||
(change)="onSetAddedRelativeDate($event)">
|
(change)="onSetAddedRelativeDate($event)">
|
||||||
<ng-template ng-option-tmp let-item="item">
|
<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' }} – <ng-container i18n>now</ng-container></span></div>
|
<ng-container [ngTemplateOutlet]="relativeDateOptionTemplate" [ngTemplateOutletContext]="{ $implicit: item }"></ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-select>
|
</ng-select>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,3 +158,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</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' }} – {{ item.dateEnd | customDate:'mediumDate' }}
|
||||||
|
} @else {
|
||||||
|
{{ item.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass } from '@angular/common'
|
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
@@ -42,6 +42,10 @@ export enum RelativeDate {
|
|||||||
THIS_MONTH = 6,
|
THIS_MONTH = 6,
|
||||||
TODAY = 7,
|
TODAY = 7,
|
||||||
YESTERDAY = 8,
|
YESTERDAY = 8,
|
||||||
|
PREVIOUS_WEEK = 9,
|
||||||
|
PREVIOUS_MONTH = 10,
|
||||||
|
PREVIOUS_QUARTER = 11,
|
||||||
|
PREVIOUS_YEAR = 12,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -59,6 +63,7 @@ export enum RelativeDate {
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
|
NgTemplateOutlet,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DatesDropdownComponent implements OnInit, OnDestroy {
|
export class DatesDropdownComponent implements OnInit, OnDestroy {
|
||||||
@@ -111,6 +116,46 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
|
|||||||
name: $localize`Yesterday`,
|
name: $localize`Yesterday`,
|
||||||
date: new Date().setDate(new Date().getDate() - 1),
|
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
|
datePlaceHolder: string
|
||||||
|
|||||||
@@ -173,6 +173,22 @@ const RELATIVE_DATE_QUERYSTRINGS = [
|
|||||||
relativeDate: RelativeDate.YESTERDAY,
|
relativeDate: RelativeDate.YESTERDAY,
|
||||||
dateQuery: '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 = [
|
const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from shutil import rmtree
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone as django_timezone
|
from django.utils import timezone as django_timezone
|
||||||
from django.utils.timezone import get_current_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:
|
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.
|
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()
|
tz = get_current_timezone()
|
||||||
local_now = now().astimezone(tz)
|
local_now = now().astimezone(tz)
|
||||||
|
|
||||||
today = local_now.date()
|
today = local_now.date()
|
||||||
yesterday = today - timedelta(days=1)
|
|
||||||
|
|
||||||
ranges = {
|
# all supported Keywords
|
||||||
"today": (
|
pattern = r"(\b(?:added|created|modified))\s*:\s*[\"']?(today|yesterday|this month|previous month|previous week|previous quarter|this year|previous year)[\"']?"
|
||||||
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)[\"']?"
|
|
||||||
|
|
||||||
def repl(m):
|
def repl(m):
|
||||||
field, keyword = m.group(1), m.group(2)
|
field = m.group(1)
|
||||||
start, end = ranges[keyword]
|
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")
|
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")
|
end_str = end.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||||
return f"{field}:[{start_str} TO {end_str}]"
|
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)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from datetime import datetime
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import SimpleTestCase
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils.timezone import get_current_timezone
|
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")
|
response = self.client.get("/api/documents/?query=added:yesterday")
|
||||||
results = response.json()["results"]
|
results = response.json()["results"]
|
||||||
self.assertEqual(len(results), 0)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user