mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-25 19:00:33 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5a4026d4f | ||
|
|
339f9a4d30 | ||
|
|
856140c068 | ||
|
|
62cf7c4429 | ||
|
|
9a2c2b9331 |
@@ -2,7 +2,6 @@
|
|||||||
# shellcheck shell=bash
|
# shellcheck shell=bash
|
||||||
|
|
||||||
declare -r log_prefix="[svc-flower]"
|
declare -r log_prefix="[svc-flower]"
|
||||||
declare -r flower_config="${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py"
|
|
||||||
|
|
||||||
echo "${log_prefix} Checking if we should start flower..."
|
echo "${log_prefix} Checking if we should start flower..."
|
||||||
|
|
||||||
@@ -10,20 +9,12 @@ if [[ -n "${PAPERLESS_ENABLE_FLOWER}" ]]; then
|
|||||||
# Small delay to allow celery to be up first
|
# Small delay to allow celery to be up first
|
||||||
echo "${log_prefix} Starting flower in 5s"
|
echo "${log_prefix} Starting flower in 5s"
|
||||||
sleep 5
|
sleep 5
|
||||||
cd "${PAPERLESS_SRC_DIR}" || exit 1
|
cd ${PAPERLESS_SRC_DIR}
|
||||||
|
|
||||||
# Only pass --conf if the file is actually there. The image does not ship one, and
|
|
||||||
# flower >= 2.1.0 exits with FileNotFoundError when an explicitly given --conf path
|
|
||||||
# does not exist (mher/flower#1391). Earlier versions silently ignored it.
|
|
||||||
declare -a conf_args=()
|
|
||||||
if [[ -f "${flower_config}" ]]; then
|
|
||||||
conf_args=(--conf="${flower_config}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
exec /usr/local/bin/celery --app paperless flower "${conf_args[@]}"
|
exec /usr/local/bin/celery --app paperless flower --conf=${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py
|
||||||
else
|
else
|
||||||
exec s6-setuidgid paperless /usr/local/bin/celery --app paperless flower "${conf_args[@]}"
|
exec s6-setuidgid paperless /usr/local/bin/celery --app paperless flower --conf=${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py
|
||||||
fi
|
fi
|
||||||
|
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,30 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## paperless-ngx 3.2.1
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fix: only pass --conf to flower when flowerconfig.py exists [@bitfoo1](https://github.com/bitfoo1) ([#14182](https://github.com/paperless-ngx/paperless-ngx/pull/14182))
|
|
||||||
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
|
||||||
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
|
||||||
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
|
||||||
|
|
||||||
### All App Changes
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>4 changes</summary>
|
|
||||||
|
|
||||||
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
|
||||||
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
|
||||||
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
|
||||||
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## paperless-ngx 3.2.0
|
## paperless-ngx 3.2.0
|
||||||
|
|
||||||
### Features / Enhancements
|
### Features / Enhancements
|
||||||
|
|||||||
+1
-3
@@ -76,9 +76,7 @@ is not supported by any of the available parsers.
|
|||||||
|
|
||||||
**A:** Not by default. As of v3, a file whose contents match an existing document is still
|
**A:** Not by default. As of v3, a file whose contents match an existing document is still
|
||||||
consumed, and the duplicate is flagged in the UI — open the document and check the
|
consumed, and the duplicate is flagged in the UI — open the document and check the
|
||||||
**Duplicates** tab to review documents that share the same content, or filter the document
|
**Duplicates** tab to review documents that share the same content. If you prefer the old
|
||||||
list by **Duplicates** to find all of them (see
|
|
||||||
[Duplicate documents](usage.md#duplicate-documents)). If you prefer the old
|
|
||||||
behavior of rejecting duplicates during consumption, set
|
behavior of rejecting duplicates during consumption, set
|
||||||
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
||||||
to `true`.
|
to `true`.
|
||||||
|
|||||||
+8
-7
@@ -299,18 +299,19 @@ for details.
|
|||||||
### Duplicate documents
|
### Duplicate documents
|
||||||
|
|
||||||
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
|
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
|
||||||
contents match an existing document (same original or archive checksum), the new copy is
|
contents exactly match an existing document (same checksum), the new copy is still
|
||||||
still consumed and a warning is logged.
|
consumed and a warning is logged. The task entry for the upload also flags that a
|
||||||
|
duplicate was detected and links to the existing document(s).
|
||||||
|
|
||||||
When a document has duplicates, a **Duplicates** tab appears on its detail page, listing
|
To review duplicates, open a document and switch to the **Duplicates** tab on the
|
||||||
the other documents you can view that share the same content (including any in the trash).
|
document detail page. It lists other documents that share the same content, including any
|
||||||
To find all documents with duplicates, choose **Duplicates** in the document list's text
|
that are in the trash (shown with a badge), and links to each so you can decide which to
|
||||||
filter dropdown, or use `has_duplicates=true` in the REST API.
|
keep.
|
||||||
|
|
||||||
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
|
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
|
||||||
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
||||||
to `true`. The duplicate file is then deleted instead of consumed, and the task fails with
|
to `true`. The duplicate file is then deleted instead of consumed, and the task fails with
|
||||||
a "Document already exists" message linking to the existing document.
|
a "document already exists" message.
|
||||||
|
|
||||||
## Document Suggestions
|
## Document Suggestions
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.2.1"
|
version = "3.2.0"
|
||||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.2.1",
|
"version": "3.2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
+347
@@ -0,0 +1,347 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="advanced-search-dialog-title" i18n>
|
||||||
|
Advanced search
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
aria-label="Close"
|
||||||
|
i18n-aria-label
|
||||||
|
(click)="cancel()"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (unreadable) {
|
||||||
|
<div class="alert alert-warning d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<span i18n
|
||||||
|
>The current query uses syntax this editor can't show. It still works
|
||||||
|
as typed.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
(click)="startOver()"
|
||||||
|
i18n
|
||||||
|
>
|
||||||
|
Start a new query
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="
|
||||||
|
groupTemplate;
|
||||||
|
context: { group: root, parent: null, depth: 0 }
|
||||||
|
"
|
||||||
|
></ng-container>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label
|
||||||
|
class="form-label small text-muted"
|
||||||
|
for="advanced-search-preview"
|
||||||
|
i18n
|
||||||
|
>Query</label
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
id="advanced-search-preview"
|
||||||
|
class="query-preview mb-0 p-2 rounded border"
|
||||||
|
>@if (generatedQuery) {{{ generatedQuery }}} @else {<span class="text-muted fst-italic" i18n>Nothing to search for yet</span>}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
(click)="cancel()"
|
||||||
|
i18n
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
(click)="apply()"
|
||||||
|
[disabled]="!generatedQuery"
|
||||||
|
i18n
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template
|
||||||
|
#groupTemplate
|
||||||
|
let-group="group"
|
||||||
|
let-parent="parent"
|
||||||
|
let-depth="depth"
|
||||||
|
>
|
||||||
|
<div class="d-flex w-100 gap-2">
|
||||||
|
<div class="d-flex flex-grow-1 flex-column">
|
||||||
|
<div class="d-flex align-items-center flex-wrap">
|
||||||
|
<div
|
||||||
|
class="btn-group btn-group-xs"
|
||||||
|
role="group"
|
||||||
|
aria-label="Match"
|
||||||
|
i18n-aria-label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
class="btn-check"
|
||||||
|
[(ngModel)]="group.operator"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
[value]="LogicalOperator.Or"
|
||||||
|
id="advancedSearchAny_{{ idFor(group) }}"
|
||||||
|
name="advancedSearchAny_{{ idFor(group) }}"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
class="btn btn-outline-primary"
|
||||||
|
for="advancedSearchAny_{{ idFor(group) }}"
|
||||||
|
i18n
|
||||||
|
>Any</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
class="btn-check"
|
||||||
|
[(ngModel)]="group.operator"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
[value]="LogicalOperator.And"
|
||||||
|
id="advancedSearchAll_{{ idFor(group) }}"
|
||||||
|
name="advancedSearchAll_{{ idFor(group) }}"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
class="btn btn-outline-primary"
|
||||||
|
for="advancedSearchAll_{{ idFor(group) }}"
|
||||||
|
i18n
|
||||||
|
>All</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
class="btn-check"
|
||||||
|
[(ngModel)]="group.operator"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
[value]="LogicalOperator.Not"
|
||||||
|
id="advancedSearchNot_{{ idFor(group) }}"
|
||||||
|
name="advancedSearchNot_{{ idFor(group) }}"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
for="advancedSearchNot_{{ idFor(group) }}"
|
||||||
|
i18n
|
||||||
|
>Not</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span class="small text-muted ms-2">
|
||||||
|
@switch (group.operator) {
|
||||||
|
@case (LogicalOperator.And) {
|
||||||
|
<ng-container i18n>match all of these</ng-container>
|
||||||
|
}
|
||||||
|
@case (LogicalOperator.Or) {
|
||||||
|
<ng-container i18n>match any of these</ng-container>
|
||||||
|
}
|
||||||
|
@case (LogicalOperator.Not) {
|
||||||
|
<ng-container i18n>match none of these</ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
@for (element of group.children; track element) {
|
||||||
|
<div class="list-group-item px-0 d-flex flex-nowrap">
|
||||||
|
@if (element.type === ElementType.Group) {
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="
|
||||||
|
groupTemplate;
|
||||||
|
context: { group: element, parent: group, depth: depth + 1 }
|
||||||
|
"
|
||||||
|
></ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="
|
||||||
|
atomTemplate;
|
||||||
|
context: { atom: element, parent: group }
|
||||||
|
"
|
||||||
|
></ng-container>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="btn-group-vertical align-self-start ms-2 ps-2 border-start"
|
||||||
|
role="group"
|
||||||
|
aria-label="Group actions"
|
||||||
|
i18n-aria-label
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-secondary text-primary"
|
||||||
|
title="Add condition"
|
||||||
|
i18n-title
|
||||||
|
(click)="addAtom(group)"
|
||||||
|
[disabled]="group.children.length >= maxAtoms"
|
||||||
|
>
|
||||||
|
<i-bs name="node-plus"></i-bs>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-secondary text-primary"
|
||||||
|
title="Add group"
|
||||||
|
i18n-title
|
||||||
|
(click)="addGroup(group)"
|
||||||
|
[disabled]="depth >= maxDepth"
|
||||||
|
>
|
||||||
|
<i-bs name="braces"></i-bs>
|
||||||
|
</button>
|
||||||
|
@if (parent) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-secondary text-danger"
|
||||||
|
aria-label="Remove group"
|
||||||
|
i18n-aria-label
|
||||||
|
(click)="remove(parent, group)"
|
||||||
|
>
|
||||||
|
<i-bs name="x-circle"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #atomTemplate let-atom="atom" let-parent="parent">
|
||||||
|
<div class="d-flex align-items-center gap-1 w-100">
|
||||||
|
<div class="input-group input-group-sm flex-wrap">
|
||||||
|
<select
|
||||||
|
class="form-select flex-grow-0 w-auto"
|
||||||
|
[(ngModel)]="atom.field"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
(ngModelChange)="onFieldChange(atom)"
|
||||||
|
aria-label="Field"
|
||||||
|
i18n-aria-label
|
||||||
|
>
|
||||||
|
@for (fieldGroup of fieldGroups; track fieldGroup.label) {
|
||||||
|
<optgroup [label]="fieldGroup.label">
|
||||||
|
@for (field of fieldGroup.fields; track field) {
|
||||||
|
<option [ngValue]="field">{{ fieldLabels[field] }}</option>
|
||||||
|
}
|
||||||
|
</optgroup>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
class="form-select flex-grow-0 w-auto"
|
||||||
|
[(ngModel)]="atom.operator"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
(ngModelChange)="onOperatorChange(atom)"
|
||||||
|
aria-label="Condition"
|
||||||
|
i18n-aria-label
|
||||||
|
>
|
||||||
|
@for (operator of operatorsFor(atom); track operator) {
|
||||||
|
<option [ngValue]="operator">
|
||||||
|
{{ operatorLabel(atom, operator) }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
@switch (atom.operator) {
|
||||||
|
@case (Operator.DateKeyword) {
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
[(ngModel)]="atom.value"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
aria-label="Period"
|
||||||
|
i18n-aria-label
|
||||||
|
>
|
||||||
|
<option [ngValue]="''" disabled i18n>Choose a period</option>
|
||||||
|
@for (keyword of dateKeywords; track keyword) {
|
||||||
|
<option [ngValue]="keyword">
|
||||||
|
{{ dateKeywordLabels[keyword] }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
@case (Operator.WithinLast) {
|
||||||
|
<input
|
||||||
|
class="form-control amount"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
[(ngModel)]="atom.value"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
aria-label="Amount"
|
||||||
|
i18n-aria-label
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
[(ngModel)]="atom.unit"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
aria-label="Unit"
|
||||||
|
i18n-aria-label
|
||||||
|
>
|
||||||
|
@for (unit of dateUnits; track unit) {
|
||||||
|
<option [ngValue]="unit">{{ dateUnitLabels[unit] }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
@case (Operator.Between) {
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
|
||||||
|
[(ngModel)]="atom.value"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
aria-label="From"
|
||||||
|
i18n-aria-label
|
||||||
|
/>
|
||||||
|
<span class="input-group-text" i18n>and</span>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
|
||||||
|
[(ngModel)]="atom.valueTo"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
aria-label="To"
|
||||||
|
i18n-aria-label
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
@if (kindOf(atom) === FieldKind.Date) {
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
type="date"
|
||||||
|
[(ngModel)]="atom.value"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
aria-label="Date"
|
||||||
|
i18n-aria-label
|
||||||
|
/>
|
||||||
|
} @else if (kindOf(atom) === FieldKind.Number) {
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
[(ngModel)]="atom.value"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
aria-label="Number"
|
||||||
|
i18n-aria-label
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="atom.value"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
[placeholder]="placeholderFor(atom)"
|
||||||
|
aria-label="Value"
|
||||||
|
i18n-aria-label
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-link btn-sm text-danger px-1"
|
||||||
|
type="button"
|
||||||
|
(click)="remove(parent, atom)"
|
||||||
|
aria-label="Remove condition"
|
||||||
|
i18n-aria-label
|
||||||
|
>
|
||||||
|
<i-bs name="x-circle"></i-bs>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
.query-preview {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background-color: var(--pngx-bg-darker);
|
||||||
|
border-color: var(--bs-border-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group .amount {
|
||||||
|
flex: 0 1 5rem;
|
||||||
|
}
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import {
|
||||||
|
AdvancedSearchDateUnit,
|
||||||
|
AdvancedSearchField,
|
||||||
|
AdvancedSearchLogicalOperator,
|
||||||
|
AdvancedSearchOperator,
|
||||||
|
AdvancedSearchQueryAtom,
|
||||||
|
AdvancedSearchQueryElementType,
|
||||||
|
AdvancedSearchQueryGroup,
|
||||||
|
} from 'src/app/data/advanced-search-query'
|
||||||
|
import { AdvancedSearchDialogComponent } from './advanced-search-dialog.component'
|
||||||
|
|
||||||
|
describe('AdvancedSearchDialogComponent', () => {
|
||||||
|
let component: AdvancedSearchDialogComponent
|
||||||
|
let fixture: ComponentFixture<AdvancedSearchDialogComponent>
|
||||||
|
let activeModal: NgbActiveModal
|
||||||
|
|
||||||
|
const firstAtom = (): AdvancedSearchQueryAtom =>
|
||||||
|
component.root.children[0] as AdvancedSearchQueryAtom
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
AdvancedSearchDialogComponent,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
providers: [NgbActiveModal],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AdvancedSearchDialogComponent)
|
||||||
|
activeModal = TestBed.inject(NgbActiveModal)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should start with one empty condition and nothing to search for', () => {
|
||||||
|
expect(component.root.children).toHaveLength(1)
|
||||||
|
expect(component.generatedQuery).toBe('')
|
||||||
|
expect(component.unreadable).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show an existing query as conditions', () => {
|
||||||
|
component.query = 'title:invoice AND NOT tag:paid'
|
||||||
|
expect(component.unreadable).toBeFalsy()
|
||||||
|
expect(component.root.children).toHaveLength(2)
|
||||||
|
expect(component.generatedQuery).toBe('title:invoice AND NOT tag:paid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should flag a query it cannot show and start empty', () => {
|
||||||
|
component.query = 'title:invoice^2'
|
||||||
|
expect(component.unreadable).toBeTruthy()
|
||||||
|
expect(component.generatedQuery).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear the warning when starting a new query', () => {
|
||||||
|
component.query = 'title:invoice^2'
|
||||||
|
component.startOver()
|
||||||
|
expect(component.unreadable).toBeFalsy()
|
||||||
|
expect(component.root.children).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should treat an empty query as a fresh start', () => {
|
||||||
|
component.query = ' '
|
||||||
|
expect(component.unreadable).toBeFalsy()
|
||||||
|
expect(component.root.children).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should write the query as conditions are filled in', () => {
|
||||||
|
const atom = firstAtom()
|
||||||
|
atom.field = AdvancedSearchField.Title
|
||||||
|
atom.value = 'unpaid invoice'
|
||||||
|
expect(component.generatedQuery).toBe('title:unpaid AND title:invoice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should offer the conditions of the chosen field', () => {
|
||||||
|
const atom = firstAtom()
|
||||||
|
atom.field = AdvancedSearchField.Added
|
||||||
|
component.onFieldChange(atom)
|
||||||
|
expect(component.operatorsFor(atom)).toContain(
|
||||||
|
AdvancedSearchOperator.WithinLast
|
||||||
|
)
|
||||||
|
expect(component.operatorsFor(atom)).not.toContain(
|
||||||
|
AdvancedSearchOperator.Phrase
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep a condition the new field still offers', () => {
|
||||||
|
const atom = firstAtom()
|
||||||
|
atom.operator = AdvancedSearchOperator.Phrase
|
||||||
|
atom.field = AdvancedSearchField.Correspondent
|
||||||
|
component.onFieldChange(atom)
|
||||||
|
expect(atom.operator).toBe(AdvancedSearchOperator.Phrase)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should replace a condition the new field does not offer, and clear the value', () => {
|
||||||
|
const atom = firstAtom()
|
||||||
|
atom.operator = AdvancedSearchOperator.Phrase
|
||||||
|
atom.value = 'invoice'
|
||||||
|
atom.field = AdvancedSearchField.ASN
|
||||||
|
component.onFieldChange(atom)
|
||||||
|
expect(atom.operator).toBe(AdvancedSearchOperator.Equals)
|
||||||
|
expect(atom.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should give a within-the-last condition a unit to start from', () => {
|
||||||
|
const atom = firstAtom()
|
||||||
|
atom.field = AdvancedSearchField.Added
|
||||||
|
atom.operator = AdvancedSearchOperator.WithinLast
|
||||||
|
component.onOperatorChange(atom)
|
||||||
|
expect(atom.unit).toBe(AdvancedSearchDateUnit.Day)
|
||||||
|
atom.value = '3'
|
||||||
|
expect(component.generatedQuery).toBe('added:[-3 days to now]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should label date comparisons as dates', () => {
|
||||||
|
const atom = firstAtom()
|
||||||
|
atom.field = AdvancedSearchField.Created
|
||||||
|
expect(
|
||||||
|
component.operatorLabel(atom, AdvancedSearchOperator.AtLeast)
|
||||||
|
).toEqual('is on or after')
|
||||||
|
atom.field = AdvancedSearchField.ASN
|
||||||
|
expect(
|
||||||
|
component.operatorLabel(atom, AdvancedSearchOperator.AtLeast)
|
||||||
|
).toEqual('is at least')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add and remove conditions', () => {
|
||||||
|
component.addAtom(component.root)
|
||||||
|
expect(component.root.children).toHaveLength(2)
|
||||||
|
component.remove(component.root, component.root.children[1])
|
||||||
|
expect(component.root.children).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add a group, which starts as Any', () => {
|
||||||
|
component.addGroup(component.root)
|
||||||
|
const group = component.root.children[1] as AdvancedSearchQueryGroup
|
||||||
|
expect(group.type).toBe(AdvancedSearchQueryElementType.Group)
|
||||||
|
expect(group.operator).toBe(AdvancedSearchLogicalOperator.Or)
|
||||||
|
expect(group.children).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should give every group its own id, once', () => {
|
||||||
|
component.addGroup(component.root)
|
||||||
|
const group = component.root.children[1] as AdvancedSearchQueryGroup
|
||||||
|
expect(component.idFor(component.root)).not.toEqual(component.idFor(group))
|
||||||
|
expect(component.idFor(group)).toEqual(component.idFor(group))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply the query and close', () => {
|
||||||
|
const emitSpy = jest.spyOn(component.queryApplied, 'emit')
|
||||||
|
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||||
|
const atom = firstAtom()
|
||||||
|
atom.value = 'invoice'
|
||||||
|
component.apply()
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith('content:invoice')
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should close without applying on cancel', () => {
|
||||||
|
const emitSpy = jest.spyOn(component.queryApplied, 'emit')
|
||||||
|
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||||
|
component.cancel()
|
||||||
|
expect(emitSpy).not.toHaveBeenCalled()
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show the query it will apply', () => {
|
||||||
|
component.query = 'content:invoice OR content:receipt'
|
||||||
|
fixture.detectChanges()
|
||||||
|
const preview = fixture.nativeElement.querySelector(
|
||||||
|
'#advanced-search-preview'
|
||||||
|
)
|
||||||
|
expect(preview.textContent).toContain('content:invoice OR content:receipt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not offer to apply an empty query', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
const apply = Array.from(
|
||||||
|
fixture.nativeElement.querySelectorAll('.modal-footer button')
|
||||||
|
).pop() as HTMLButtonElement
|
||||||
|
expect(apply.disabled).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
import { NgTemplateOutlet } from '@angular/common'
|
||||||
|
import { Component, EventEmitter, inject, Input, Output } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import {
|
||||||
|
ADVANCED_SEARCH_DATE_KEYWORD_LABELS,
|
||||||
|
ADVANCED_SEARCH_DATE_KEYWORDS,
|
||||||
|
ADVANCED_SEARCH_DATE_OPERATOR_LABELS,
|
||||||
|
ADVANCED_SEARCH_DATE_UNIT_LABELS,
|
||||||
|
ADVANCED_SEARCH_FIELD_GROUPS,
|
||||||
|
ADVANCED_SEARCH_FIELD_KINDS,
|
||||||
|
ADVANCED_SEARCH_FIELD_LABELS,
|
||||||
|
ADVANCED_SEARCH_MAX_ATOMS,
|
||||||
|
ADVANCED_SEARCH_MAX_DEPTH,
|
||||||
|
ADVANCED_SEARCH_OPERATOR_LABELS,
|
||||||
|
ADVANCED_SEARCH_OPERATORS_BY_KIND,
|
||||||
|
AdvancedSearchDateUnit,
|
||||||
|
AdvancedSearchField,
|
||||||
|
AdvancedSearchFieldKind,
|
||||||
|
AdvancedSearchLogicalOperator,
|
||||||
|
AdvancedSearchOperator,
|
||||||
|
AdvancedSearchQueryAtom,
|
||||||
|
AdvancedSearchQueryElement,
|
||||||
|
AdvancedSearchQueryElementType,
|
||||||
|
AdvancedSearchQueryGroup,
|
||||||
|
} from 'src/app/data/advanced-search-query'
|
||||||
|
import {
|
||||||
|
parseAdvancedSearchQuery,
|
||||||
|
serializeAdvancedSearchQuery,
|
||||||
|
} from 'src/app/utils/advanced-search-query'
|
||||||
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-advanced-search-dialog',
|
||||||
|
templateUrl: './advanced-search-dialog.component.html',
|
||||||
|
styleUrl: './advanced-search-dialog.component.scss',
|
||||||
|
imports: [FormsModule, NgTemplateOutlet, NgxBootstrapIconsModule],
|
||||||
|
})
|
||||||
|
export class AdvancedSearchDialogComponent extends LoadingComponentWithPermissions {
|
||||||
|
private activeModal = inject(NgbActiveModal)
|
||||||
|
|
||||||
|
public readonly ElementType = AdvancedSearchQueryElementType
|
||||||
|
public readonly LogicalOperator = AdvancedSearchLogicalOperator
|
||||||
|
public readonly Operator = AdvancedSearchOperator
|
||||||
|
public readonly FieldKind = AdvancedSearchFieldKind
|
||||||
|
public readonly fieldGroups = ADVANCED_SEARCH_FIELD_GROUPS
|
||||||
|
public readonly fieldLabels = ADVANCED_SEARCH_FIELD_LABELS
|
||||||
|
public readonly dateKeywords = ADVANCED_SEARCH_DATE_KEYWORDS
|
||||||
|
public readonly dateKeywordLabels = ADVANCED_SEARCH_DATE_KEYWORD_LABELS
|
||||||
|
public readonly dateUnits = Object.values(AdvancedSearchDateUnit)
|
||||||
|
public readonly dateUnitLabels = ADVANCED_SEARCH_DATE_UNIT_LABELS
|
||||||
|
public readonly maxDepth = ADVANCED_SEARCH_MAX_DEPTH
|
||||||
|
public readonly maxAtoms = ADVANCED_SEARCH_MAX_ATOMS
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
public queryApplied = new EventEmitter<string>()
|
||||||
|
|
||||||
|
public root: AdvancedSearchQueryGroup = this.emptyRoot()
|
||||||
|
|
||||||
|
// True when the query in the search box uses syntax the editor cannot show
|
||||||
|
public unreadable: boolean = false
|
||||||
|
|
||||||
|
private _query: string = ''
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
set query(query: string) {
|
||||||
|
this._query = query ?? ''
|
||||||
|
const parsed = parseAdvancedSearchQuery(this._query)
|
||||||
|
this.unreadable = !!this._query.trim() && !parsed
|
||||||
|
this.root = parsed ?? this.emptyRoot()
|
||||||
|
}
|
||||||
|
|
||||||
|
get query(): string {
|
||||||
|
return this._query
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.loading.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable ids for the radio groups, without putting them in the query model
|
||||||
|
private ids = new WeakMap<object, number>()
|
||||||
|
private nextId = 0
|
||||||
|
|
||||||
|
public idFor(element: AdvancedSearchQueryElement): number {
|
||||||
|
if (!this.ids.has(element)) {
|
||||||
|
this.ids.set(element, this.nextId++)
|
||||||
|
}
|
||||||
|
return this.ids.get(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
private emptyRoot(): AdvancedSearchQueryGroup {
|
||||||
|
return {
|
||||||
|
type: AdvancedSearchQueryElementType.Group,
|
||||||
|
operator: AdvancedSearchLogicalOperator.And,
|
||||||
|
children: [this.newAtom()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private newAtom(): AdvancedSearchQueryAtom {
|
||||||
|
return {
|
||||||
|
type: AdvancedSearchQueryElementType.Atom,
|
||||||
|
field: AdvancedSearchField.Content,
|
||||||
|
operator: AdvancedSearchOperator.AllWords,
|
||||||
|
value: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public get generatedQuery(): string {
|
||||||
|
return serializeAdvancedSearchQuery(this.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
public kindOf(atom: AdvancedSearchQueryAtom): AdvancedSearchFieldKind {
|
||||||
|
return ADVANCED_SEARCH_FIELD_KINDS[atom.field]
|
||||||
|
}
|
||||||
|
|
||||||
|
public operatorsFor(atom: AdvancedSearchQueryAtom): AdvancedSearchOperator[] {
|
||||||
|
return ADVANCED_SEARCH_OPERATORS_BY_KIND[this.kindOf(atom)]
|
||||||
|
}
|
||||||
|
|
||||||
|
public operatorLabel(
|
||||||
|
atom: AdvancedSearchQueryAtom,
|
||||||
|
operator: AdvancedSearchOperator
|
||||||
|
): string {
|
||||||
|
return this.kindOf(atom) === AdvancedSearchFieldKind.Date
|
||||||
|
? (ADVANCED_SEARCH_DATE_OPERATOR_LABELS[operator] ??
|
||||||
|
ADVANCED_SEARCH_OPERATOR_LABELS[operator])
|
||||||
|
: ADVANCED_SEARCH_OPERATOR_LABELS[operator]
|
||||||
|
}
|
||||||
|
|
||||||
|
public placeholderFor(atom: AdvancedSearchQueryAtom): string {
|
||||||
|
switch (atom.operator) {
|
||||||
|
case AdvancedSearchOperator.Phrase:
|
||||||
|
return $localize`phrase`
|
||||||
|
case AdvancedSearchOperator.StartsWith:
|
||||||
|
return $localize`beginning of a word`
|
||||||
|
default:
|
||||||
|
return $localize`words`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onFieldChange(atom: AdvancedSearchQueryAtom) {
|
||||||
|
// Keep the condition only if the new field still offers it
|
||||||
|
if (!this.operatorsFor(atom).includes(atom.operator)) {
|
||||||
|
atom.operator = this.operatorsFor(atom)[0]
|
||||||
|
}
|
||||||
|
this.clearValues(atom)
|
||||||
|
}
|
||||||
|
|
||||||
|
public onOperatorChange(atom: AdvancedSearchQueryAtom) {
|
||||||
|
this.clearValues(atom)
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearValues(atom: AdvancedSearchQueryAtom) {
|
||||||
|
atom.value = ''
|
||||||
|
atom.valueTo = undefined
|
||||||
|
atom.unit =
|
||||||
|
atom.operator === AdvancedSearchOperator.WithinLast
|
||||||
|
? AdvancedSearchDateUnit.Day
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
public addAtom(group: AdvancedSearchQueryGroup) {
|
||||||
|
group.children.push(this.newAtom())
|
||||||
|
}
|
||||||
|
|
||||||
|
public addGroup(group: AdvancedSearchQueryGroup) {
|
||||||
|
group.children.push({
|
||||||
|
type: AdvancedSearchQueryElementType.Group,
|
||||||
|
operator: AdvancedSearchLogicalOperator.Or,
|
||||||
|
children: [this.newAtom()],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public remove(
|
||||||
|
parent: AdvancedSearchQueryGroup,
|
||||||
|
element: AdvancedSearchQueryElement
|
||||||
|
) {
|
||||||
|
parent.children = parent.children.filter((child) => child !== element)
|
||||||
|
}
|
||||||
|
|
||||||
|
public startOver() {
|
||||||
|
this.unreadable = false
|
||||||
|
this.root = this.emptyRoot()
|
||||||
|
}
|
||||||
|
|
||||||
|
public apply() {
|
||||||
|
this.queryApplied.emit(this.generatedQuery)
|
||||||
|
this.activeModal.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancel() {
|
||||||
|
this.activeModal.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
|
@if (advancedSearchEditorAvailable) {
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="button" (click)="openAdvancedSearchEditor()"
|
||||||
|
title="Edit query" i18n-title [disabled]="disabled">
|
||||||
|
<i-bs name="sliders"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
@if (_textFilter) {
|
@if (_textFilter) {
|
||||||
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()" aria-label="Clear search" i18n-aria-label>
|
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()" aria-label="Clear search" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
NgbDatepickerModule,
|
NgbDatepickerModule,
|
||||||
NgbDropdownItem,
|
NgbDropdownItem,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
|
NgbModal,
|
||||||
|
NgbModalRef,
|
||||||
NgbTypeaheadModule,
|
NgbTypeaheadModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
|
||||||
@@ -2515,4 +2517,45 @@ describe('FilterEditorComponent', () => {
|
|||||||
|
|
||||||
expect(component.textFilter).toEqual('help ')
|
expect(component.textFilter).toEqual('help ')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should open the advanced search editor with the current query and apply the result', () => {
|
||||||
|
const modalService: NgbModal = TestBed.inject(NgbModal)
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe(
|
||||||
|
(instances) => (modal = instances[0])
|
||||||
|
)
|
||||||
|
component.textFilterTarget = 'fulltext-query'
|
||||||
|
component.updateTextFilter('title:invoice')
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const editorButton = fixture.debugElement.query(
|
||||||
|
By.css('button[title="Edit query"]')
|
||||||
|
)
|
||||||
|
expect(editorButton).not.toBeNull()
|
||||||
|
editorButton.triggerEventHandler('click')
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(modal.componentInstance.query).toEqual('title:invoice')
|
||||||
|
|
||||||
|
const rulesSpy = jest.spyOn(component.filterRulesChange, 'next')
|
||||||
|
modal.componentInstance.queryApplied.emit('title:invoice AND NOT tag:paid')
|
||||||
|
expect(component.textFilter).toEqual('title:invoice AND NOT tag:paid')
|
||||||
|
expect(documentService.searchQuery).toEqual(
|
||||||
|
'title:invoice AND NOT tag:paid'
|
||||||
|
)
|
||||||
|
expect(rulesSpy).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
|
value: 'title:invoice AND NOT tag:paid',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not offer the advanced search editor for other targets', () => {
|
||||||
|
component.textFilterTarget = 'title-content'
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
fixture.debugElement.query(By.css('button[title="Edit query"]'))
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ import {
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
|
NgbModal,
|
||||||
NgbTypeahead,
|
NgbTypeahead,
|
||||||
NgbTypeaheadModule,
|
NgbTypeaheadModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { Observable, Subject, from } from 'rxjs'
|
import { first, Observable, Subject, from } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
debounceTime,
|
debounceTime,
|
||||||
@@ -121,6 +122,7 @@ import {
|
|||||||
PermissionsFilterDropdownComponent,
|
PermissionsFilterDropdownComponent,
|
||||||
PermissionsSelectionModel,
|
PermissionsSelectionModel,
|
||||||
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
|
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
|
||||||
|
import { AdvancedSearchDialogComponent } from '../../common/advanced-search-dialog/advanced-search-dialog.component'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
|
|
||||||
const TEXT_FILTER_TARGET_TITLE = 'title'
|
const TEXT_FILTER_TARGET_TITLE = 'title'
|
||||||
@@ -286,6 +288,7 @@ export class FilterEditorComponent
|
|||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private customFieldService = inject(CustomFieldsService)
|
private customFieldService = inject(CustomFieldsService)
|
||||||
private searchService = inject(SearchService)
|
private searchService = inject(SearchService)
|
||||||
|
private modalService = inject(NgbModal)
|
||||||
|
|
||||||
generateFilterName() {
|
generateFilterName() {
|
||||||
if (this.filterRules.length == 1) {
|
if (this.filterRules.length == 1) {
|
||||||
@@ -1372,6 +1375,23 @@ export class FilterEditorComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get advancedSearchEditorAvailable(): boolean {
|
||||||
|
return this.textFilterTarget === TEXT_FILTER_TARGET_FULLTEXT_QUERY
|
||||||
|
}
|
||||||
|
|
||||||
|
openAdvancedSearchEditor() {
|
||||||
|
const modal = this.modalService.open(AdvancedSearchDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
size: 'lg',
|
||||||
|
})
|
||||||
|
modal.componentInstance.query = this._textFilter ?? ''
|
||||||
|
modal.componentInstance.queryApplied
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((query: string) => {
|
||||||
|
this.updateTextFilter(query)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
textFilterKeydown(event: KeyboardEvent) {
|
textFilterKeydown(event: KeyboardEvent) {
|
||||||
if (event.key == 'Enter') {
|
if (event.key == 'Enter') {
|
||||||
if (event.defaultPrevented) {
|
if (event.defaultPrevented) {
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// Fields and forms documented in docs/usage.md > "Document searches"
|
||||||
|
|
||||||
|
export enum AdvancedSearchField {
|
||||||
|
Any = '',
|
||||||
|
Title = 'title',
|
||||||
|
Content = 'content',
|
||||||
|
OriginalFilename = 'original_filename',
|
||||||
|
NoteText = 'notes.note',
|
||||||
|
NoteAuthor = 'notes.user',
|
||||||
|
CustomFieldName = 'custom_fields.name',
|
||||||
|
CustomFieldValue = 'custom_fields.value',
|
||||||
|
Correspondent = 'correspondent',
|
||||||
|
DocumentType = 'document_type',
|
||||||
|
StoragePath = 'storage_path',
|
||||||
|
Tag = 'tag',
|
||||||
|
ASN = 'asn',
|
||||||
|
PageCount = 'page_count',
|
||||||
|
NumNotes = 'num_notes',
|
||||||
|
Created = 'created',
|
||||||
|
Added = 'added',
|
||||||
|
Modified = 'modified',
|
||||||
|
Checksum = 'checksum',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AdvancedSearchFieldKind {
|
||||||
|
Text = 'text',
|
||||||
|
Number = 'number',
|
||||||
|
Date = 'date',
|
||||||
|
Checksum = 'checksum',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADVANCED_SEARCH_FIELD_KINDS: Record<
|
||||||
|
AdvancedSearchField,
|
||||||
|
AdvancedSearchFieldKind
|
||||||
|
> = {
|
||||||
|
[AdvancedSearchField.Any]: AdvancedSearchFieldKind.Text,
|
||||||
|
[AdvancedSearchField.Title]: AdvancedSearchFieldKind.Text,
|
||||||
|
[AdvancedSearchField.Content]: AdvancedSearchFieldKind.Text,
|
||||||
|
[AdvancedSearchField.OriginalFilename]: AdvancedSearchFieldKind.Text,
|
||||||
|
[AdvancedSearchField.NoteText]: AdvancedSearchFieldKind.Text,
|
||||||
|
[AdvancedSearchField.NoteAuthor]: AdvancedSearchFieldKind.Text,
|
||||||
|
[AdvancedSearchField.CustomFieldName]: AdvancedSearchFieldKind.Text,
|
||||||
|
[AdvancedSearchField.CustomFieldValue]: AdvancedSearchFieldKind.Text,
|
||||||
|
[AdvancedSearchField.Correspondent]: AdvancedSearchFieldKind.Text,
|
||||||
|
[AdvancedSearchField.DocumentType]: AdvancedSearchFieldKind.Text,
|
||||||
|
[AdvancedSearchField.StoragePath]: AdvancedSearchFieldKind.Text,
|
||||||
|
[AdvancedSearchField.Tag]: AdvancedSearchFieldKind.Text,
|
||||||
|
[AdvancedSearchField.ASN]: AdvancedSearchFieldKind.Number,
|
||||||
|
[AdvancedSearchField.PageCount]: AdvancedSearchFieldKind.Number,
|
||||||
|
[AdvancedSearchField.NumNotes]: AdvancedSearchFieldKind.Number,
|
||||||
|
[AdvancedSearchField.Created]: AdvancedSearchFieldKind.Date,
|
||||||
|
[AdvancedSearchField.Added]: AdvancedSearchFieldKind.Date,
|
||||||
|
[AdvancedSearchField.Modified]: AdvancedSearchFieldKind.Date,
|
||||||
|
[AdvancedSearchField.Checksum]: AdvancedSearchFieldKind.Checksum,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AdvancedSearchOperator {
|
||||||
|
AllWords = 'all',
|
||||||
|
AnyWord = 'any',
|
||||||
|
Phrase = 'phrase',
|
||||||
|
StartsWith = 'prefix',
|
||||||
|
Equals = 'eq',
|
||||||
|
AtLeast = 'gte',
|
||||||
|
AtMost = 'lte',
|
||||||
|
Between = 'between',
|
||||||
|
DateKeyword = 'keyword',
|
||||||
|
WithinLast = 'within',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADVANCED_SEARCH_OPERATORS_BY_KIND: Record<
|
||||||
|
AdvancedSearchFieldKind,
|
||||||
|
AdvancedSearchOperator[]
|
||||||
|
> = {
|
||||||
|
[AdvancedSearchFieldKind.Text]: [
|
||||||
|
AdvancedSearchOperator.AllWords,
|
||||||
|
AdvancedSearchOperator.AnyWord,
|
||||||
|
AdvancedSearchOperator.Phrase,
|
||||||
|
AdvancedSearchOperator.StartsWith,
|
||||||
|
],
|
||||||
|
[AdvancedSearchFieldKind.Number]: [
|
||||||
|
AdvancedSearchOperator.Equals,
|
||||||
|
AdvancedSearchOperator.AtLeast,
|
||||||
|
AdvancedSearchOperator.AtMost,
|
||||||
|
AdvancedSearchOperator.Between,
|
||||||
|
],
|
||||||
|
[AdvancedSearchFieldKind.Date]: [
|
||||||
|
AdvancedSearchOperator.DateKeyword,
|
||||||
|
AdvancedSearchOperator.WithinLast,
|
||||||
|
AdvancedSearchOperator.AtLeast,
|
||||||
|
AdvancedSearchOperator.AtMost,
|
||||||
|
AdvancedSearchOperator.Between,
|
||||||
|
],
|
||||||
|
[AdvancedSearchFieldKind.Checksum]: [AdvancedSearchOperator.StartsWith],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADVANCED_SEARCH_DATE_KEYWORDS = [
|
||||||
|
'today',
|
||||||
|
'yesterday',
|
||||||
|
'tomorrow',
|
||||||
|
'previous week',
|
||||||
|
'this month',
|
||||||
|
'previous month',
|
||||||
|
'previous quarter',
|
||||||
|
'this year',
|
||||||
|
'previous year',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type AdvancedSearchDateKeyword =
|
||||||
|
(typeof ADVANCED_SEARCH_DATE_KEYWORDS)[number]
|
||||||
|
|
||||||
|
export enum AdvancedSearchDateUnit {
|
||||||
|
Day = 'day',
|
||||||
|
Week = 'week',
|
||||||
|
Month = 'month',
|
||||||
|
Year = 'year',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AdvancedSearchLogicalOperator {
|
||||||
|
And = 'AND',
|
||||||
|
Or = 'OR',
|
||||||
|
Not = 'NOT',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AdvancedSearchQueryElementType {
|
||||||
|
Atom = 'atom',
|
||||||
|
Group = 'group',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdvancedSearchQueryAtom {
|
||||||
|
type: AdvancedSearchQueryElementType.Atom
|
||||||
|
field: AdvancedSearchField
|
||||||
|
operator: AdvancedSearchOperator
|
||||||
|
value?: string
|
||||||
|
valueTo?: string
|
||||||
|
unit?: AdvancedSearchDateUnit // for WithinLast
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdvancedSearchQueryGroup {
|
||||||
|
type: AdvancedSearchQueryElementType.Group
|
||||||
|
operator: AdvancedSearchLogicalOperator
|
||||||
|
children: AdvancedSearchQueryElement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdvancedSearchQueryElement =
|
||||||
|
AdvancedSearchQueryAtom | AdvancedSearchQueryGroup
|
||||||
|
|
||||||
|
export const ADVANCED_SEARCH_MAX_DEPTH = 2
|
||||||
|
export const ADVANCED_SEARCH_MAX_ATOMS = 10
|
||||||
|
|
||||||
|
export const ADVANCED_SEARCH_FIELD_LABELS: Record<AdvancedSearchField, string> =
|
||||||
|
{
|
||||||
|
[AdvancedSearchField.Any]: $localize`Any field`,
|
||||||
|
[AdvancedSearchField.Title]: $localize`Title`,
|
||||||
|
[AdvancedSearchField.Content]: $localize`Content`,
|
||||||
|
[AdvancedSearchField.OriginalFilename]: $localize`File name`,
|
||||||
|
[AdvancedSearchField.NoteText]: $localize`Note text`,
|
||||||
|
[AdvancedSearchField.NoteAuthor]: $localize`Note author`,
|
||||||
|
[AdvancedSearchField.CustomFieldName]: $localize`Custom field name`,
|
||||||
|
[AdvancedSearchField.CustomFieldValue]: $localize`Custom field value`,
|
||||||
|
[AdvancedSearchField.Correspondent]: $localize`Correspondent name`,
|
||||||
|
[AdvancedSearchField.DocumentType]: $localize`Document type name`,
|
||||||
|
[AdvancedSearchField.StoragePath]: $localize`Storage path name`,
|
||||||
|
[AdvancedSearchField.Tag]: $localize`Tag name`,
|
||||||
|
[AdvancedSearchField.ASN]: $localize`ASN`,
|
||||||
|
[AdvancedSearchField.PageCount]: $localize`Pages`,
|
||||||
|
[AdvancedSearchField.NumNotes]: $localize`Number of notes`,
|
||||||
|
[AdvancedSearchField.Created]: $localize`Created`,
|
||||||
|
[AdvancedSearchField.Added]: $localize`Added`,
|
||||||
|
[AdvancedSearchField.Modified]: $localize`Modified`,
|
||||||
|
[AdvancedSearchField.Checksum]: $localize`Checksum`,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADVANCED_SEARCH_FIELD_GROUPS: {
|
||||||
|
label: string
|
||||||
|
fields: AdvancedSearchField[]
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
label: $localize`Text`,
|
||||||
|
fields: [
|
||||||
|
AdvancedSearchField.Any,
|
||||||
|
AdvancedSearchField.Title,
|
||||||
|
AdvancedSearchField.Content,
|
||||||
|
AdvancedSearchField.OriginalFilename,
|
||||||
|
AdvancedSearchField.NoteText,
|
||||||
|
AdvancedSearchField.NoteAuthor,
|
||||||
|
AdvancedSearchField.CustomFieldName,
|
||||||
|
AdvancedSearchField.CustomFieldValue,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $localize`Names`,
|
||||||
|
fields: [
|
||||||
|
AdvancedSearchField.Correspondent,
|
||||||
|
AdvancedSearchField.DocumentType,
|
||||||
|
AdvancedSearchField.StoragePath,
|
||||||
|
AdvancedSearchField.Tag,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $localize`Numbers`,
|
||||||
|
fields: [
|
||||||
|
AdvancedSearchField.ASN,
|
||||||
|
AdvancedSearchField.PageCount,
|
||||||
|
AdvancedSearchField.NumNotes,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $localize`Dates`,
|
||||||
|
fields: [
|
||||||
|
AdvancedSearchField.Created,
|
||||||
|
AdvancedSearchField.Added,
|
||||||
|
AdvancedSearchField.Modified,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ label: $localize`Other`, fields: [AdvancedSearchField.Checksum] },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ADVANCED_SEARCH_OPERATOR_LABELS: Record<
|
||||||
|
AdvancedSearchOperator,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
[AdvancedSearchOperator.AllWords]: $localize`contains all words`,
|
||||||
|
[AdvancedSearchOperator.AnyWord]: $localize`contains any word`,
|
||||||
|
[AdvancedSearchOperator.Phrase]: $localize`contains the phrase`,
|
||||||
|
[AdvancedSearchOperator.StartsWith]: $localize`starts with`,
|
||||||
|
[AdvancedSearchOperator.Equals]: $localize`is`,
|
||||||
|
[AdvancedSearchOperator.AtLeast]: $localize`is at least`,
|
||||||
|
[AdvancedSearchOperator.AtMost]: $localize`is at most`,
|
||||||
|
[AdvancedSearchOperator.Between]: $localize`is between`,
|
||||||
|
[AdvancedSearchOperator.DateKeyword]: $localize`is`,
|
||||||
|
[AdvancedSearchOperator.WithinLast]: $localize`is within the last`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparing dates reads differently than comparing counts
|
||||||
|
export const ADVANCED_SEARCH_DATE_OPERATOR_LABELS: Partial<
|
||||||
|
Record<AdvancedSearchOperator, string>
|
||||||
|
> = {
|
||||||
|
[AdvancedSearchOperator.AtLeast]: $localize`is on or after`,
|
||||||
|
[AdvancedSearchOperator.AtMost]: $localize`is on or before`,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADVANCED_SEARCH_DATE_KEYWORD_LABELS: Record<string, string> = {
|
||||||
|
today: $localize`today`,
|
||||||
|
yesterday: $localize`yesterday`,
|
||||||
|
tomorrow: $localize`tomorrow`,
|
||||||
|
'previous week': $localize`previous week`,
|
||||||
|
'this month': $localize`this month`,
|
||||||
|
'previous month': $localize`previous month`,
|
||||||
|
'previous quarter': $localize`previous quarter`,
|
||||||
|
'this year': $localize`this year`,
|
||||||
|
'previous year': $localize`previous year`,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADVANCED_SEARCH_DATE_UNIT_LABELS: Record<
|
||||||
|
AdvancedSearchDateUnit,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
[AdvancedSearchDateUnit.Day]: $localize`days`,
|
||||||
|
[AdvancedSearchDateUnit.Week]: $localize`weeks`,
|
||||||
|
[AdvancedSearchDateUnit.Month]: $localize`months`,
|
||||||
|
[AdvancedSearchDateUnit.Year]: $localize`years`,
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import {
|
||||||
|
AdvancedSearchDateUnit,
|
||||||
|
AdvancedSearchField,
|
||||||
|
AdvancedSearchLogicalOperator,
|
||||||
|
AdvancedSearchOperator,
|
||||||
|
AdvancedSearchQueryAtom,
|
||||||
|
AdvancedSearchQueryElement,
|
||||||
|
AdvancedSearchQueryElementType,
|
||||||
|
AdvancedSearchQueryGroup,
|
||||||
|
} from '../data/advanced-search-query'
|
||||||
|
import {
|
||||||
|
parseAdvancedSearchQuery,
|
||||||
|
serializeAdvancedSearchQuery,
|
||||||
|
} from './advanced-search-query'
|
||||||
|
|
||||||
|
const atom = (
|
||||||
|
field: AdvancedSearchField,
|
||||||
|
operator: AdvancedSearchOperator,
|
||||||
|
value?: string,
|
||||||
|
extra: Partial<AdvancedSearchQueryAtom> = {}
|
||||||
|
): AdvancedSearchQueryAtom => ({
|
||||||
|
type: AdvancedSearchQueryElementType.Atom,
|
||||||
|
field,
|
||||||
|
operator,
|
||||||
|
value,
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
|
||||||
|
const group = (
|
||||||
|
operator: AdvancedSearchLogicalOperator,
|
||||||
|
...children: AdvancedSearchQueryElement[]
|
||||||
|
): AdvancedSearchQueryGroup => ({
|
||||||
|
type: AdvancedSearchQueryElementType.Group,
|
||||||
|
operator,
|
||||||
|
children,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { And, Or, Not } = AdvancedSearchLogicalOperator
|
||||||
|
|
||||||
|
describe('serializeAdvancedSearchQuery', () => {
|
||||||
|
describe('text fields', () => {
|
||||||
|
it.each([
|
||||||
|
[AdvancedSearchOperator.AllWords, 'invoice', 'title:invoice'],
|
||||||
|
[
|
||||||
|
AdvancedSearchOperator.AllWords,
|
||||||
|
' invoice unpaid ',
|
||||||
|
'title:invoice AND title:unpaid',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
AdvancedSearchOperator.AnyWord,
|
||||||
|
'invoice unpaid',
|
||||||
|
'title:invoice OR title:unpaid',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
AdvancedSearchOperator.Phrase,
|
||||||
|
'quick brown fox',
|
||||||
|
'title:"quick brown fox"',
|
||||||
|
],
|
||||||
|
[AdvancedSearchOperator.Phrase, 'say "hi"', 'title:"say hi"'],
|
||||||
|
[AdvancedSearchOperator.StartsWith, 'invoi', 'title:invoi*'],
|
||||||
|
[AdvancedSearchOperator.StartsWith, 'in*v?oi', 'title:invoi*'],
|
||||||
|
])('%s %j writes %s', (operator, value, expected) => {
|
||||||
|
expect(
|
||||||
|
serializeAdvancedSearchQuery(
|
||||||
|
atom(AdvancedSearchField.Title, operator, value)
|
||||||
|
)
|
||||||
|
).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes bare words for the Any field', () => {
|
||||||
|
expect(
|
||||||
|
serializeAdvancedSearchQuery(
|
||||||
|
atom(AdvancedSearchField.Any, AdvancedSearchOperator.AllWords, 'a b')
|
||||||
|
)
|
||||||
|
).toBe('a AND b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['A-1312/99', 'custom_fields.value:A-1312/99'],
|
||||||
|
["O'Brien", "custom_fields.value:O'Brien"],
|
||||||
|
["'quoted'", `custom_fields.value:"'quoted'"`],
|
||||||
|
['foo:bar', 'custom_fields.value:"foo:bar"'],
|
||||||
|
['(x)', 'custom_fields.value:"(x)"'],
|
||||||
|
['2024*', 'custom_fields.value:"2024*"'],
|
||||||
|
['a,b', 'custom_fields.value:"a,b"'],
|
||||||
|
['OR', 'custom_fields.value:"OR"'],
|
||||||
|
['or', 'custom_fields.value:or'],
|
||||||
|
])(
|
||||||
|
'quotes %j only when the grammar would read it as syntax',
|
||||||
|
(value, expected) => {
|
||||||
|
expect(
|
||||||
|
serializeAdvancedSearchQuery(
|
||||||
|
atom(
|
||||||
|
AdvancedSearchField.CustomFieldValue,
|
||||||
|
AdvancedSearchOperator.AllWords,
|
||||||
|
value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toBe(expected)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it('uses the dotted names for custom fields', () => {
|
||||||
|
expect(
|
||||||
|
serializeAdvancedSearchQuery(
|
||||||
|
group(
|
||||||
|
And,
|
||||||
|
atom(
|
||||||
|
AdvancedSearchField.CustomFieldName,
|
||||||
|
AdvancedSearchOperator.Phrase,
|
||||||
|
'status'
|
||||||
|
),
|
||||||
|
atom(
|
||||||
|
AdvancedSearchField.CustomFieldValue,
|
||||||
|
AdvancedSearchOperator.AllWords,
|
||||||
|
'paid'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toBe('custom_fields.name:"status" AND custom_fields.value:paid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the dotted names for notes', () => {
|
||||||
|
expect(
|
||||||
|
serializeAdvancedSearchQuery(
|
||||||
|
group(
|
||||||
|
And,
|
||||||
|
atom(
|
||||||
|
AdvancedSearchField.NoteText,
|
||||||
|
AdvancedSearchOperator.AllWords,
|
||||||
|
'call'
|
||||||
|
),
|
||||||
|
atom(
|
||||||
|
AdvancedSearchField.NoteAuthor,
|
||||||
|
AdvancedSearchOperator.AllWords,
|
||||||
|
'alice'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toBe('notes.note:call AND notes.user:alice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[AdvancedSearchOperator.AllWords, ''],
|
||||||
|
[AdvancedSearchOperator.AllWords, ' '],
|
||||||
|
[AdvancedSearchOperator.AllWords, '!! --'],
|
||||||
|
[AdvancedSearchOperator.Phrase, '""'],
|
||||||
|
[AdvancedSearchOperator.StartsWith, 'two words'],
|
||||||
|
[AdvancedSearchOperator.StartsWith, '***'],
|
||||||
|
[AdvancedSearchOperator.StartsWith, undefined],
|
||||||
|
])('leaves out %s %j', (operator, value) => {
|
||||||
|
expect(
|
||||||
|
serializeAdvancedSearchQuery(
|
||||||
|
atom(AdvancedSearchField.Title, operator, value)
|
||||||
|
)
|
||||||
|
).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('checksum', () => {
|
||||||
|
it('lowercases the prefix', () => {
|
||||||
|
expect(
|
||||||
|
serializeAdvancedSearchQuery(
|
||||||
|
atom(
|
||||||
|
AdvancedSearchField.Checksum,
|
||||||
|
AdvancedSearchOperator.StartsWith,
|
||||||
|
'9F86D081'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toBe('checksum:9f86d081*')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('number fields', () => {
|
||||||
|
it.each([
|
||||||
|
[AdvancedSearchOperator.Equals, '42', undefined, 'asn:42'],
|
||||||
|
[AdvancedSearchOperator.AtLeast, '50', undefined, 'asn:[50 to]'],
|
||||||
|
[AdvancedSearchOperator.AtMost, '50', undefined, 'asn:[to 50]'],
|
||||||
|
[AdvancedSearchOperator.Between, '50', '150', 'asn:[50 to 150]'],
|
||||||
|
[AdvancedSearchOperator.Equals, '4.2', undefined, ''],
|
||||||
|
[AdvancedSearchOperator.Equals, '-1', undefined, ''],
|
||||||
|
[AdvancedSearchOperator.Equals, '2024-01-01', undefined, ''],
|
||||||
|
[AdvancedSearchOperator.Between, '50', '', ''],
|
||||||
|
])('%s %j %j writes %j', (operator, value, valueTo, expected) => {
|
||||||
|
expect(
|
||||||
|
serializeAdvancedSearchQuery(
|
||||||
|
atom(AdvancedSearchField.ASN, operator, value, { valueTo })
|
||||||
|
)
|
||||||
|
).toBe(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('date fields', () => {
|
||||||
|
it.each([
|
||||||
|
['today', 'added:today'],
|
||||||
|
['previous month', 'added:"previous month"'],
|
||||||
|
['last tuesday', ''],
|
||||||
|
['', ''],
|
||||||
|
])('keyword %j writes %j', (value, expected) => {
|
||||||
|
expect(
|
||||||
|
serializeAdvancedSearchQuery(
|
||||||
|
atom(
|
||||||
|
AdvancedSearchField.Added,
|
||||||
|
AdvancedSearchOperator.DateKeyword,
|
||||||
|
value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['1', AdvancedSearchDateUnit.Day, 'added:[-1 day to now]'],
|
||||||
|
['3', AdvancedSearchDateUnit.Month, 'added:[-3 months to now]'],
|
||||||
|
['2', AdvancedSearchDateUnit.Week, 'added:[-2 weeks to now]'],
|
||||||
|
['0', AdvancedSearchDateUnit.Year, ''],
|
||||||
|
['1.5', AdvancedSearchDateUnit.Year, ''],
|
||||||
|
['3', undefined, ''],
|
||||||
|
['3', 'fortnight' as AdvancedSearchDateUnit, ''],
|
||||||
|
])('within the last %j %j writes %j', (value, unit, expected) => {
|
||||||
|
expect(
|
||||||
|
serializeAdvancedSearchQuery(
|
||||||
|
atom(
|
||||||
|
AdvancedSearchField.Added,
|
||||||
|
AdvancedSearchOperator.WithinLast,
|
||||||
|
value,
|
||||||
|
{
|
||||||
|
unit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
AdvancedSearchOperator.AtLeast,
|
||||||
|
'2024-01-01',
|
||||||
|
undefined,
|
||||||
|
'created:[2024-01-01 to]',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
AdvancedSearchOperator.AtMost,
|
||||||
|
'2024-01-01',
|
||||||
|
undefined,
|
||||||
|
'created:[to 2024-01-01]',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
AdvancedSearchOperator.Between,
|
||||||
|
'2024-01-01',
|
||||||
|
'2024-03-31',
|
||||||
|
'created:[2024-01-01 to 2024-03-31]',
|
||||||
|
],
|
||||||
|
[AdvancedSearchOperator.AtLeast, '2024', undefined, ''],
|
||||||
|
[AdvancedSearchOperator.Between, '2024-01-01', 'now', ''],
|
||||||
|
])('%s %j %j writes %j', (operator, value, valueTo, expected) => {
|
||||||
|
expect(
|
||||||
|
serializeAdvancedSearchQuery(
|
||||||
|
atom(AdvancedSearchField.Created, operator, value, { valueTo })
|
||||||
|
)
|
||||||
|
).toBe(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('groups', () => {
|
||||||
|
const invoice = atom(
|
||||||
|
AdvancedSearchField.Content,
|
||||||
|
AdvancedSearchOperator.AllWords,
|
||||||
|
'invoice'
|
||||||
|
)
|
||||||
|
const letter = atom(
|
||||||
|
AdvancedSearchField.Title,
|
||||||
|
AdvancedSearchOperator.AllWords,
|
||||||
|
'letter'
|
||||||
|
)
|
||||||
|
const paid = atom(
|
||||||
|
AdvancedSearchField.Tag,
|
||||||
|
AdvancedSearchOperator.AllWords,
|
||||||
|
'paid'
|
||||||
|
)
|
||||||
|
const twoWords = atom(
|
||||||
|
AdvancedSearchField.Title,
|
||||||
|
AdvancedSearchOperator.AllWords,
|
||||||
|
'a b'
|
||||||
|
)
|
||||||
|
const anyWords = atom(
|
||||||
|
AdvancedSearchField.Title,
|
||||||
|
AdvancedSearchOperator.AnyWord,
|
||||||
|
'a b'
|
||||||
|
)
|
||||||
|
const empty = atom(
|
||||||
|
AdvancedSearchField.Title,
|
||||||
|
AdvancedSearchOperator.AllWords,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['an empty group', group(And), ''],
|
||||||
|
['a group of empty atoms', group(Or, empty, group(And, empty)), ''],
|
||||||
|
[
|
||||||
|
'a single child without parentheses',
|
||||||
|
group(Or, invoice),
|
||||||
|
'content:invoice',
|
||||||
|
],
|
||||||
|
['All', group(And, invoice, letter), 'content:invoice AND title:letter'],
|
||||||
|
['Any', group(Or, invoice, letter), 'content:invoice OR title:letter'],
|
||||||
|
[
|
||||||
|
'skipped empty atoms',
|
||||||
|
group(And, empty, invoice, empty),
|
||||||
|
'content:invoice',
|
||||||
|
],
|
||||||
|
['Not with one child', group(Not, paid), 'NOT tag:paid'],
|
||||||
|
[
|
||||||
|
'Not as none of',
|
||||||
|
group(Not, paid, letter),
|
||||||
|
'NOT (tag:paid OR title:letter)',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Not with a compound child',
|
||||||
|
group(Not, twoWords),
|
||||||
|
'NOT (title:a AND title:b)',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Not inside All',
|
||||||
|
group(And, invoice, group(Not, paid)),
|
||||||
|
'content:invoice AND NOT tag:paid',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Not inside Any',
|
||||||
|
group(Or, invoice, group(Not, paid)),
|
||||||
|
'content:invoice OR NOT tag:paid',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Any inside All',
|
||||||
|
group(And, invoice, group(Or, letter, paid)),
|
||||||
|
'content:invoice AND (title:letter OR tag:paid)',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'All inside Any',
|
||||||
|
group(Or, invoice, group(And, letter, paid)),
|
||||||
|
'content:invoice OR (title:letter AND tag:paid)',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'All inside All flattened',
|
||||||
|
group(And, invoice, group(And, letter, paid)),
|
||||||
|
'content:invoice AND title:letter AND tag:paid',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'an all-words atom inside Any',
|
||||||
|
group(Or, invoice, twoWords),
|
||||||
|
'content:invoice OR (title:a AND title:b)',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'an any-word atom inside All',
|
||||||
|
group(And, invoice, anyWords),
|
||||||
|
'content:invoice AND (title:a OR title:b)',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'an all-words atom inside Not with siblings',
|
||||||
|
group(Not, paid, twoWords),
|
||||||
|
'NOT (tag:paid OR (title:a AND title:b))',
|
||||||
|
],
|
||||||
|
])('writes %s', (_, tree, expected) => {
|
||||||
|
expect(serializeAdvancedSearchQuery(tree)).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes the mockup example', () => {
|
||||||
|
expect(
|
||||||
|
serializeAdvancedSearchQuery(
|
||||||
|
group(
|
||||||
|
And,
|
||||||
|
invoice,
|
||||||
|
group(
|
||||||
|
Or,
|
||||||
|
atom(
|
||||||
|
AdvancedSearchField.Correspondent,
|
||||||
|
AdvancedSearchOperator.Phrase,
|
||||||
|
'acme corp'
|
||||||
|
),
|
||||||
|
atom(
|
||||||
|
AdvancedSearchField.Content,
|
||||||
|
AdvancedSearchOperator.Phrase,
|
||||||
|
'acme corporation'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
atom(
|
||||||
|
AdvancedSearchField.Added,
|
||||||
|
AdvancedSearchOperator.WithinLast,
|
||||||
|
'3',
|
||||||
|
{
|
||||||
|
unit: AdvancedSearchDateUnit.Month,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
group(Not, paid)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toBe(
|
||||||
|
'content:invoice AND (correspondent:"acme corp" OR content:"acme corporation") AND added:[-3 months to now] AND NOT tag:paid'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseAdvancedSearchQuery', () => {
|
||||||
|
const canonical = [
|
||||||
|
'title:invoice',
|
||||||
|
'title:invoice AND title:unpaid',
|
||||||
|
'content:invoice OR content:receipt',
|
||||||
|
'a AND b',
|
||||||
|
'title:"quick brown fox"',
|
||||||
|
'title:invoi*',
|
||||||
|
"custom_fields.value:O'Brien",
|
||||||
|
'custom_fields.value:"foo:bar"',
|
||||||
|
'custom_fields.name:"status" AND custom_fields.value:paid',
|
||||||
|
'notes.note:call AND notes.user:alice',
|
||||||
|
'checksum:9f86d081*',
|
||||||
|
'asn:42',
|
||||||
|
'asn:[50 to 150]',
|
||||||
|
'asn:[50 to]',
|
||||||
|
'asn:[to 50]',
|
||||||
|
'page_count:[10 to]',
|
||||||
|
'added:today',
|
||||||
|
'added:"previous month"',
|
||||||
|
'added:[-1 day to now]',
|
||||||
|
'added:[-3 months to now]',
|
||||||
|
'created:[2024-01-01 to 2024-03-31]',
|
||||||
|
'created:[2024-01-01 to]',
|
||||||
|
'created:[to 2024-01-01]',
|
||||||
|
'NOT tag:paid',
|
||||||
|
'NOT (tag:paid OR title:letter)',
|
||||||
|
'NOT (title:a AND title:b)',
|
||||||
|
'content:invoice OR NOT tag:paid',
|
||||||
|
'content:invoice AND (title:letter OR tag:paid)',
|
||||||
|
'content:invoice OR (title:letter AND tag:paid)',
|
||||||
|
'content:invoice AND (correspondent:"acme corp" OR content:"acme corporation") AND added:[-3 months to now] AND NOT tag:paid',
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(canonical)('reads back %s unchanged', (query) => {
|
||||||
|
const tree = parseAdvancedSearchQuery(query)
|
||||||
|
expect(tree).not.toBeNull()
|
||||||
|
expect(serializeAdvancedSearchQuery(tree)).toBe(query)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads surrounding whitespace', () => {
|
||||||
|
expect(
|
||||||
|
serializeAdvancedSearchQuery(
|
||||||
|
parseAdvancedSearchQuery(' title:invoice ')
|
||||||
|
)
|
||||||
|
).toBe('title:invoice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts the words of one condition back together', () => {
|
||||||
|
expect(parseAdvancedSearchQuery('title:invoice AND title:unpaid')).toEqual(
|
||||||
|
group(
|
||||||
|
And,
|
||||||
|
atom(
|
||||||
|
AdvancedSearchField.Title,
|
||||||
|
AdvancedSearchOperator.AllWords,
|
||||||
|
'invoice unpaid'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps words of different fields apart', () => {
|
||||||
|
expect(parseAdvancedSearchQuery('title:a OR content:b')).toEqual(
|
||||||
|
group(
|
||||||
|
Or,
|
||||||
|
atom(AdvancedSearchField.Title, AdvancedSearchOperator.AllWords, 'a'),
|
||||||
|
atom(AdvancedSearchField.Content, AdvancedSearchOperator.AllWords, 'b')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads a range as its condition', () => {
|
||||||
|
expect(parseAdvancedSearchQuery('added:[-3 months to now]')).toEqual(
|
||||||
|
group(
|
||||||
|
And,
|
||||||
|
atom(
|
||||||
|
AdvancedSearchField.Added,
|
||||||
|
AdvancedSearchOperator.WithinLast,
|
||||||
|
'3',
|
||||||
|
{
|
||||||
|
unit: AdvancedSearchDateUnit.Month,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['', 'nothing'],
|
||||||
|
[' ', 'whitespace'],
|
||||||
|
['type:invoice', 'a field alias'],
|
||||||
|
['notes:call', 'a bare notes prefix'],
|
||||||
|
['unknown:x', 'an unknown field'],
|
||||||
|
['title:a AND content:b OR title:c', 'AND and OR mixed at one level'],
|
||||||
|
['title:a b', 'an implicit AND'],
|
||||||
|
['title:"unterminated', 'an unterminated phrase'],
|
||||||
|
['asn:[50 to', 'an unterminated range'],
|
||||||
|
['title:a*b', 'a wildcard in the middle'],
|
||||||
|
['title:invoice^2', 'a boost'],
|
||||||
|
['asn:[50 to abc]', 'a non-numeric bound'],
|
||||||
|
['asn:invoice', 'a word on a number field'],
|
||||||
|
['created:[2024 to 2025]', 'a year-only date range'],
|
||||||
|
['added:"last tuesday"', 'a date keyword paperless does not have'],
|
||||||
|
['title:[a to b]', 'a range on a text field'],
|
||||||
|
['checksum:9f86d081', 'a checksum without a wildcard'],
|
||||||
|
['(title:invoice)', 'parentheses the editor would not write'],
|
||||||
|
['title:invoice AND', 'a trailing operator'],
|
||||||
|
['title:"a"b', 'a value running into the next'],
|
||||||
|
['ADDED:today', 'an uppercase field name'],
|
||||||
|
])('leaves %s alone, having %s', (query) => {
|
||||||
|
expect(parseAdvancedSearchQuery(query)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
import {
|
||||||
|
ADVANCED_SEARCH_DATE_KEYWORDS,
|
||||||
|
ADVANCED_SEARCH_FIELD_KINDS,
|
||||||
|
AdvancedSearchDateUnit,
|
||||||
|
AdvancedSearchField,
|
||||||
|
AdvancedSearchFieldKind,
|
||||||
|
AdvancedSearchLogicalOperator,
|
||||||
|
AdvancedSearchOperator,
|
||||||
|
AdvancedSearchQueryAtom,
|
||||||
|
AdvancedSearchQueryElement,
|
||||||
|
AdvancedSearchQueryElementType,
|
||||||
|
AdvancedSearchQueryGroup,
|
||||||
|
} from '../data/advanced-search-query'
|
||||||
|
|
||||||
|
// Anything the query grammar would read as syntax rather than as a word
|
||||||
|
const SYNTAX_CHARS = /[\s():"[\]*?,{}^~\\]/
|
||||||
|
const SYNTAX_CHARS_GLOBAL = new RegExp(SYNTAX_CHARS, 'g')
|
||||||
|
// A word wrapped in single quotes is also syntax, an apostrophe inside one is not
|
||||||
|
const EDGE_SINGLE_QUOTE = /^'|'$/
|
||||||
|
const RESERVED_WORDS = /^(AND|OR|NOT|TO)$/
|
||||||
|
const HAS_WORD_CHAR = /[\p{L}\p{N}]/u
|
||||||
|
const WHOLE_NUMBER = /^\d+$/
|
||||||
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
|
||||||
|
interface Serialized {
|
||||||
|
text: string
|
||||||
|
// The operator joining the top level of `text`, null when it is self-delimiting
|
||||||
|
join:
|
||||||
|
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = (field: AdvancedSearchField) => (field ? `${field}:` : '')
|
||||||
|
|
||||||
|
const quote = (text: string) => `"${text.replace(/"/g, '')}"`
|
||||||
|
|
||||||
|
const words = (value: string) =>
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((word) => HAS_WORD_CHAR.test(word))
|
||||||
|
|
||||||
|
const word = (w: string) =>
|
||||||
|
SYNTAX_CHARS.test(w) || EDGE_SINGLE_QUOTE.test(w) || RESERVED_WORDS.test(w)
|
||||||
|
? quote(w)
|
||||||
|
: w
|
||||||
|
|
||||||
|
const atomic = (text: string): Serialized => ({ text, join: null })
|
||||||
|
|
||||||
|
function serializeWords(
|
||||||
|
atom: AdvancedSearchQueryAtom,
|
||||||
|
join: AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
|
||||||
|
): Serialized {
|
||||||
|
// A field applies only to the word right after it, so repeat it per word
|
||||||
|
const terms = words(atom.value ?? '').map(
|
||||||
|
(w) => `${prefix(atom.field)}${word(w)}`
|
||||||
|
)
|
||||||
|
if (terms.length === 0) return null
|
||||||
|
if (terms.length === 1) return atomic(terms[0])
|
||||||
|
return { text: terms.join(` ${join} `), join }
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeRange(
|
||||||
|
atom: AdvancedSearchQueryAtom,
|
||||||
|
isValid: (v: string) => boolean
|
||||||
|
): Serialized {
|
||||||
|
const lo = atom.value?.trim() ?? ''
|
||||||
|
const hi = atom.valueTo?.trim() ?? ''
|
||||||
|
const field = prefix(atom.field)
|
||||||
|
switch (atom.operator) {
|
||||||
|
case AdvancedSearchOperator.Equals:
|
||||||
|
return isValid(lo) ? atomic(`${field}${lo}`) : null
|
||||||
|
case AdvancedSearchOperator.AtLeast:
|
||||||
|
return isValid(lo) ? atomic(`${field}[${lo} to]`) : null
|
||||||
|
case AdvancedSearchOperator.AtMost:
|
||||||
|
return isValid(lo) ? atomic(`${field}[to ${lo}]`) : null
|
||||||
|
case AdvancedSearchOperator.Between:
|
||||||
|
return isValid(lo) && isValid(hi)
|
||||||
|
? atomic(`${field}[${lo} to ${hi}]`)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeAtom(atom: AdvancedSearchQueryAtom): Serialized {
|
||||||
|
const field = prefix(atom.field)
|
||||||
|
const value = atom.value?.trim() ?? ''
|
||||||
|
|
||||||
|
switch (atom.operator) {
|
||||||
|
case AdvancedSearchOperator.AllWords:
|
||||||
|
return serializeWords(atom, AdvancedSearchLogicalOperator.And)
|
||||||
|
case AdvancedSearchOperator.AnyWord:
|
||||||
|
return serializeWords(atom, AdvancedSearchLogicalOperator.Or)
|
||||||
|
case AdvancedSearchOperator.Phrase:
|
||||||
|
return HAS_WORD_CHAR.test(value)
|
||||||
|
? atomic(`${field}${quote(value)}`)
|
||||||
|
: null
|
||||||
|
case AdvancedSearchOperator.StartsWith: {
|
||||||
|
if (/\s/.test(value)) return null
|
||||||
|
let stem = value.replace(SYNTAX_CHARS_GLOBAL, '')
|
||||||
|
if (!HAS_WORD_CHAR.test(stem)) return null
|
||||||
|
// checksum is indexed as-is, in lowercase
|
||||||
|
if (atom.field === AdvancedSearchField.Checksum) {
|
||||||
|
stem = stem.toLowerCase()
|
||||||
|
}
|
||||||
|
return atomic(`${field}${stem}*`)
|
||||||
|
}
|
||||||
|
case AdvancedSearchOperator.DateKeyword:
|
||||||
|
return (ADVANCED_SEARCH_DATE_KEYWORDS as readonly string[]).includes(
|
||||||
|
value
|
||||||
|
)
|
||||||
|
? atomic(`${field}${word(value)}`)
|
||||||
|
: null
|
||||||
|
case AdvancedSearchOperator.WithinLast: {
|
||||||
|
const amount = parseInt(value, 10)
|
||||||
|
const units = Object.values(AdvancedSearchDateUnit) as string[]
|
||||||
|
if (!WHOLE_NUMBER.test(value) || amount < 1) return null
|
||||||
|
if (!units.includes(atom.unit)) return null
|
||||||
|
const unit = amount === 1 ? atom.unit : `${atom.unit}s`
|
||||||
|
return atomic(`${field}[-${amount} ${unit} to now]`)
|
||||||
|
}
|
||||||
|
case AdvancedSearchOperator.Equals:
|
||||||
|
case AdvancedSearchOperator.AtLeast:
|
||||||
|
case AdvancedSearchOperator.AtMost:
|
||||||
|
case AdvancedSearchOperator.Between:
|
||||||
|
return serializeRange(
|
||||||
|
atom,
|
||||||
|
ADVANCED_SEARCH_FIELD_KINDS[atom.field] === AdvancedSearchFieldKind.Date
|
||||||
|
? (v) => ISO_DATE.test(v)
|
||||||
|
: (v) => WHOLE_NUMBER.test(v)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(
|
||||||
|
child: Serialized,
|
||||||
|
parentJoin: AdvancedSearchLogicalOperator
|
||||||
|
): string {
|
||||||
|
return child.join === null || child.join === parentJoin
|
||||||
|
? child.text
|
||||||
|
: `(${child.text})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeGroup(group: AdvancedSearchQueryGroup): Serialized {
|
||||||
|
const children = group.children.map(serializeElement).filter(Boolean)
|
||||||
|
if (children.length === 0) return null
|
||||||
|
|
||||||
|
if (group.operator === AdvancedSearchLogicalOperator.Not) {
|
||||||
|
// A Not group matches documents matching none of its children
|
||||||
|
if (children.length === 1 && children[0].join === null) {
|
||||||
|
return atomic(`NOT ${children[0].text}`)
|
||||||
|
}
|
||||||
|
const inner =
|
||||||
|
children.length === 1
|
||||||
|
? children[0].text
|
||||||
|
: children
|
||||||
|
.map((c) => wrap(c, AdvancedSearchLogicalOperator.Or))
|
||||||
|
.join(' OR ')
|
||||||
|
return atomic(`NOT (${inner})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children.length === 1) return children[0]
|
||||||
|
return {
|
||||||
|
text: children
|
||||||
|
.map((c) => wrap(c, group.operator))
|
||||||
|
.join(` ${group.operator} `),
|
||||||
|
join: group.operator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeElement(element: AdvancedSearchQueryElement): Serialized {
|
||||||
|
return element.type === AdvancedSearchQueryElementType.Group
|
||||||
|
? serializeGroup(element)
|
||||||
|
: serializeAtom(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes an editor tree as a full-text query. Atoms that are not filled in
|
||||||
|
* (or not valid) are left out, and empty groups with them.
|
||||||
|
*/
|
||||||
|
export function serializeAdvancedSearchQuery(
|
||||||
|
element: AdvancedSearchQueryElement
|
||||||
|
): string {
|
||||||
|
return serializeElement(element)?.text ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reading a query back into the editor --------------------------------
|
||||||
|
//
|
||||||
|
// Deliberately narrow: this reads the forms serializeAdvancedSearchQuery
|
||||||
|
// writes, and nothing else. A query it cannot read is not a failure, it just
|
||||||
|
// stays text, so there is never a lossy or surprising conversion. The final
|
||||||
|
// round-trip check below is what holds that promise: a tree is only returned
|
||||||
|
// when writing it out again reproduces the query exactly.
|
||||||
|
|
||||||
|
const RELATIVE_BOUND = /^-(\d+) (day|week|month|year)s?$/
|
||||||
|
const FIELD_PREFIX = /^([a-z_]+(?:\.[a-z_]+)?):/
|
||||||
|
const KEYWORD_TOKEN = /^(AND|OR|NOT)(?=[\s(]|$)/
|
||||||
|
// Either bound may be missing: [50 to 150], [50 to], [to 50]
|
||||||
|
const RANGE_BOUNDS = /^(?:(.+?) )?to(?: (.+))?$/
|
||||||
|
const TRAILING_WILDCARD = /^([^*?]+)\*$/
|
||||||
|
|
||||||
|
class UnreadableQuery extends Error {}
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
type: 'term' | 'AND' | 'OR' | 'NOT' | '(' | ')'
|
||||||
|
field?: string
|
||||||
|
value?: string
|
||||||
|
quoted?: boolean
|
||||||
|
range?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// A parsed element, plus what it takes to merge the per-word terms the
|
||||||
|
// serializer writes for "contains all words" back into a single condition
|
||||||
|
interface Parsed {
|
||||||
|
element: AdvancedSearchQueryElement
|
||||||
|
word?: { field: AdvancedSearchField; text: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(query: string): Token[] {
|
||||||
|
const tokens: Token[] = []
|
||||||
|
let i = 0
|
||||||
|
while (i < query.length) {
|
||||||
|
const rest = query.slice(i)
|
||||||
|
if (/^\s/.test(rest)) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (rest[0] === '(' || rest[0] === ')') {
|
||||||
|
tokens.push({ type: rest[0] as '(' | ')' })
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const keyword = KEYWORD_TOKEN.exec(rest)
|
||||||
|
if (keyword) {
|
||||||
|
tokens.push({ type: keyword[1] as 'AND' | 'OR' | 'NOT' })
|
||||||
|
i += keyword[1].length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const fieldMatch = FIELD_PREFIX.exec(rest)
|
||||||
|
const field = fieldMatch ? fieldMatch[1] : ''
|
||||||
|
i += fieldMatch ? fieldMatch[0].length : 0
|
||||||
|
|
||||||
|
const value = query.slice(i)
|
||||||
|
if (value.startsWith('"')) {
|
||||||
|
const end = query.indexOf('"', i + 1)
|
||||||
|
if (end < 0) throw new UnreadableQuery()
|
||||||
|
tokens.push({
|
||||||
|
type: 'term',
|
||||||
|
field,
|
||||||
|
value: query.slice(i + 1, end),
|
||||||
|
quoted: true,
|
||||||
|
})
|
||||||
|
i = end + 1
|
||||||
|
} else if (value.startsWith('[')) {
|
||||||
|
const end = query.indexOf(']', i + 1)
|
||||||
|
if (end < 0) throw new UnreadableQuery()
|
||||||
|
tokens.push({
|
||||||
|
type: 'term',
|
||||||
|
field,
|
||||||
|
value: query.slice(i + 1, end),
|
||||||
|
range: true,
|
||||||
|
})
|
||||||
|
i = end + 1
|
||||||
|
} else {
|
||||||
|
const bare = /^[^\s()]+/.exec(value)
|
||||||
|
if (!bare) throw new UnreadableQuery()
|
||||||
|
tokens.push({ type: 'term', field, value: bare[0] })
|
||||||
|
i += bare[0].length
|
||||||
|
}
|
||||||
|
// Nothing may run on directly after a value, e.g. title:"a"b
|
||||||
|
if (i < query.length && !/[\s)]/.test(query[i])) throw new UnreadableQuery()
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveField(name: string): AdvancedSearchField {
|
||||||
|
const fields = Object.values(AdvancedSearchField) as string[]
|
||||||
|
// Aliases (type:, path:, notes:) are left to the text box on purpose:
|
||||||
|
// reading one would mean rewriting the user's query as it was read
|
||||||
|
if (!fields.includes(name)) throw new UnreadableQuery()
|
||||||
|
return name as AdvancedSearchField
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomFrom(
|
||||||
|
field: AdvancedSearchField,
|
||||||
|
operator: AdvancedSearchOperator,
|
||||||
|
value: string,
|
||||||
|
extra: Partial<AdvancedSearchQueryAtom> = {}
|
||||||
|
): AdvancedSearchQueryAtom {
|
||||||
|
return {
|
||||||
|
type: AdvancedSearchQueryElementType.Atom,
|
||||||
|
field,
|
||||||
|
operator,
|
||||||
|
value,
|
||||||
|
...extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRange(
|
||||||
|
field: AdvancedSearchField,
|
||||||
|
kind: AdvancedSearchFieldKind,
|
||||||
|
body: string
|
||||||
|
): AdvancedSearchQueryAtom {
|
||||||
|
const bounds = RANGE_BOUNDS.exec(body)
|
||||||
|
if (!bounds) throw new UnreadableQuery()
|
||||||
|
const lo = bounds[1] ?? ''
|
||||||
|
const hi = bounds[2] ?? ''
|
||||||
|
|
||||||
|
if (kind === AdvancedSearchFieldKind.Date) {
|
||||||
|
const relative = RELATIVE_BOUND.exec(lo)
|
||||||
|
if (relative && hi === 'now') {
|
||||||
|
return atomFrom(field, AdvancedSearchOperator.WithinLast, relative[1], {
|
||||||
|
unit: relative[2] as AdvancedSearchDateUnit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (kind !== AdvancedSearchFieldKind.Number) {
|
||||||
|
throw new UnreadableQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid =
|
||||||
|
kind === AdvancedSearchFieldKind.Date
|
||||||
|
? (v: string) => ISO_DATE.test(v)
|
||||||
|
: (v: string) => WHOLE_NUMBER.test(v)
|
||||||
|
|
||||||
|
if (lo && hi) {
|
||||||
|
if (!isValid(lo) || !isValid(hi)) throw new UnreadableQuery()
|
||||||
|
return atomFrom(field, AdvancedSearchOperator.Between, lo, { valueTo: hi })
|
||||||
|
}
|
||||||
|
if (lo && isValid(lo))
|
||||||
|
return atomFrom(field, AdvancedSearchOperator.AtLeast, lo)
|
||||||
|
if (hi && isValid(hi))
|
||||||
|
return atomFrom(field, AdvancedSearchOperator.AtMost, hi)
|
||||||
|
throw new UnreadableQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTerm(token: Token): Parsed {
|
||||||
|
const field = resolveField(token.field)
|
||||||
|
const kind = ADVANCED_SEARCH_FIELD_KINDS[field]
|
||||||
|
const value = token.value
|
||||||
|
const isKeyword = (
|
||||||
|
ADVANCED_SEARCH_DATE_KEYWORDS as readonly string[]
|
||||||
|
).includes(value)
|
||||||
|
|
||||||
|
if (token.range) {
|
||||||
|
return { element: parseRange(field, kind, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === AdvancedSearchFieldKind.Date) {
|
||||||
|
if (!isKeyword) throw new UnreadableQuery()
|
||||||
|
return {
|
||||||
|
element: atomFrom(field, AdvancedSearchOperator.DateKeyword, value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.quoted) {
|
||||||
|
if (kind !== AdvancedSearchFieldKind.Text) throw new UnreadableQuery()
|
||||||
|
return { element: atomFrom(field, AdvancedSearchOperator.Phrase, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const wildcard = TRAILING_WILDCARD.exec(value)
|
||||||
|
if (wildcard) {
|
||||||
|
if (kind === AdvancedSearchFieldKind.Number) throw new UnreadableQuery()
|
||||||
|
return {
|
||||||
|
element: atomFrom(field, AdvancedSearchOperator.StartsWith, wildcard[1]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === AdvancedSearchFieldKind.Number) {
|
||||||
|
if (!WHOLE_NUMBER.test(value)) throw new UnreadableQuery()
|
||||||
|
return { element: atomFrom(field, AdvancedSearchOperator.Equals, value) }
|
||||||
|
}
|
||||||
|
// A checksum is only ever searched by its first characters
|
||||||
|
if (kind === AdvancedSearchFieldKind.Checksum) throw new UnreadableQuery()
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: atomFrom(field, AdvancedSearchOperator.AllWords, value),
|
||||||
|
word: { field, text: value },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The serializer repeats the field for every word, because a field applies
|
||||||
|
// only to the word after it. Put those back together into one condition.
|
||||||
|
function mergeWords(
|
||||||
|
parts: Parsed[],
|
||||||
|
operator: AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
|
||||||
|
): AdvancedSearchQueryElement[] {
|
||||||
|
const merged: AdvancedSearchQueryElement[] = []
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const run = [parts[i]]
|
||||||
|
while (
|
||||||
|
parts[i].word &&
|
||||||
|
parts[i + 1]?.word &&
|
||||||
|
parts[i + 1].word.field === parts[i].word.field
|
||||||
|
) {
|
||||||
|
run.push(parts[++i])
|
||||||
|
}
|
||||||
|
if (run.length === 1) {
|
||||||
|
merged.push(run[0].element)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged.push(
|
||||||
|
atomFrom(
|
||||||
|
run[0].word.field,
|
||||||
|
operator === AdvancedSearchLogicalOperator.And
|
||||||
|
? AdvancedSearchOperator.AllWords
|
||||||
|
: AdvancedSearchOperator.AnyWord,
|
||||||
|
run.map((part) => part.word.text).join(' ')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Cursor {
|
||||||
|
tokens: Token[]
|
||||||
|
at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExpression(cursor: Cursor): Parsed {
|
||||||
|
const parts: Parsed[] = [parseOperand(cursor)]
|
||||||
|
let operator:
|
||||||
|
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
|
||||||
|
while (
|
||||||
|
cursor.tokens[cursor.at]?.type === 'AND' ||
|
||||||
|
cursor.tokens[cursor.at]?.type === 'OR'
|
||||||
|
) {
|
||||||
|
const next = cursor.tokens[cursor.at++].type as
|
||||||
|
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
|
||||||
|
// One level mixing AND and OR is never something the editor wrote
|
||||||
|
if (operator && next !== operator) throw new UnreadableQuery()
|
||||||
|
operator = next
|
||||||
|
parts.push(parseOperand(cursor))
|
||||||
|
}
|
||||||
|
if (parts.length === 1) return parts[0]
|
||||||
|
|
||||||
|
const children = mergeWords(parts, operator)
|
||||||
|
if (children.length === 1) return { element: children[0] }
|
||||||
|
return {
|
||||||
|
element: {
|
||||||
|
type: AdvancedSearchQueryElementType.Group,
|
||||||
|
operator,
|
||||||
|
children,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOperand(cursor: Cursor): Parsed {
|
||||||
|
const token = cursor.tokens[cursor.at++]
|
||||||
|
if (!token) throw new UnreadableQuery()
|
||||||
|
|
||||||
|
if (token.type === 'NOT') {
|
||||||
|
const child = parseOperand(cursor)
|
||||||
|
return {
|
||||||
|
element: {
|
||||||
|
type: AdvancedSearchQueryElementType.Group,
|
||||||
|
operator: AdvancedSearchLogicalOperator.Not,
|
||||||
|
children: [child.element],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (token.type === '(') {
|
||||||
|
const inner = parseExpression(cursor)
|
||||||
|
if (cursor.tokens[cursor.at++]?.type !== ')') throw new UnreadableQuery()
|
||||||
|
return { element: inner.element }
|
||||||
|
}
|
||||||
|
if (token.type !== 'term') throw new UnreadableQuery()
|
||||||
|
return parseTerm(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a query the editor could have written back into an editor tree, or
|
||||||
|
* returns null when the editor cannot show it, in which case the query stays
|
||||||
|
* text. Never returns a tree that would be written back differently.
|
||||||
|
*/
|
||||||
|
export function parseAdvancedSearchQuery(
|
||||||
|
query: string
|
||||||
|
): AdvancedSearchQueryGroup | null {
|
||||||
|
const trimmed = query?.trim() ?? ''
|
||||||
|
if (!trimmed) return null
|
||||||
|
|
||||||
|
let parsed: Parsed
|
||||||
|
try {
|
||||||
|
const cursor: Cursor = { tokens: tokenize(trimmed), at: 0 }
|
||||||
|
parsed = parseExpression(cursor)
|
||||||
|
if (cursor.at !== cursor.tokens.length) throw new UnreadableQuery()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const root =
|
||||||
|
parsed.element.type === AdvancedSearchQueryElementType.Group
|
||||||
|
? parsed.element
|
||||||
|
: {
|
||||||
|
type: AdvancedSearchQueryElementType.Group as const,
|
||||||
|
operator: AdvancedSearchLogicalOperator.And,
|
||||||
|
children: [parsed.element],
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializeAdvancedSearchQuery(root) === trimmed ? root : null
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export const environment = {
|
|||||||
apiVersion: '10', // match src/paperless/settings.py
|
apiVersion: '10', // match src/paperless/settings.py
|
||||||
appTitle: DEFAULT_APP_TITLE,
|
appTitle: DEFAULT_APP_TITLE,
|
||||||
tag: 'prod',
|
tag: 'prod',
|
||||||
version: '3.2.1',
|
version: '3.2.0',
|
||||||
webSocketHost: window.location.host,
|
webSocketHost: window.location.host,
|
||||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||||
|
|||||||
@@ -2855,7 +2855,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">104</context>
|
<context context-type="linenumber">104</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">KI-Vorschläge anwenden</target>
|
<target state="needs-translation">Apply AI Suggestions</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9172233176401579786" datatype="html" approved="yes">
|
<trans-unit id="9172233176401579786" datatype="html" approved="yes">
|
||||||
<source>Scheduled</source>
|
<source>Scheduled</source>
|
||||||
@@ -3907,7 +3907,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">2,3</context>
|
<context context-type="linenumber">2,3</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Share Links</target>
|
<target state="needs-translation">Share links</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7844706011418789951" datatype="html" approved="yes">
|
<trans-unit id="7844706011418789951" datatype="html" approved="yes">
|
||||||
<source>Administration</source>
|
<source>Administration</source>
|
||||||
@@ -5835,7 +5835,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">182</context>
|
<context context-type="linenumber">182</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Fehler beim Abrufen der Korrespondenten</target>
|
<target state="needs-translation">Error retrieving correspondents</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3407606224361082860" datatype="html">
|
<trans-unit id="3407606224361082860" datatype="html">
|
||||||
<source>Error retrieving document types</source>
|
<source>Error retrieving document types</source>
|
||||||
@@ -9623,7 +9623,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1051</context>
|
<context context-type="linenumber">1051</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Sie haben ungespeicherte Änderungen am Inhalt dieser Version.</target>
|
<target state="needs-translation">You have unsaved changes to the content of this version.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="85184271222513014" datatype="html">
|
<trans-unit id="85184271222513014" datatype="html">
|
||||||
<source>Switching versions will discard them.</source>
|
<source>Switching versions will discard them.</source>
|
||||||
@@ -12574,7 +12574,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">4,5</context>
|
<context context-type="linenumber">4,5</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Verwalten Sie öffentliche Links zu einzelnen Dokumenten und Dokumentenbündeln.</target>
|
<target state="needs-translation">Manage public links to individual documents and document bundles.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3740311348735113099" datatype="html">
|
<trans-unit id="3740311348735113099" datatype="html">
|
||||||
<source>Document links</source>
|
<source>Document links</source>
|
||||||
|
|||||||
@@ -3907,7 +3907,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">2,3</context>
|
<context context-type="linenumber">2,3</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Condividi link</target>
|
<target state="needs-translation">Share links</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7844706011418789951" datatype="html">
|
<trans-unit id="7844706011418789951" datatype="html">
|
||||||
<source>Administration</source>
|
<source>Administration</source>
|
||||||
@@ -12411,7 +12411,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">40,41</context>
|
<context context-type="linenumber">40,41</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Scaduto</target>
|
<target state="needs-translation">Expired</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2057574872309338088" datatype="html">
|
<trans-unit id="2057574872309338088" datatype="html">
|
||||||
<source>Copy share link</source>
|
<source>Copy share link</source>
|
||||||
@@ -12455,7 +12455,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context>
|
||||||
<context context-type="linenumber">164,165</context>
|
<context context-type="linenumber">164,165</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Paginazione dei pacchetti di link di condivisione</target>
|
<target state="needs-translation">Share link bundles pagination</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1541995860059283227" datatype="html">
|
<trans-unit id="1541995860059283227" datatype="html">
|
||||||
<source>Failed to load share link bundles.</source>
|
<source>Failed to load share link bundles.</source>
|
||||||
@@ -12511,7 +12511,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">7,8</context>
|
<context context-type="linenumber">7,8</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated"> Attualmente non sono presenti link per la condivisione dei documenti. </target>
|
<target state="needs-translation"> No document share links currently exist. </target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="894965666917873756" datatype="html">
|
<trans-unit id="894965666917873756" datatype="html">
|
||||||
<source>Document</source>
|
<source>Document</source>
|
||||||
@@ -12519,7 +12519,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">15,16</context>
|
<context context-type="linenumber">15,16</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Documento</target>
|
<target state="needs-translation">Document</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7420713064779772535" datatype="html">
|
<trans-unit id="7420713064779772535" datatype="html">
|
||||||
<source>Delete share link</source>
|
<source>Delete share link</source>
|
||||||
@@ -12527,7 +12527,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">75,76</context>
|
<context context-type="linenumber">75,76</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Elimina link di condivisione</target>
|
<target state="needs-translation">Delete share link</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5198271726831144068" datatype="html">
|
<trans-unit id="5198271726831144068" datatype="html">
|
||||||
<source>Share links pagination</source>
|
<source>Share links pagination</source>
|
||||||
@@ -12535,7 +12535,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">104,105</context>
|
<context context-type="linenumber">104,105</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Paginazione link condivisi</target>
|
<target state="needs-translation">Share links pagination</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2976350639944567244" datatype="html">
|
<trans-unit id="2976350639944567244" datatype="html">
|
||||||
<source>Failed to load share links.</source>
|
<source>Failed to load share links.</source>
|
||||||
@@ -12543,7 +12543,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
||||||
<context context-type="linenumber">105</context>
|
<context context-type="linenumber">105</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Impossibile caricare i link di condivisione.</target>
|
<target state="needs-translation">Failed to load share links.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4100863040928608880" datatype="html">
|
<trans-unit id="4100863040928608880" datatype="html">
|
||||||
<source>Error retrieving share links.</source>
|
<source>Error retrieving share links.</source>
|
||||||
@@ -12551,7 +12551,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
||||||
<context context-type="linenumber">107</context>
|
<context context-type="linenumber">107</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Errore nel recuperare i link di condivisione.</target>
|
<target state="needs-translation">Error retrieving share links.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="326120283142363299" datatype="html">
|
<trans-unit id="326120283142363299" datatype="html">
|
||||||
<source>Share link deleted.</source>
|
<source>Share link deleted.</source>
|
||||||
@@ -12559,7 +12559,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
||||||
<context context-type="linenumber">154</context>
|
<context context-type="linenumber">154</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Link di condivisione eliminato.</target>
|
<target state="needs-translation">Share link deleted.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4964931417181298145" datatype="html">
|
<trans-unit id="4964931417181298145" datatype="html">
|
||||||
<source>Error deleting share link.</source>
|
<source>Error deleting share link.</source>
|
||||||
@@ -12567,7 +12567,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
||||||
<context context-type="linenumber">159</context>
|
<context context-type="linenumber">159</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Errore nell'eliminare il link condivisione.</target>
|
<target state="needs-translation">Error deleting share link.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3439104787814858626" datatype="html">
|
<trans-unit id="3439104787814858626" datatype="html">
|
||||||
<source>Manage public links to individual documents and document bundles.</source>
|
<source>Manage public links to individual documents and document bundles.</source>
|
||||||
@@ -12575,7 +12575,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">4,5</context>
|
<context context-type="linenumber">4,5</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Gestisci i link pubblici ai singoli documenti e pacchetti di documenti.</target>
|
<target state="needs-translation">Manage public links to individual documents and document bundles.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3740311348735113099" datatype="html">
|
<trans-unit id="3740311348735113099" datatype="html">
|
||||||
<source>Document links</source>
|
<source>Document links</source>
|
||||||
@@ -12583,7 +12583,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">18,19</context>
|
<context context-type="linenumber">18,19</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Link al documento</target>
|
<target state="needs-translation">Document links</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8216780576869093031" datatype="html">
|
<trans-unit id="8216780576869093031" datatype="html">
|
||||||
<source>Bundles</source>
|
<source>Bundles</source>
|
||||||
@@ -12591,7 +12591,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">26,27</context>
|
<context context-type="linenumber">26,27</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Pacchetti</target>
|
<target state="needs-translation">Bundles</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1229748338333965418" datatype="html">
|
<trans-unit id="1229748338333965418" datatype="html">
|
||||||
<source>Use workflows to customize the behavior of Paperless-ngx when events 'trigger' a workflow.</source>
|
<source>Use workflows to customize the behavior of Paperless-ngx when events 'trigger' a workflow.</source>
|
||||||
|
|||||||
@@ -3907,7 +3907,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">2,3</context>
|
<context context-type="linenumber">2,3</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Ссылки для обмена</target>
|
<target state="needs-translation">Share links</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7844706011418789951" datatype="html">
|
<trans-unit id="7844706011418789951" datatype="html">
|
||||||
<source>Administration</source>
|
<source>Administration</source>
|
||||||
@@ -12410,7 +12410,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">40,41</context>
|
<context context-type="linenumber">40,41</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Истёкшие</target>
|
<target state="needs-translation">Expired</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2057574872309338088" datatype="html">
|
<trans-unit id="2057574872309338088" datatype="html">
|
||||||
<source>Copy share link</source>
|
<source>Copy share link</source>
|
||||||
@@ -12454,7 +12454,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context>
|
||||||
<context context-type="linenumber">164,165</context>
|
<context context-type="linenumber">164,165</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Пагинация ссылок</target>
|
<target state="needs-translation">Share link bundles pagination</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1541995860059283227" datatype="html">
|
<trans-unit id="1541995860059283227" datatype="html">
|
||||||
<source>Failed to load share link bundles.</source>
|
<source>Failed to load share link bundles.</source>
|
||||||
@@ -12510,7 +12510,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">7,8</context>
|
<context context-type="linenumber">7,8</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated"> В настоящее время нет общих ссылок. </target>
|
<target state="needs-translation"> No document share links currently exist. </target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="894965666917873756" datatype="html">
|
<trans-unit id="894965666917873756" datatype="html">
|
||||||
<source>Document</source>
|
<source>Document</source>
|
||||||
@@ -12518,7 +12518,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">15,16</context>
|
<context context-type="linenumber">15,16</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Документ</target>
|
<target state="needs-translation">Document</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7420713064779772535" datatype="html">
|
<trans-unit id="7420713064779772535" datatype="html">
|
||||||
<source>Delete share link</source>
|
<source>Delete share link</source>
|
||||||
@@ -12526,7 +12526,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">75,76</context>
|
<context context-type="linenumber">75,76</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Удалить ссылку для обмена</target>
|
<target state="needs-translation">Delete share link</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5198271726831144068" datatype="html">
|
<trans-unit id="5198271726831144068" datatype="html">
|
||||||
<source>Share links pagination</source>
|
<source>Share links pagination</source>
|
||||||
@@ -12534,7 +12534,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">104,105</context>
|
<context context-type="linenumber">104,105</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Пагинация ссылок</target>
|
<target state="needs-translation">Share links pagination</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2976350639944567244" datatype="html">
|
<trans-unit id="2976350639944567244" datatype="html">
|
||||||
<source>Failed to load share links.</source>
|
<source>Failed to load share links.</source>
|
||||||
@@ -12542,7 +12542,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
||||||
<context context-type="linenumber">105</context>
|
<context context-type="linenumber">105</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Не удалось загрузить ссылки.</target>
|
<target state="needs-translation">Failed to load share links.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4100863040928608880" datatype="html">
|
<trans-unit id="4100863040928608880" datatype="html">
|
||||||
<source>Error retrieving share links.</source>
|
<source>Error retrieving share links.</source>
|
||||||
@@ -12550,7 +12550,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
||||||
<context context-type="linenumber">107</context>
|
<context context-type="linenumber">107</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Ошибка при получении ссылок.</target>
|
<target state="needs-translation">Error retrieving share links.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="326120283142363299" datatype="html">
|
<trans-unit id="326120283142363299" datatype="html">
|
||||||
<source>Share link deleted.</source>
|
<source>Share link deleted.</source>
|
||||||
@@ -12558,7 +12558,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
||||||
<context context-type="linenumber">154</context>
|
<context context-type="linenumber">154</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Ссылка для обмена удалена.</target>
|
<target state="needs-translation">Share link deleted.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4964931417181298145" datatype="html">
|
<trans-unit id="4964931417181298145" datatype="html">
|
||||||
<source>Error deleting share link.</source>
|
<source>Error deleting share link.</source>
|
||||||
@@ -12566,7 +12566,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
||||||
<context context-type="linenumber">159</context>
|
<context context-type="linenumber">159</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Ошибка при удалении ссылки.</target>
|
<target state="needs-translation">Error deleting share link.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3439104787814858626" datatype="html">
|
<trans-unit id="3439104787814858626" datatype="html">
|
||||||
<source>Manage public links to individual documents and document bundles.</source>
|
<source>Manage public links to individual documents and document bundles.</source>
|
||||||
@@ -12574,7 +12574,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">4,5</context>
|
<context context-type="linenumber">4,5</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Управление публичными ссылками на отдельные документы и комплекты документов.</target>
|
<target state="needs-translation">Manage public links to individual documents and document bundles.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3740311348735113099" datatype="html">
|
<trans-unit id="3740311348735113099" datatype="html">
|
||||||
<source>Document links</source>
|
<source>Document links</source>
|
||||||
@@ -12582,7 +12582,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">18,19</context>
|
<context context-type="linenumber">18,19</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Ссылки на документы</target>
|
<target state="needs-translation">Document links</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8216780576869093031" datatype="html">
|
<trans-unit id="8216780576869093031" datatype="html">
|
||||||
<source>Bundles</source>
|
<source>Bundles</source>
|
||||||
@@ -12590,7 +12590,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">26,27</context>
|
<context context-type="linenumber">26,27</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Комплекты</target>
|
<target state="needs-translation">Bundles</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1229748338333965418" datatype="html">
|
<trans-unit id="1229748338333965418" datatype="html">
|
||||||
<source>Use workflows to customize the behavior of Paperless-ngx when events 'trigger' a workflow.</source>
|
<source>Use workflows to customize the behavior of Paperless-ngx when events 'trigger' a workflow.</source>
|
||||||
|
|||||||
@@ -2855,7 +2855,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">104</context>
|
<context context-type="linenumber">104</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Uporabi predloge umetne inteligence</target>
|
<target state="needs-translation">Apply AI Suggestions</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9172233176401579786" datatype="html">
|
<trans-unit id="9172233176401579786" datatype="html">
|
||||||
<source>Scheduled</source>
|
<source>Scheduled</source>
|
||||||
@@ -3907,7 +3907,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">2,3</context>
|
<context context-type="linenumber">2,3</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Delite povezave</target>
|
<target state="needs-translation">Share links</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7844706011418789951" datatype="html">
|
<trans-unit id="7844706011418789951" datatype="html">
|
||||||
<source>Administration</source>
|
<source>Administration</source>
|
||||||
@@ -12411,7 +12411,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">40,41</context>
|
<context context-type="linenumber">40,41</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Poteklo</target>
|
<target state="needs-translation">Expired</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2057574872309338088" datatype="html">
|
<trans-unit id="2057574872309338088" datatype="html">
|
||||||
<source>Copy share link</source>
|
<source>Copy share link</source>
|
||||||
@@ -12455,7 +12455,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context>
|
||||||
<context context-type="linenumber">164,165</context>
|
<context context-type="linenumber">164,165</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Deljenje povezav – strani v sklopih</target>
|
<target state="needs-translation">Share link bundles pagination</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1541995860059283227" datatype="html">
|
<trans-unit id="1541995860059283227" datatype="html">
|
||||||
<source>Failed to load share link bundles.</source>
|
<source>Failed to load share link bundles.</source>
|
||||||
@@ -12511,7 +12511,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">7,8</context>
|
<context context-type="linenumber">7,8</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated"> Trenutno ni povezav za skupno rabo dokumentov. </target>
|
<target state="needs-translation"> No document share links currently exist. </target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="894965666917873756" datatype="html">
|
<trans-unit id="894965666917873756" datatype="html">
|
||||||
<source>Document</source>
|
<source>Document</source>
|
||||||
@@ -12519,7 +12519,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">15,16</context>
|
<context context-type="linenumber">15,16</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Dokument</target>
|
<target state="needs-translation">Document</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7420713064779772535" datatype="html">
|
<trans-unit id="7420713064779772535" datatype="html">
|
||||||
<source>Delete share link</source>
|
<source>Delete share link</source>
|
||||||
@@ -12527,7 +12527,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">75,76</context>
|
<context context-type="linenumber">75,76</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Izbriši povezavo za deljenje</target>
|
<target state="needs-translation">Delete share link</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5198271726831144068" datatype="html">
|
<trans-unit id="5198271726831144068" datatype="html">
|
||||||
<source>Share links pagination</source>
|
<source>Share links pagination</source>
|
||||||
@@ -12535,7 +12535,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
|
||||||
<context context-type="linenumber">104,105</context>
|
<context context-type="linenumber">104,105</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Deljenje povezav – strani v sklopih</target>
|
<target state="needs-translation">Share links pagination</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2976350639944567244" datatype="html">
|
<trans-unit id="2976350639944567244" datatype="html">
|
||||||
<source>Failed to load share links.</source>
|
<source>Failed to load share links.</source>
|
||||||
@@ -12543,7 +12543,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
||||||
<context context-type="linenumber">105</context>
|
<context context-type="linenumber">105</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Povezav za deljenje ni bilo mogoče naložiti.</target>
|
<target state="needs-translation">Failed to load share links.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4100863040928608880" datatype="html">
|
<trans-unit id="4100863040928608880" datatype="html">
|
||||||
<source>Error retrieving share links.</source>
|
<source>Error retrieving share links.</source>
|
||||||
@@ -12551,7 +12551,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
||||||
<context context-type="linenumber">107</context>
|
<context context-type="linenumber">107</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Napaka pri pridobivanju povezav za deljenje.</target>
|
<target state="needs-translation">Error retrieving share links.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="326120283142363299" datatype="html">
|
<trans-unit id="326120283142363299" datatype="html">
|
||||||
<source>Share link deleted.</source>
|
<source>Share link deleted.</source>
|
||||||
@@ -12559,7 +12559,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
||||||
<context context-type="linenumber">154</context>
|
<context context-type="linenumber">154</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Povezava za deljenje je bila izbrisana.</target>
|
<target state="needs-translation">Share link deleted.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4964931417181298145" datatype="html">
|
<trans-unit id="4964931417181298145" datatype="html">
|
||||||
<source>Error deleting share link.</source>
|
<source>Error deleting share link.</source>
|
||||||
@@ -12567,7 +12567,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
|
||||||
<context context-type="linenumber">159</context>
|
<context context-type="linenumber">159</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Napaka pri brisanju povezave za deljenje.</target>
|
<target state="needs-translation">Error deleting share link.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3439104787814858626" datatype="html">
|
<trans-unit id="3439104787814858626" datatype="html">
|
||||||
<source>Manage public links to individual documents and document bundles.</source>
|
<source>Manage public links to individual documents and document bundles.</source>
|
||||||
@@ -12575,7 +12575,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">4,5</context>
|
<context context-type="linenumber">4,5</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Upravljajte javne povezave do posameznih dokumentov in paketov dokumentov.</target>
|
<target state="needs-translation">Manage public links to individual documents and document bundles.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3740311348735113099" datatype="html">
|
<trans-unit id="3740311348735113099" datatype="html">
|
||||||
<source>Document links</source>
|
<source>Document links</source>
|
||||||
@@ -12583,7 +12583,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">18,19</context>
|
<context context-type="linenumber">18,19</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Povezave do dokumentov</target>
|
<target state="needs-translation">Document links</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8216780576869093031" datatype="html">
|
<trans-unit id="8216780576869093031" datatype="html">
|
||||||
<source>Bundles</source>
|
<source>Bundles</source>
|
||||||
@@ -12591,7 +12591,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">26,27</context>
|
<context context-type="linenumber">26,27</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Paketi</target>
|
<target state="needs-translation">Bundles</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1229748338333965418" datatype="html">
|
<trans-unit id="1229748338333965418" datatype="html">
|
||||||
<source>Use workflows to customize the behavior of Paperless-ngx when events 'trigger' a workflow.</source>
|
<source>Use workflows to customize the behavior of Paperless-ngx when events 'trigger' a workflow.</source>
|
||||||
|
|||||||
@@ -11518,7 +11518,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
||||||
<context context-type="linenumber">70,72</context>
|
<context context-type="linenumber">70,72</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Inga fält har definierats.</target>
|
<target state="translated"/>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3032792139967609806" datatype="html">
|
<trans-unit id="3032792139967609806" datatype="html">
|
||||||
<source>Confirm delete field</source>
|
<source>Confirm delete field</source>
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ import {
|
|||||||
send,
|
send,
|
||||||
shop,
|
shop,
|
||||||
slashCircle,
|
slashCircle,
|
||||||
|
sliders,
|
||||||
sliders2Vertical,
|
sliders2Vertical,
|
||||||
sortAlphaDown,
|
sortAlphaDown,
|
||||||
sortAlphaUpAlt,
|
sortAlphaUpAlt,
|
||||||
@@ -397,6 +398,7 @@ const icons = {
|
|||||||
send,
|
send,
|
||||||
slashCircle,
|
slashCircle,
|
||||||
shop,
|
shop,
|
||||||
|
sliders,
|
||||||
sliders2Vertical,
|
sliders2Vertical,
|
||||||
sortAlphaDown,
|
sortAlphaDown,
|
||||||
sortAlphaUpAlt,
|
sortAlphaUpAlt,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ msgstr ""
|
|||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-18 01:29+0000\n"
|
"POT-Creation-Date: 2026-09-18 01:29+0000\n"
|
||||||
"PO-Revision-Date: 2026-09-19 12:26\n"
|
"PO-Revision-Date: 2026-09-18 01:30\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: German, Switzerland\n"
|
"Language-Team: German, Switzerland\n"
|
||||||
"Language: de_CH\n"
|
"Language: de_CH\n"
|
||||||
@@ -350,7 +350,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: documents/models.py:570
|
#: documents/models.py:570
|
||||||
msgid "Download"
|
msgid "Download"
|
||||||
msgstr "Download"
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:571
|
#: documents/models.py:571
|
||||||
msgid "Envelope"
|
msgid "Envelope"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ msgstr ""
|
|||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-18 01:29+0000\n"
|
"POT-Creation-Date: 2026-09-18 01:29+0000\n"
|
||||||
"PO-Revision-Date: 2026-09-20 00:58\n"
|
"PO-Revision-Date: 2026-09-18 01:30\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: German\n"
|
"Language-Team: German\n"
|
||||||
"Language: de_DE\n"
|
"Language: de_DE\n"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ msgstr ""
|
|||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-18 01:29+0000\n"
|
"POT-Creation-Date: 2026-09-18 01:29+0000\n"
|
||||||
"PO-Revision-Date: 2026-09-20 00:58\n"
|
"PO-Revision-Date: 2026-09-18 01:30\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Italian\n"
|
"Language-Team: Italian\n"
|
||||||
"Language: it_IT\n"
|
"Language: it_IT\n"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ msgstr ""
|
|||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-18 01:29+0000\n"
|
"POT-Creation-Date: 2026-09-18 01:29+0000\n"
|
||||||
"PO-Revision-Date: 2026-09-19 12:26\n"
|
"PO-Revision-Date: 2026-09-18 01:30\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Russian\n"
|
"Language-Team: Russian\n"
|
||||||
"Language: ru_RU\n"
|
"Language: ru_RU\n"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ msgstr ""
|
|||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-18 01:29+0000\n"
|
"POT-Creation-Date: 2026-09-18 01:29+0000\n"
|
||||||
"PO-Revision-Date: 2026-09-19 12:26\n"
|
"PO-Revision-Date: 2026-09-18 01:30\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Slovenian\n"
|
"Language-Team: Slovenian\n"
|
||||||
"Language: sl_SI\n"
|
"Language: sl_SI\n"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ msgstr ""
|
|||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-18 01:29+0000\n"
|
"POT-Creation-Date: 2026-09-18 01:29+0000\n"
|
||||||
"PO-Revision-Date: 2026-09-20 12:26\n"
|
"PO-Revision-Date: 2026-09-18 18:55\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Swedish\n"
|
"Language-Team: Swedish\n"
|
||||||
"Language: sv_SE\n"
|
"Language: sv_SE\n"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
__version__: Final[tuple[int, int, int]] = (3, 2, 1)
|
__version__: Final[tuple[int, int, int]] = (3, 2, 0)
|
||||||
# Version string like X.Y.Z
|
# Version string like X.Y.Z
|
||||||
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
||||||
# Version string like X.Y
|
# Version string like X.Y
|
||||||
|
|||||||
@@ -180,15 +180,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.14.2"
|
version = "4.12.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2871,7 +2871,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.2.1"
|
version = "3.2.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "azure-ai-documentintelligence" },
|
{ name = "azure-ai-documentintelligence" },
|
||||||
|
|||||||
Reference in New Issue
Block a user