Compare commits

..
Author SHA1 Message Date
shamoon f5a4026d4f Update advanced-search-query.ts 2026-09-19 21:04:52 -07:00
shamoon 339f9a4d30 Hook up to the filter bar 2026-09-19 21:04:52 -07:00
shamoon 856140c068 Advanced search dialog 2026-09-19 21:04:52 -07:00
shamoonandClaude Opus 5 62cf7c4429 feat(search): read a query back into the advanced search editor
The inverse of the serializer, deliberately narrow: it reads the forms
the serializer writes and nothing else, so a query it cannot show stays
text instead of being converted into something subtly different.

The guarantee is enforced rather than argued: a tree is only returned
when writing it out again reproduces the query character for character.
That turns every near miss into a plain "can't show this one" - a
boost, an implicit AND, a field alias, or AND and OR mixed at one level.
Aliases are refused for the same reason, since reading type: would mean
rewriting the query as it was read.

Per-word field prefixes are put back together into the single condition
they came from, but only when adjacent and on the same field, so the
order of what the user typed is never rearranged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:04:51 -07:00
shamoon 9a2c2b9331 Basic advanced search query foundations 2026-09-19 21:04:51 -07:00
31 changed files with 2166 additions and 112 deletions
@@ -2,7 +2,6 @@
# shellcheck shell=bash
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..."
@@ -10,20 +9,12 @@ if [[ -n "${PAPERLESS_ENABLE_FLOWER}" ]]; then
# Small delay to allow celery to be up first
echo "${log_prefix} Starting flower in 5s"
sleep 5
cd "${PAPERLESS_SRC_DIR}" || exit 1
# 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
cd ${PAPERLESS_SRC_DIR}
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
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
else
-25
View File
@@ -1,30 +1,5 @@
# 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
### Features / Enhancements
+1 -3
View File
@@ -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
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
list by **Duplicates** to find all of them (see
[Duplicate documents](usage.md#duplicate-documents)). If you prefer the old
**Duplicates** tab to review documents that share the same content. If you prefer the old
behavior of rejecting duplicates during consumption, set
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
to `true`.
+8 -7
View File
@@ -299,18 +299,19 @@ for details.
### Duplicate documents
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
still consumed and a warning is logged.
contents exactly match an existing document (same checksum), the new copy is still
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
the other documents you can view that share the same content (including any in the trash).
To find all documents with duplicates, choose **Duplicates** in the document list's text
filter dropdown, or use `has_duplicates=true` in the REST API.
To review duplicates, open a document and switch to the **Duplicates** tab on the
document detail page. It lists other documents that share the same content, including any
that are in the trash (shown with a badge), and links to each so you can decide which to
keep.
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
[`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
a "Document already exists" message linking to the existing document.
a "document already exists" message.
## Document Suggestions
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
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"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "3.2.1",
"version": "3.2.0",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
@@ -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>
@@ -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;
}
@@ -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()
})
})
@@ -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>
}
@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) {
<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>
@@ -12,6 +12,8 @@ import {
NgbDatepickerModule,
NgbDropdownItem,
NgbDropdownModule,
NgbModal,
NgbModalRef,
NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
@@ -2515,4 +2517,45 @@ describe('FilterEditorComponent', () => {
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 {
NgbDropdownModule,
NgbModal,
NgbTypeahead,
NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { Observable, Subject, from } from 'rxjs'
import { first, Observable, Subject, from } from 'rxjs'
import {
catchError,
debounceTime,
@@ -121,6 +122,7 @@ import {
PermissionsFilterDropdownComponent,
PermissionsSelectionModel,
} 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'
const TEXT_FILTER_TARGET_TITLE = 'title'
@@ -286,6 +288,7 @@ export class FilterEditorComponent
permissionsService = inject(PermissionsService)
private customFieldService = inject(CustomFieldsService)
private searchService = inject(SearchService)
private modalService = inject(NgbModal)
generateFilterName() {
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) {
if (event.key == 'Enter') {
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
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py
appTitle: DEFAULT_APP_TITLE,
tag: 'prod',
version: '3.2.1',
version: '3.2.0',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',
+5 -5
View File
@@ -2855,7 +2855,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">104</context>
</context-group>
<target state="translated">KI-Vorschläge anwenden</target>
<target state="needs-translation">Apply AI Suggestions</target>
</trans-unit>
<trans-unit id="9172233176401579786" datatype="html" approved="yes">
<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="linenumber">2,3</context>
</context-group>
<target state="translated">Share Links</target>
<target state="needs-translation">Share links</target>
</trans-unit>
<trans-unit id="7844706011418789951" datatype="html" approved="yes">
<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="linenumber">182</context>
</context-group>
<target state="translated">Fehler beim Abrufen der Korrespondenten</target>
<target state="needs-translation">Error retrieving correspondents</target>
</trans-unit>
<trans-unit id="3407606224361082860" datatype="html">
<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="linenumber">1051</context>
</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 id="85184271222513014" datatype="html">
<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="linenumber">4,5</context>
</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 id="3740311348735113099" datatype="html">
<source>Document links</source>
+14 -14
View File
@@ -3907,7 +3907,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">2,3</context>
</context-group>
<target state="translated">Condividi link</target>
<target state="needs-translation">Share links</target>
</trans-unit>
<trans-unit id="7844706011418789951" datatype="html">
<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="linenumber">40,41</context>
</context-group>
<target state="translated">Scaduto</target>
<target state="needs-translation">Expired</target>
</trans-unit>
<trans-unit id="2057574872309338088" datatype="html">
<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="linenumber">164,165</context>
</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 id="1541995860059283227" datatype="html">
<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="linenumber">7,8</context>
</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 id="894965666917873756" datatype="html">
<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="linenumber">15,16</context>
</context-group>
<target state="translated">Documento</target>
<target state="needs-translation">Document</target>
</trans-unit>
<trans-unit id="7420713064779772535" datatype="html">
<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="linenumber">75,76</context>
</context-group>
<target state="translated">Elimina link di condivisione</target>
<target state="needs-translation">Delete share link</target>
</trans-unit>
<trans-unit id="5198271726831144068" datatype="html">
<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="linenumber">104,105</context>
</context-group>
<target state="translated">Paginazione link condivisi</target>
<target state="needs-translation">Share links pagination</target>
</trans-unit>
<trans-unit id="2976350639944567244" datatype="html">
<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="linenumber">105</context>
</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 id="4100863040928608880" datatype="html">
<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="linenumber">107</context>
</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 id="326120283142363299" datatype="html">
<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="linenumber">154</context>
</context-group>
<target state="translated">Link di condivisione eliminato.</target>
<target state="needs-translation">Share link deleted.</target>
</trans-unit>
<trans-unit id="4964931417181298145" datatype="html">
<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="linenumber">159</context>
</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 id="3439104787814858626" datatype="html">
<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="linenumber">4,5</context>
</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 id="3740311348735113099" datatype="html">
<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="linenumber">18,19</context>
</context-group>
<target state="translated">Link al documento</target>
<target state="needs-translation">Document links</target>
</trans-unit>
<trans-unit id="8216780576869093031" datatype="html">
<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="linenumber">26,27</context>
</context-group>
<target state="translated">Pacchetti</target>
<target state="needs-translation">Bundles</target>
</trans-unit>
<trans-unit id="1229748338333965418" datatype="html">
<source>Use workflows to customize the behavior of Paperless-ngx when events &apos;trigger&apos; a workflow.</source>
+14 -14
View File
@@ -3907,7 +3907,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">2,3</context>
</context-group>
<target state="translated">Ссылки для обмена</target>
<target state="needs-translation">Share links</target>
</trans-unit>
<trans-unit id="7844706011418789951" datatype="html">
<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="linenumber">40,41</context>
</context-group>
<target state="translated">Истёкшие</target>
<target state="needs-translation">Expired</target>
</trans-unit>
<trans-unit id="2057574872309338088" datatype="html">
<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="linenumber">164,165</context>
</context-group>
<target state="translated">Пагинация ссылок</target>
<target state="needs-translation">Share link bundles pagination</target>
</trans-unit>
<trans-unit id="1541995860059283227" datatype="html">
<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="linenumber">7,8</context>
</context-group>
<target state="translated"> В настоящее время нет общих ссылок. </target>
<target state="needs-translation"> No document share links currently exist. </target>
</trans-unit>
<trans-unit id="894965666917873756" datatype="html">
<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="linenumber">15,16</context>
</context-group>
<target state="translated">Документ</target>
<target state="needs-translation">Document</target>
</trans-unit>
<trans-unit id="7420713064779772535" datatype="html">
<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="linenumber">75,76</context>
</context-group>
<target state="translated">Удалить ссылку для обмена</target>
<target state="needs-translation">Delete share link</target>
</trans-unit>
<trans-unit id="5198271726831144068" datatype="html">
<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="linenumber">104,105</context>
</context-group>
<target state="translated">Пагинация ссылок</target>
<target state="needs-translation">Share links pagination</target>
</trans-unit>
<trans-unit id="2976350639944567244" datatype="html">
<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="linenumber">105</context>
</context-group>
<target state="translated">Не удалось загрузить ссылки.</target>
<target state="needs-translation">Failed to load share links.</target>
</trans-unit>
<trans-unit id="4100863040928608880" datatype="html">
<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="linenumber">107</context>
</context-group>
<target state="translated">Ошибка при получении ссылок.</target>
<target state="needs-translation">Error retrieving share links.</target>
</trans-unit>
<trans-unit id="326120283142363299" datatype="html">
<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="linenumber">154</context>
</context-group>
<target state="translated">Ссылка для обмена удалена.</target>
<target state="needs-translation">Share link deleted.</target>
</trans-unit>
<trans-unit id="4964931417181298145" datatype="html">
<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="linenumber">159</context>
</context-group>
<target state="translated">Ошибка при удалении ссылки.</target>
<target state="needs-translation">Error deleting share link.</target>
</trans-unit>
<trans-unit id="3439104787814858626" datatype="html">
<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="linenumber">4,5</context>
</context-group>
<target state="translated">Управление публичными ссылками на отдельные документы и комплекты документов.</target>
<target state="needs-translation">Manage public links to individual documents and document bundles.</target>
</trans-unit>
<trans-unit id="3740311348735113099" datatype="html">
<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="linenumber">18,19</context>
</context-group>
<target state="translated">Ссылки на документы</target>
<target state="needs-translation">Document links</target>
</trans-unit>
<trans-unit id="8216780576869093031" datatype="html">
<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="linenumber">26,27</context>
</context-group>
<target state="translated">Комплекты</target>
<target state="needs-translation">Bundles</target>
</trans-unit>
<trans-unit id="1229748338333965418" datatype="html">
<source>Use workflows to customize the behavior of Paperless-ngx when events &apos;trigger&apos; a workflow.</source>
+15 -15
View File
@@ -2855,7 +2855,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">104</context>
</context-group>
<target state="translated">Uporabi predloge umetne inteligence</target>
<target state="needs-translation">Apply AI Suggestions</target>
</trans-unit>
<trans-unit id="9172233176401579786" datatype="html">
<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="linenumber">2,3</context>
</context-group>
<target state="translated">Delite povezave</target>
<target state="needs-translation">Share links</target>
</trans-unit>
<trans-unit id="7844706011418789951" datatype="html">
<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="linenumber">40,41</context>
</context-group>
<target state="translated">Poteklo</target>
<target state="needs-translation">Expired</target>
</trans-unit>
<trans-unit id="2057574872309338088" datatype="html">
<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="linenumber">164,165</context>
</context-group>
<target state="translated">Deljenje povezav – strani v sklopih</target>
<target state="needs-translation">Share link bundles pagination</target>
</trans-unit>
<trans-unit id="1541995860059283227" datatype="html">
<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="linenumber">7,8</context>
</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 id="894965666917873756" datatype="html">
<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="linenumber">15,16</context>
</context-group>
<target state="translated">Dokument</target>
<target state="needs-translation">Document</target>
</trans-unit>
<trans-unit id="7420713064779772535" datatype="html">
<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="linenumber">75,76</context>
</context-group>
<target state="translated">Izbriši povezavo za deljenje</target>
<target state="needs-translation">Delete share link</target>
</trans-unit>
<trans-unit id="5198271726831144068" datatype="html">
<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="linenumber">104,105</context>
</context-group>
<target state="translated">Deljenje povezav – strani v sklopih</target>
<target state="needs-translation">Share links pagination</target>
</trans-unit>
<trans-unit id="2976350639944567244" datatype="html">
<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="linenumber">105</context>
</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 id="4100863040928608880" datatype="html">
<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="linenumber">107</context>
</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 id="326120283142363299" datatype="html">
<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="linenumber">154</context>
</context-group>
<target state="translated">Povezava za deljenje je bila izbrisana.</target>
<target state="needs-translation">Share link deleted.</target>
</trans-unit>
<trans-unit id="4964931417181298145" datatype="html">
<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="linenumber">159</context>
</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 id="3439104787814858626" datatype="html">
<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="linenumber">4,5</context>
</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 id="3740311348735113099" datatype="html">
<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="linenumber">18,19</context>
</context-group>
<target state="translated">Povezave do dokumentov</target>
<target state="needs-translation">Document links</target>
</trans-unit>
<trans-unit id="8216780576869093031" datatype="html">
<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="linenumber">26,27</context>
</context-group>
<target state="translated">Paketi</target>
<target state="needs-translation">Bundles</target>
</trans-unit>
<trans-unit id="1229748338333965418" datatype="html">
<source>Use workflows to customize the behavior of Paperless-ngx when events &apos;trigger&apos; a workflow.</source>
+1 -1
View File
@@ -11518,7 +11518,7 @@
<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-group>
<target state="translated">Inga fält har definierats.</target>
<target state="translated"/>
</trans-unit>
<trans-unit id="3032792139967609806" datatype="html">
<source>Confirm delete field</source>
+2
View File
@@ -148,6 +148,7 @@ import {
send,
shop,
slashCircle,
sliders,
sliders2Vertical,
sortAlphaDown,
sortAlphaUpAlt,
@@ -397,6 +398,7 @@ const icons = {
send,
slashCircle,
shop,
sliders,
sliders2Vertical,
sortAlphaDown,
sortAlphaUpAlt,
+2 -2
View File
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \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"
"Language-Team: German, Switzerland\n"
"Language: de_CH\n"
@@ -350,7 +350,7 @@ msgstr ""
#: documents/models.py:570
msgid "Download"
msgstr "Download"
msgstr ""
#: documents/models.py:571
msgid "Envelope"
+1 -1
View File
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \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"
"Language-Team: German\n"
"Language: de_DE\n"
+1 -1
View File
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \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"
"Language-Team: Italian\n"
"Language: it_IT\n"
+1 -1
View File
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \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"
"Language-Team: Russian\n"
"Language: ru_RU\n"
+1 -1
View File
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \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"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
+1 -1
View File
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \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"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
+1 -1
View File
@@ -1,6 +1,6 @@
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
__full_version_str__: Final[str] = ".".join(map(str, __version__))
# Version string like X.Y
Generated
+4 -4
View File
@@ -180,15 +180,15 @@ wheels = [
[[package]]
name = "anyio"
version = "4.14.2"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ 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 = [
{ 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]]
@@ -2871,7 +2871,7 @@ wheels = [
[[package]]
name = "paperless-ngx"
version = "3.2.1"
version = "3.2.0"
source = { virtual = "." }
dependencies = [
{ name = "azure-ai-documentintelligence" },