Feature: custom fields queries (#7761)

This commit is contained in:
shamoon 2024-10-02 17:15:42 -07:00 committed by GitHub
parent 2e3637d712
commit f8d79b012f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 2130 additions and 599 deletions

View File

@ -278,39 +278,39 @@ attribute with various information about the search results:
### Filtering by custom fields ### Filtering by custom fields
You can filter documents by their custom field values by specifying the You can filter documents by their custom field values by specifying the
`custom_field_lookup` query parameter. Here are some recipes for common `custom_field_query` query parameter. Here are some recipes for common
use cases: use cases:
1. Documents with a custom field "due" (date) between Aug 1, 2024 and 1. Documents with a custom field "due" (date) between Aug 1, 2024 and
Sept 1, 2024 (inclusive): Sept 1, 2024 (inclusive):
`?custom_field_lookup=["due", "range", ["2024-08-01", "2024-09-01"]]` `?custom_field_query=["due", "range", ["2024-08-01", "2024-09-01"]]`
2. Documents with a custom field "customer" (text) that equals "bob" 2. Documents with a custom field "customer" (text) that equals "bob"
(case sensitive): (case sensitive):
`?custom_field_lookup=["customer", "exact", "bob"]` `?custom_field_query=["customer", "exact", "bob"]`
3. Documents with a custom field "answered" (boolean) set to `true`: 3. Documents with a custom field "answered" (boolean) set to `true`:
`?custom_field_lookup=["answered", "exact", true]` `?custom_field_query=["answered", "exact", true]`
4. Documents with a custom field "favorite animal" (select) set to either 4. Documents with a custom field "favorite animal" (select) set to either
"cat" or "dog": "cat" or "dog":
`?custom_field_lookup=["favorite animal", "in", ["cat", "dog"]]` `?custom_field_query=["favorite animal", "in", ["cat", "dog"]]`
5. Documents with a custom field "address" (text) that is empty: 5. Documents with a custom field "address" (text) that is empty:
`?custom_field_lookup=["OR", ["address", "isnull", true], ["address", "exact", ""]]` `?custom_field_query=["OR", ["address", "isnull", true], ["address", "exact", ""]]`
6. Documents that don't have a field called "foo": 6. Documents that don't have a field called "foo":
`?custom_field_lookup=["foo", "exists", false]` `?custom_field_query=["foo", "exists", false]`
7. Documents that have document links "references" to both document 3 and 7: 7. Documents that have document links "references" to both document 3 and 7:
`?custom_field_lookup=["references", "contains", [3, 7]]` `?custom_field_query=["references", "contains", [3, 7]]`
All field types support basic operations including `exact`, `in`, `isnull`, All field types support basic operations including `exact`, `in`, `isnull`,
and `exists`. String, URL, and monetary fields support case-insensitive and `exists`. String, URL, and monetary fields support case-insensitive
@ -320,22 +320,6 @@ including `gt` (>), `gte` (>=), `lt` (<), `lte` (<=), and `range`.
Lastly, document link fields support a `contains` operator that behaves Lastly, document link fields support a `contains` operator that behaves
like a "is superset of" check. like a "is superset of" check.
!!! warning
It is possible to do case-insensitive exact match (i.e., `iexact`) and
case-sensitive substring match (i.e., `contains`, `startswith`,
`endswith`) for string, URL, and monetary fields, but
[they may not work as expected on some database backends](https://docs.djangoproject.com/en/5.1/ref/databases/#substring-matching-and-case-sensitivity).
It is also possible to use regular expressions to match string, URL, and
monetary fields, but the syntax is database-dependent, and accepting
regular expressions from untrusted sources could make your instance
vulnerable to regular expression denial of service attacks.
For these reasons the above expressions are disabled by default.
If you understand the implications, you may enable them by uncommenting
`PAPERLESS_CUSTOM_FIELD_LOOKUP_OPT_IN` in your configuration file.
### `/api/search/autocomplete/` ### `/api/search/autocomplete/`
Get auto completions for a partial search term. Get auto completions for a partial search term.

View File

@ -81,7 +81,6 @@
#PAPERLESS_THUMBNAIL_FONT_NAME= #PAPERLESS_THUMBNAIL_FONT_NAME=
#PAPERLESS_IGNORE_DATES= #PAPERLESS_IGNORE_DATES=
#PAPERLESS_ENABLE_UPDATE_CHECK= #PAPERLESS_ENABLE_UPDATE_CHECK=
#PAPERLESS_ALLOW_CUSTOM_FIELD_LOOKUP=iexact,contains,startswith,endswith,regex,iregex
# Tika settings # Tika settings

View File

@ -698,7 +698,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context> <context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
<context context-type="linenumber">38</context> <context context-type="linenumber">51</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
@ -1031,7 +1031,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">143</context> <context context-type="linenumber">152</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8104421162933956065" datatype="html"> <trans-unit id="8104421162933956065" datatype="html">
@ -1088,7 +1088,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">110</context> <context context-type="linenumber">105</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
@ -3300,6 +3300,102 @@
<context context-type="linenumber">63</context> <context context-type="linenumber">63</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4465085913683915434" datatype="html">
<source>True</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">40</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">73</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">79</context>
</context-group>
</trans-unit>
<trans-unit id="3800326155195149498" datatype="html">
<source>False</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">41</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">74</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">80</context>
</context-group>
</trans-unit>
<trans-unit id="7551700625201096185" datatype="html">
<source>Search docs...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">96</context>
</context-group>
</trans-unit>
<trans-unit id="3184700926171002527" datatype="html">
<source>Any</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">126</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="1616102757855967475" datatype="html">
<source>All</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">128</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">15</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">14</context>
</context-group>
</trans-unit>
<trans-unit id="1496549861742963591" datatype="html">
<source>Not</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">131</context>
</context-group>
</trans-unit>
<trans-unit id="6548676277933116532" datatype="html">
<source>Add query</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">150</context>
</context-group>
</trans-unit>
<trans-unit id="5599577087865387184" datatype="html">
<source>Add expression</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">153</context>
</context-group>
</trans-unit>
<trans-unit id="6052766076365105714" datatype="html"> <trans-unit id="6052766076365105714" datatype="html">
<source>now</source> <source>now</source>
<context-group purpose="location"> <context-group purpose="location">
@ -4549,36 +4645,6 @@
<context context-type="linenumber">146</context> <context context-type="linenumber">146</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1616102757855967475" datatype="html">
<source>All</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">15</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">14</context>
</context-group>
</trans-unit>
<trans-unit id="3184700926171002527" datatype="html">
<source>Any</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="6381578200008167206" datatype="html"> <trans-unit id="6381578200008167206" datatype="html">
<source>Include</source> <source>Include</source>
<context-group purpose="location"> <context-group purpose="location">
@ -4668,7 +4734,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context> <context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
<context context-type="linenumber">9</context> <context context-type="linenumber">12</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/file/file.component.html</context> <context context-type="sourcefile">src/app/components/common/input/file/file.component.html</context>
@ -4740,14 +4806,14 @@
<source>Remove link</source> <source>Remove link</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context> <context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
<context context-type="linenumber">30</context> <context context-type="linenumber">43</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1388712764439031120" datatype="html"> <trans-unit id="1388712764439031120" datatype="html">
<source>Open link</source> <source>Open link</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context> <context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
<context context-type="linenumber">31</context> <context context-type="linenumber">44</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/url/url.component.html</context> <context context-type="sourcefile">src/app/components/common/input/url/url.component.html</context>
@ -4761,6 +4827,13 @@
<context context-type="linenumber">44</context> <context context-type="linenumber">44</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5676637575587497817" datatype="html">
<source>Search for documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
<context context-type="linenumber">53</context>
</context-group>
</trans-unit>
<trans-unit id="8627133593113147800" datatype="html"> <trans-unit id="8627133593113147800" datatype="html">
<source>Selected items</source> <source>Selected items</source>
<context-group purpose="location"> <context-group purpose="location">
@ -5834,7 +5907,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">131</context> <context context-type="linenumber">140</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context> <context context-type="sourcefile">src/app/data/document.ts</context>
@ -6416,7 +6489,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">139</context> <context context-type="linenumber">148</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6475890479659129881" datatype="html"> <trans-unit id="6475890479659129881" datatype="html">
@ -6425,10 +6498,6 @@
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">83</context> <context context-type="linenumber">83</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">90</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="3206542606001340679" datatype="html"> <trans-unit id="3206542606001340679" datatype="html">
<source>Merge</source> <source>Merge</source>
@ -6925,7 +6994,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">116</context> <context context-type="linenumber">111</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1559883523769732271" datatype="html"> <trans-unit id="1559883523769732271" datatype="html">
@ -6950,7 +7019,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">136</context> <context context-type="linenumber">145</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context> <context context-type="sourcefile">src/app/data/document.ts</context>
@ -7126,161 +7195,154 @@
<source>Dates</source> <source>Dates</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">100</context> <context context-type="linenumber">95</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3100631071441658964" datatype="html"> <trans-unit id="3100631071441658964" datatype="html">
<source>Title &amp; content</source> <source>Title &amp; content</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">134</context> <context context-type="linenumber">143</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2649431021108393503" datatype="html"> <trans-unit id="2649431021108393503" datatype="html">
<source>More like</source> <source>More like</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">149</context> <context context-type="linenumber">158</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3697582909018473071" datatype="html"> <trans-unit id="3697582909018473071" datatype="html">
<source>equals</source> <source>equals</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">155</context> <context context-type="linenumber">164</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5325481293405718739" datatype="html"> <trans-unit id="5325481293405718739" datatype="html">
<source>is empty</source> <source>is empty</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">159</context> <context context-type="linenumber">168</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6166785695326182482" datatype="html"> <trans-unit id="6166785695326182482" datatype="html">
<source>is not empty</source> <source>is not empty</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">163</context> <context context-type="linenumber">172</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4686622206659266699" datatype="html"> <trans-unit id="4686622206659266699" datatype="html">
<source>greater than</source> <source>greater than</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">167</context> <context context-type="linenumber">176</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8014012170270529279" datatype="html"> <trans-unit id="8014012170270529279" datatype="html">
<source>less than</source> <source>less than</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">171</context> <context context-type="linenumber">180</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5195932016807797291" datatype="html"> <trans-unit id="5195932016807797291" datatype="html">
<source>Correspondent: <x id="PH" equiv-text="this.correspondents.find((c) =&gt; c.id == +rule.value)?.name"/></source> <source>Correspondent: <x id="PH" equiv-text="this.correspondents.find((c) =&gt; c.id == +rule.value)?.name"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">191,193</context> <context context-type="linenumber">200,202</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8170755470576301659" datatype="html"> <trans-unit id="8170755470576301659" datatype="html">
<source>Without correspondent</source> <source>Without correspondent</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">195</context> <context context-type="linenumber">204</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="317796810569008208" datatype="html"> <trans-unit id="317796810569008208" datatype="html">
<source>Document type: <x id="PH" equiv-text="this.documentTypes.find((dt) =&gt; dt.id == +rule.value)?.name"/></source> <source>Document type: <x id="PH" equiv-text="this.documentTypes.find((dt) =&gt; dt.id == +rule.value)?.name"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">201,203</context> <context context-type="linenumber">210,212</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4362173610367509215" datatype="html"> <trans-unit id="4362173610367509215" datatype="html">
<source>Without document type</source> <source>Without document type</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">205</context> <context context-type="linenumber">214</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="232202047340644471" datatype="html"> <trans-unit id="232202047340644471" datatype="html">
<source>Storage path: <x id="PH" equiv-text="this.storagePaths.find((sp) =&gt; sp.id == +rule.value)?.name"/></source> <source>Storage path: <x id="PH" equiv-text="this.storagePaths.find((sp) =&gt; sp.id == +rule.value)?.name"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">211,213</context> <context context-type="linenumber">220,222</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1562820715074533164" datatype="html"> <trans-unit id="1562820715074533164" datatype="html">
<source>Without storage path</source> <source>Without storage path</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">215</context> <context context-type="linenumber">224</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8180755793012580465" datatype="html"> <trans-unit id="8180755793012580465" datatype="html">
<source>Tag: <x id="PH" equiv-text="this.tags.find((t) =&gt; t.id == +rule.value)?.name"/></source> <source>Tag: <x id="PH" equiv-text="this.tags.find((t) =&gt; t.id == +rule.value)?.name"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">219,221</context> <context context-type="linenumber">228,230</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6494566478302448576" datatype="html"> <trans-unit id="6494566478302448576" datatype="html">
<source>Without any tag</source> <source>Without any tag</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">225</context> <context context-type="linenumber">234</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6370692707013694620" datatype="html"> <trans-unit id="8644099678903817943" datatype="html">
<source>Custom fields: <x id="PH" equiv-text="this.customFields.find((f) =&gt; f.id == +rule.value)?.name"/></source> <source>Custom fields query</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">229,231</context> <context context-type="linenumber">238</context>
</context-group>
</trans-unit>
<trans-unit id="5297600960590041873" datatype="html">
<source>Without any custom field</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">235</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6523384805359286307" datatype="html"> <trans-unit id="6523384805359286307" datatype="html">
<source>Title: <x id="PH" equiv-text="rule.value"/></source> <source>Title: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">239</context> <context context-type="linenumber">241</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1872523635812236432" datatype="html"> <trans-unit id="1872523635812236432" datatype="html">
<source>ASN: <x id="PH" equiv-text="rule.value"/></source> <source>ASN: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">242</context> <context context-type="linenumber">244</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="102674688969746976" datatype="html"> <trans-unit id="102674688969746976" datatype="html">
<source>Owner: <x id="PH" equiv-text="rule.value"/></source> <source>Owner: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">245</context> <context context-type="linenumber">247</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3550877650686009106" datatype="html"> <trans-unit id="3550877650686009106" datatype="html">
<source>Owner not in: <x id="PH" equiv-text="rule.value"/></source> <source>Owner not in: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">248</context> <context context-type="linenumber">250</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1082034558646673343" datatype="html"> <trans-unit id="1082034558646673343" datatype="html">
<source>Without an owner</source> <source>Without an owner</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">251</context> <context context-type="linenumber">253</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7210076240260527720" datatype="html"> <trans-unit id="7210076240260527720" datatype="html">
@ -8007,6 +8069,83 @@
<context context-type="linenumber">9</context> <context context-type="linenumber">9</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7088714514100361567" datatype="html">
<source>Equal to</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">24</context>
</context-group>
</trans-unit>
<trans-unit id="2841739558138901231" datatype="html">
<source>In</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">25</context>
</context-group>
</trans-unit>
<trans-unit id="6504828068656625171" datatype="html">
<source>Is null</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">26</context>
</context-group>
</trans-unit>
<trans-unit id="4112599358351148632" datatype="html">
<source>Exists</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="6238291467288576076" datatype="html">
<source>Contains</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">28</context>
</context-group>
</trans-unit>
<trans-unit id="870133374397538941" datatype="html">
<source>Contains (case-insensitive)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">29</context>
</context-group>
</trans-unit>
<trans-unit id="7732309408488818531" datatype="html">
<source>Greater than</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="9087788064443057357" datatype="html">
<source>Greater than or equal to</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="5995604223909447366" datatype="html">
<source>Less than</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="6989379963430864867" datatype="html">
<source>Less than or equal to</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">33</context>
</context-group>
</trans-unit>
<trans-unit id="2348971518300945764" datatype="html">
<source>Range</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
<context context-type="linenumber">34</context>
</context-group>
</trans-unit>
<trans-unit id="969459137986754249" datatype="html"> <trans-unit id="969459137986754249" datatype="html">
<source>Boolean</source> <source>Boolean</source>
<context-group purpose="location"> <context-group purpose="location">

View File

@ -108,6 +108,7 @@ import { FileDropComponent } from './components/file-drop/file-drop.component'
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component' import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component' import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
import { CustomFieldsQueryDropdownComponent } from './components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
import { PdfViewerModule } from 'ng2-pdf-viewer' import { PdfViewerModule } from 'ng2-pdf-viewer'
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component' import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
@ -141,6 +142,7 @@ import {
arrowRightShort, arrowRightShort,
arrowUpRight, arrowUpRight,
asterisk, asterisk,
braces,
bodyText, bodyText,
boxArrowUp, boxArrowUp,
boxArrowUpRight, boxArrowUpRight,
@ -198,6 +200,7 @@ import {
link, link,
listTask, listTask,
listUl, listUl,
nodePlus,
pencil, pencil,
people, people,
peopleFill, peopleFill,
@ -227,6 +230,7 @@ import {
uiRadios, uiRadios,
upcScan, upcScan,
x, x,
xCircle,
xLg, xLg,
} from 'ngx-bootstrap-icons' } from 'ngx-bootstrap-icons'
@ -242,6 +246,7 @@ const icons = {
arrowRightShort, arrowRightShort,
arrowUpRight, arrowUpRight,
asterisk, asterisk,
braces,
bodyText, bodyText,
boxArrowUp, boxArrowUp,
boxArrowUpRight, boxArrowUpRight,
@ -299,6 +304,7 @@ const icons = {
link, link,
listTask, listTask,
listUl, listUl,
nodePlus,
pencil, pencil,
people, people,
peopleFill, peopleFill,
@ -328,6 +334,7 @@ const icons = {
uiRadios, uiRadios,
upcScan, upcScan,
x, x,
xCircle,
xLg, xLg,
} }
@ -485,6 +492,7 @@ function initializeApp(settings: SettingsService) {
CustomFieldsComponent, CustomFieldsComponent,
CustomFieldEditDialogComponent, CustomFieldEditDialogComponent,
CustomFieldsDropdownComponent, CustomFieldsDropdownComponent,
CustomFieldsQueryDropdownComponent,
ProfileEditDialogComponent, ProfileEditDialogComponent,
DocumentLinkComponent, DocumentLinkComponent,
PreviewPopupComponent, PreviewPopupComponent,

View File

@ -0,0 +1,163 @@
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
@if (isActive) {
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
}
</button>
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<div class="list-group list-group-flush">
@for (element of selectionModel.queries; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
}
</div>
</div>
</div>
<ng-template #comparisonValueTemplate let-atom="atom">
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
<input class="form-control" placeholder="yyyy-mm-dd"
[(ngModel)]="atom.value"
ngbDatepicker
#d="ngbDatepicker" />
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button">
<i-bs name="calendar-event"></i-bs>
</button>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [disabled]="disabled">
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
<option value="true" i18n>True</option>
<option value="false" i18n>False</option>
</select>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Select) {
<ng-select
class="paperless-input-select rounded-end"
[items]="getSelectOptionsForField(atom.field)"
[(ngModel)]="atom.value"
[disabled]="disabled"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
} @else {
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
}
</ng-template>
<ng-template #queryAtom let-atom="atom">
<div class="input-group input-group-sm">
<ng-select
class="paperless-input-select"
[items]="customFields"
[(ngModel)]="atom.field"
[disabled]="disabled"
bindLabel="name"
bindValue="id"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
<select class="w-25 form-select" [(ngModel)]="atom.operator" [disabled]="disabled">
<option *ngFor="let operator of getOperatorsForField(atom.field)" [ngValue]="operator.value">{{operator.label}}</option>
</select>
@switch (atom.operator) {
@case (CustomFieldQueryOperator.Exists) {
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
<option value="true" i18n>True</option>
<option value="false" i18n>False</option>
</select>
}
@case (CustomFieldQueryOperator.IsNull) {
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
<option value="true" i18n>True</option>
<option value="false" i18n>False</option>
</select>
}
@case (CustomFieldQueryOperator.GreaterThanOrEqual) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.LessThanOrEqual) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.GreaterThan) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.LessThan) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.Contains) {
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true"></pngx-input-document-link>
}
@case (CustomFieldQueryOperator.In) {
<ng-select
class="paperless-input-select rounded-end"
[items]="getSelectOptionsForField(atom.field)"
[(ngModel)]="atom.value"
[disabled]="disabled"
[multiple]="true"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
}
@case (CustomFieldQueryOperator.Exact) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@default {
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
}
}
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled">
<i-bs name="x-circle"></i-bs>
</button>
</div>
</ng-template>
<ng-template #queryExpression let-expression="expression">
<div class="d-flex w-100">
<div class="d-flex flex-grow-1 flex-column">
<div class="btn-group btn-group-xs" role="group">
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorOr_{{expression.id}}" name="logicalOperatorOr_{{expression.id}}" value="OR" [disabled]="expression.depth > 0 && expression.value.length < 2">
<label class="btn btn-outline-primary" for="logicalOperatorOr_{{expression.id}}" i18n>Any</label>
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorAnd_{{expression.id}}" name="logicalOperatorAnd_{{expression.id}}" value="AND" [disabled]="expression.depth > 0 && expression.value.length < 2">
<label class="btn btn-outline-primary" for="logicalOperatorAnd_{{expression.id}}" i18n>All</label>
@if (expression.negatable) {
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorNot_{{expression.id}}" name="logicalOperatorNot_{{expression.id}}" value="NOT">
<label class="btn btn-outline-secondary" for="logicalOperatorNot_{{expression.id}}" i18n>Not</label>
}
</div>
<div class="list-group list-group-flush mb-n2">
@for (element of expression.value; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
}
</div>
</div>
<div class="btn-group-vertical ms-2 ps-2 border-start" role="group" aria-label="Vertical button group">
<button type="button" class="btn btn-sm btn-outline-secondary text-primary" title="Add query" i18n-title (click)="addAtom(expression)" [disabled]="disabled || expression.value.length === CUSTOM_FIELD_QUERY_MAX_ATOMS">
<i-bs name="node-plus"></i-bs>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary text-primary" title="Add expression" i18n-title (click)="addExpression(expression)" [disabled]="disabled || expression.depth === CUSTOM_FIELD_QUERY_MAX_DEPTH">
<i-bs name="braces"></i-bs>
</button>
@if (expression.depth > 0) {
<button type="button" class="btn btn-sm btn-outline-secondary text-danger" (click)="removeElement(expression)" [disabled]="disabled">
<i-bs name="x-circle"></i-bs>
</button>
}
</div>
</div>
</ng-template>

View File

@ -0,0 +1,43 @@
.dropdown-menu {
width: 370px;
@media(min-width: 768px) {
width: 600px;
}
}
::ng-deep .ng-select-container {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
height: 100% !important;
}
::ng-deep .rounded-end .ng-select-container {
border-top-right-radius: var(--bs-border-radius) !important;
border-bottom-right-radius: var(--bs-border-radius) !important;
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
::ng-deep .ng-select {
max-width: 100px;
min-width: 35%;
font-size: 14px;
}
::ng-deep .doc-link-select {
padding-top: 0 !important;
border-top-right-radius: var(--bs-border-radius) !important;
border-bottom-right-radius: var(--bs-border-radius) !important;
background-image: none !important;
.ng-select-container,
.ng-select.ng-select-opened > .ng-select-container {
border: none !important;
min-height: 34px !important;
background: none !important;
}
.ng-select {
max-width: 200px;
min-width: 140px;
}
}

View File

@ -0,0 +1,320 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
CustomFieldQueriesModel,
CustomFieldsQueryDropdownComponent,
} from './custom-fields-query-dropdown.component'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { of } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import {
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperatorGroups,
} from 'src/app/data/custom-field-query'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import {
CustomFieldQueryExpression,
CustomFieldQueryAtom,
CustomFieldQueryElement,
} from 'src/app/utils/custom-field-query-element'
const customFields = [
{
id: 1,
name: 'Test Field',
data_type: CustomFieldDataType.String,
extra_data: {},
},
{
id: 2,
name: 'Test Select Field',
data_type: CustomFieldDataType.Select,
extra_data: { select_options: ['Option 1', 'Option 2'] },
},
]
describe('CustomFieldsQueryDropdownComponent', () => {
let component: CustomFieldsQueryDropdownComponent
let fixture: ComponentFixture<CustomFieldsQueryDropdownComponent>
let customFieldsService: CustomFieldsService
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CustomFieldsQueryDropdownComponent],
imports: [NgbDropdownModule, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
customFieldsService = TestBed.inject(CustomFieldsService)
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
count: customFields.length,
all: customFields.map((f) => f.id),
results: customFields,
})
)
fixture = TestBed.createComponent(CustomFieldsQueryDropdownComponent)
component = fixture.componentInstance
component.icon = 'ui-radios'
fixture.detectChanges()
})
it('should initialize custom fields on creation', () => {
expect(component.customFields).toEqual(customFields)
})
it('should add an expression when opened if queries are empty', () => {
component.selectionModel.clear()
component.onOpenChange(true)
expect(component.selectionModel.queries.length).toBe(1)
})
it('should support reset the selection model', () => {
component.selectionModel.addExpression()
component.reset()
expect(component.selectionModel.isEmpty()).toBeTruthy()
})
it('should get operators for a field', () => {
const field: CustomField = {
id: 1,
name: 'Test Field',
data_type: CustomFieldDataType.String,
extra_data: {},
}
component.customFields = [field]
const operators = component.getOperatorsForField(1)
expect(operators.length).toEqual(
[
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.Basic
],
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.String
],
].length
)
// Fallback to basic operators if field is not found
const operators2 = component.getOperatorsForField(2)
expect(operators2.length).toEqual(
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.Basic
].length
)
})
it('should get select options for a field', () => {
const field: CustomField = {
id: 1,
name: 'Test Field',
data_type: CustomFieldDataType.Select,
extra_data: { select_options: ['Option 1', 'Option 2'] },
}
component.customFields = [field]
const options = component.getSelectOptionsForField(1)
expect(options).toEqual(['Option 1', 'Option 2'])
// Fallback to empty array if field is not found
const options2 = component.getSelectOptionsForField(2)
expect(options2).toEqual([])
})
it('should remove an element from the selection model', () => {
const expression = new CustomFieldQueryExpression()
const atom = new CustomFieldQueryAtom()
;(expression.value as CustomFieldQueryElement[]).push(atom)
component.selectionModel.addExpression(expression)
component.removeElement(atom)
expect(component.selectionModel.isEmpty()).toBeTruthy()
const expression2 = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[1, 'icontains', 'test'],
[2, 'icontains', 'test'],
],
])
component.selectionModel.addExpression(expression2)
component.removeElement(expression2)
expect(component.selectionModel.isEmpty()).toBeTruthy()
})
it('should emit selectionModelChange when model changes', () => {
const nextSpy = jest.spyOn(component.selectionModelChange, 'next')
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
component.selectionModel.addAtom(atom)
atom.changed.next(atom)
expect(nextSpy).toHaveBeenCalled()
})
it('should complete selection model subscription when new selection model is set', () => {
const completeSpy = jest.spyOn(component.selectionModel.changed, 'complete')
const selectionModel = new CustomFieldQueriesModel()
component.selectionModel = selectionModel
expect(completeSpy).toHaveBeenCalled()
})
it('should support adding an atom', () => {
const expression = new CustomFieldQueryExpression()
component.addAtom(expression)
expect(expression.value.length).toBe(1)
})
it('should support adding an expression', () => {
const expression = new CustomFieldQueryExpression()
component.addExpression(expression)
expect(expression.value.length).toBe(1)
})
it('should support getting a custom field by ID', () => {
expect(component.getCustomFieldByID(1)).toEqual(customFields[0])
})
it('should sanitize name from title', () => {
component.title = 'Test Title'
expect(component.name).toBe('test_title')
})
describe('CustomFieldQueriesModel', () => {
let model: CustomFieldQueriesModel
beforeEach(() => {
model = new CustomFieldQueriesModel()
})
it('should initialize with empty queries', () => {
expect(model.queries).toEqual([])
})
it('should clear queries and fire event', () => {
const nextSpy = jest.spyOn(model.changed, 'next')
model.addExpression()
model.clear()
expect(model.queries).toEqual([])
expect(nextSpy).toHaveBeenCalledWith(model)
})
it('should clear queries without firing event', () => {
const nextSpy = jest.spyOn(model.changed, 'next')
model.addExpression()
model.clear(false)
expect(model.queries).toEqual([])
expect(nextSpy).not.toHaveBeenCalled()
})
it('should validate an empty model as invalid', () => {
expect(model.isValid()).toBeFalsy()
})
it('should validate a model with valid expression as valid', () => {
const expression = new CustomFieldQueryExpression()
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
const atom2 = new CustomFieldQueryAtom([2, 'icontains', 'test'])
const expression2 = new CustomFieldQueryExpression()
expression2.addAtom(atom)
expression2.addAtom(atom2)
expression.addExpression(expression2)
model.addExpression(expression)
expect(model.isValid()).toBeTruthy()
})
it('should validate a model with invalid expression as invalid', () => {
const expression = new CustomFieldQueryExpression()
model.addExpression(expression)
expect(model.isValid()).toBeFalsy()
})
it('should validate an atom with in or contains operator', () => {
const atom = new CustomFieldQueryAtom([1, 'in', '[1,2,3]'])
expect(model['validateAtom'].apply(null, [atom])).toBeTruthy()
atom.operator = 'contains'
atom.value = [1, 2, 3]
expect(model['validateAtom'].apply(null, [atom])).toBeTruthy()
atom.value = null
expect(model['validateAtom'].apply(null, [atom])).toBeFalsy()
})
it('should check if model is empty', () => {
expect(model.isEmpty()).toBeTruthy()
model.addExpression()
expect(model.isEmpty()).toBeTruthy()
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
model.addAtom(atom)
expect(model.isEmpty()).toBeFalsy()
})
it('should add an atom to the model', () => {
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
model.addAtom(atom)
expect(model.queries.length).toBe(1)
expect(
(model.queries[0] as CustomFieldQueryExpression).value.length
).toBe(1)
})
it('should add an expression to the model, propagate changes', () => {
const expression = new CustomFieldQueryExpression()
model.addExpression(expression)
expect(model.queries.length).toBe(1)
const expression2 = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[1, 'icontains', 'test'],
[2, 'icontains', 'test'],
],
])
model.addExpression(expression2)
const nextSpy = jest.spyOn(model.changed, 'next')
expression2.changed.next(expression2)
expect(nextSpy).toHaveBeenCalled()
})
it('should remove an element from the model', () => {
const expression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[1, 'icontains', 'test'],
[2, 'icontains', 'test'],
],
])
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
const expression2 = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[3, 'icontains', 'test'],
[4, 'icontains', 'test'],
],
])
expression.addAtom(atom)
expression2.addExpression(expression)
model.addExpression(expression2)
model.removeElement(atom)
expect(model.queries.length).toBe(1)
model.removeElement(expression2)
})
it('should fire changed event when an atom changes', () => {
const nextSpy = jest.spyOn(model.changed, 'next')
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
model.addAtom(atom)
atom.changed.next(atom)
expect(nextSpy).toHaveBeenCalledWith(model)
})
it('should complete changed subject when element is removed', () => {
const expression = new CustomFieldQueryExpression()
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
;(expression.value as CustomFieldQueryElement[]).push(atom)
model.addExpression(expression)
const completeSpy = jest.spyOn(atom.changed, 'complete')
model.removeElement(atom)
expect(completeSpy).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,294 @@
import {
Component,
EventEmitter,
Input,
Output,
ViewChild,
} from '@angular/core'
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
import { Subject, first, takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import {
CustomFieldQueryElementType,
CustomFieldQueryOperator,
CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE,
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
CustomFieldQueryOperatorGroups,
CUSTOM_FIELD_QUERY_OPERATOR_LABELS,
CUSTOM_FIELD_QUERY_MAX_DEPTH,
CUSTOM_FIELD_QUERY_MAX_ATOMS,
} from 'src/app/data/custom-field-query'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import {
CustomFieldQueryElement,
CustomFieldQueryExpression,
CustomFieldQueryAtom,
} from 'src/app/utils/custom-field-query-element'
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options'
export class CustomFieldQueriesModel {
public queries: CustomFieldQueryElement[] = []
public readonly changed = new Subject<CustomFieldQueriesModel>()
public clear(fireEvent = true) {
this.queries = []
if (fireEvent) {
this.changed.next(this)
}
}
public isValid(): boolean {
return (
this.queries.length > 0 &&
this.validateExpression(this.queries[0] as CustomFieldQueryExpression)
)
}
public isEmpty(): boolean {
return (
this.queries.length === 0 ||
(this.queries.length === 1 && this.queries[0].value.length === 0)
)
}
private validateAtom(atom: CustomFieldQueryAtom) {
let valid = !!(atom.field && atom.operator && atom.value !== null)
if (
[
CustomFieldQueryOperator.In.valueOf(),
CustomFieldQueryOperator.Contains.valueOf(),
].includes(atom.operator) &&
atom.value
) {
valid = valid && atom.value.length > 0
}
return valid
}
private validateExpression(expression: CustomFieldQueryExpression) {
return (
expression.operator &&
expression.value.length > 0 &&
(expression.value as CustomFieldQueryElement[]).every((e) =>
e.type === CustomFieldQueryElementType.Atom
? this.validateAtom(e as CustomFieldQueryAtom)
: this.validateExpression(e as CustomFieldQueryExpression)
)
)
}
public addAtom(atom: CustomFieldQueryAtom) {
if (this.queries.length === 0) {
this.addExpression()
}
;(this.queries[0].value as CustomFieldQueryElement[]).push(atom)
atom.changed.subscribe(() => {
if (atom.field && atom.operator && atom.value) {
this.changed.next(this)
}
})
}
public addExpression(
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
) {
if (this.queries.length > 0) {
;(
(this.queries[0] as CustomFieldQueryExpression)
.value as CustomFieldQueryElement[]
).push(expression)
} else {
this.queries.push(expression)
}
expression.changed.subscribe(() => {
this.changed.next(this)
})
}
private findElement(
queryElement: CustomFieldQueryElement,
elements: any[]
): CustomFieldQueryElement {
for (let i = 0; i < elements.length; i++) {
if (elements[i] === queryElement) {
return elements.splice(i, 1)[0]
} else if (elements[i].type === CustomFieldQueryElementType.Expression) {
return this.findElement(
queryElement,
elements[i].value as CustomFieldQueryElement[]
)
}
}
}
public removeElement(queryElement: CustomFieldQueryElement) {
let foundComponent
for (let i = 0; i < this.queries.length; i++) {
let query = this.queries[i]
if (query === queryElement) {
foundComponent = this.queries.splice(i, 1)[0]
break
} else if (query.type === CustomFieldQueryElementType.Expression) {
foundComponent = this.findElement(queryElement, query.value as any[])
}
}
if (foundComponent) {
foundComponent.changed.complete()
if (this.isEmpty()) {
this.clear()
}
this.changed.next(this)
}
}
}
@Component({
selector: 'pngx-custom-fields-query-dropdown',
templateUrl: './custom-fields-query-dropdown.component.html',
styleUrls: ['./custom-fields-query-dropdown.component.scss'],
})
export class CustomFieldsQueryDropdownComponent {
public CustomFieldQueryComponentType = CustomFieldQueryElementType
public CustomFieldQueryOperator = CustomFieldQueryOperator
public CustomFieldDataType = CustomFieldDataType
public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH
public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS
public popperOptions = popperOptionsReenablePreventOverflow
@Input()
title: string
@Input()
filterPlaceholder: string = ''
@Input()
icon: string
@Input()
allowSelectNone: boolean = false
@Input()
editing = false
@Input()
applyOnClose = false
get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
}
@Input()
disabled: boolean = false
@ViewChild('dropdown') dropdown: NgbDropdown
private _selectionModel: CustomFieldQueriesModel
@Input()
set selectionModel(model: CustomFieldQueriesModel) {
if (this._selectionModel) {
this._selectionModel.changed.complete()
}
model.changed.subscribe(() => {
this.onModelChange()
})
this._selectionModel = model
}
get selectionModel(): CustomFieldQueriesModel {
return this._selectionModel
}
private onModelChange() {
if (this.selectionModel.isEmpty() || this.selectionModel.isValid()) {
this.selectionModelChange.next(this.selectionModel)
this.selectionModel.isEmpty() && this.dropdown?.close()
}
}
@Output()
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
customFields: CustomField[] = []
private unsubscribeNotifier: Subject<any> = new Subject()
constructor(protected customFieldsService: CustomFieldsService) {
this.selectionModel = new CustomFieldQueriesModel()
this.getFields()
this.reset()
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(this)
this.unsubscribeNotifier.complete()
}
public onOpenChange(open: boolean) {
if (open && this.selectionModel.queries.length === 0) {
this.selectionModel.addExpression()
}
}
public get isActive(): boolean {
return (
(this.selectionModel.queries[0] as CustomFieldQueryExpression)?.value
?.length > 0
)
}
private getFields() {
this.customFieldsService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => {
this.customFields = result.results
})
}
public getCustomFieldByID(id: number): CustomField {
return this.customFields.find((field) => field.id === id)
}
public addAtom(expression: CustomFieldQueryExpression) {
expression.addAtom()
}
public addExpression(expression: CustomFieldQueryExpression) {
expression.addExpression()
}
public removeElement(element: CustomFieldQueryElement) {
this.selectionModel.removeElement(element)
}
public reset() {
this.selectionModel.clear(false)
this.selectionModel.changed.next(this.selectionModel)
}
getOperatorsForField(
fieldID: number
): Array<{ value: string; label: string }> {
const field = this.customFields.find((field) => field.id === fieldID)
const groups: CustomFieldQueryOperatorGroups[] = field
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
: [CustomFieldQueryOperatorGroups.Basic]
const operators = groups.flatMap(
(group) => CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[group]
)
return operators.map((operator) => ({
value: operator,
label: CUSTOM_FIELD_QUERY_OPERATOR_LABELS[operator],
}))
}
getSelectOptionsForField(fieldID: number): string[] {
const field = this.customFields.find((field) => field.id === fieldID)
if (field) {
return field.extra_data['select_options']
}
return []
}
}

View File

@ -1,50 +1,57 @@
<div class="mb-3 paperless-input-select" [class.disabled]="disabled"> @if (minimal) {
<div class="row"> <ng-container *ngTemplateOutlet="select"></ng-container>
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal"> } @else {
@if (title) { <div class="mb-3 paperless-input-select" [class.disabled]="disabled">
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label> <div class="row">
} <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
@if (removable) { @if (title) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> <label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container> }
</button> @if (removable) {
} <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
</div> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
<div [class.col-md-9]="horizontal"> </button>
<div> }
<ng-select name="inputId" [(ngModel)]="selectedDocuments" </div>
[disabled]="disabled" <div [class.col-md-9]="horizontal">
[items]="foundDocuments$ | async" <ng-container *ngTemplateOutlet="select"></ng-container>
placeholder="Search for documents" @if (hint) {
[notFoundText]="notFoundText" <small class="form-text text-muted">{{hint}}</small>
[multiple]="true" }
bindValue="id"
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading"
[typeahead]="documentsInput$"
(change)="onChange(selectedDocuments)">
<ng-template ng-label-tmp let-document="item">
<div class="d-flex align-items-center">
<button class="btn p-0 lh-1" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title>
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{document.title}}</span>
</a>
</div>
</ng-template>
<ng-template ng-loadingspinner-tmp>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</ng-template>
<ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
<div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
</ng-template>
</ng-select>
</div> </div>
@if (hint) {
<small class="form-text text-muted">{{hint}}</small>
}
</div> </div>
</div> </div>
</div> }
<ng-template #select>
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
[disabled]="disabled"
[items]="foundDocuments$ | async"
[placeholder]="placeholder"
[notFoundText]="notFoundText"
[multiple]="true"
bindValue="id"
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading"
[typeahead]="documentsInput$"
(mousedown)="$event.stopImmediatePropagation()"
(change)="onChange(selectedDocuments)">
<ng-template ng-label-tmp let-document="item">
<div class="d-flex align-items-center">
<button class="btn p-0 lh-1" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title>
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{document.title}}</span>
</a>
</div>
</ng-template>
<ng-template ng-loadingspinner-tmp>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</ng-template>
<ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
<div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
</ng-template>
</ng-select>
</ng-template>

View File

@ -46,6 +46,12 @@ export class DocumentLinkComponent
@Input() @Input()
parentDocumentID: number parentDocumentID: number
@Input()
minimal: boolean = false
@Input()
placeholder: string = $localize`Search for documents`
constructor(private documentsService: DocumentService) { constructor(private documentsService: DocumentService) {
super() super()
} }

View File

@ -140,7 +140,7 @@
} @else { } @else {
@if (list.displayMode === DisplayMode.LARGE_CARDS) { @if (list.displayMode === DisplayMode.LARGE_CARDS) {
<div> <div>
@for (d of list.documents; track trackByDocumentId($index, d)) { @for (d of list.documents; track d.id) {
<pngx-document-card-large <pngx-document-card-large
[selected]="list.isSelected(d)" [selected]="list.isSelected(d)"
(toggleSelected)="toggleSelected(d, $event)" (toggleSelected)="toggleSelected(d, $event)"
@ -269,7 +269,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (d of list.documents; track trackByDocumentId($index, d)) { @for (d of list.documents; track d.id) {
<tr (click)="toggleSelected(d, $event); $event.stopPropagation();" (dblclick)="openDocumentDetail(d)" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''"> <tr (click)="toggleSelected(d, $event); $event.stopPropagation();" (dblclick)="openDocumentDetail(d)" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''">
<td> <td>
<div class="form-check"> <div class="form-check">
@ -364,7 +364,7 @@
} }
@if (list.displayMode === DisplayMode.SMALL_CARDS) { @if (list.displayMode === DisplayMode.SMALL_CARDS) {
<div class="row row-cols-paperless-cards"> <div class="row row-cols-paperless-cards">
@for (d of list.documents; track trackByDocumentId($index, d)) { @for (d of list.documents; track d.id) {
<pngx-document-card-small class="p-0" <pngx-document-card-small class="p-0"
[selected]="list.isSelected(d)" [selected]="list.isSelected(d)"
(toggleSelected)="toggleSelected(d, $event)" (toggleSelected)="toggleSelected(d, $event)"

View File

@ -383,10 +383,6 @@ export class DocumentListComponent
]) ])
} }
trackByDocumentId(index, item: Document) {
return item.id
}
get notesEnabled(): boolean { get notesEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED) return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
} }

View File

@ -86,15 +86,10 @@
} }
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) { @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
<pngx-filterable-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title <pngx-custom-fields-query-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder [(selectionModel)]="customFieldQueriesModel"
[items]="customFields"
[manyToOne]="true"
[(selectionModel)]="customFieldSelectionModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
(opened)="onCustomFieldsDropdownOpen()" ></pngx-custom-fields-query-dropdown>
[documentCounts]="customFieldDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
} }
<pngx-dates-dropdown <pngx-dates-dropdown
title="Dates" i18n-title title="Dates" i18n-title

View File

@ -17,7 +17,7 @@ import {
NgbDropdownItem, NgbDropdownItem,
NgbTypeaheadModule, NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent } from '@ng-select/ng-select' import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { import {
FILTER_TITLE, FILTER_TITLE,
@ -55,6 +55,7 @@ import {
FILTER_HAS_ANY_CUSTOM_FIELDS, FILTER_HAS_ANY_CUSTOM_FIELDS,
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS, FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
FILTER_HAS_CUSTOM_FIELDS_ALL, FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_CUSTOM_FIELDS_QUERY,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
@ -95,6 +96,12 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
import { SearchService } from 'src/app/services/rest/search.service' import { SearchService } from 'src/app/services/rest/search.service'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { CustomFieldsQueryDropdownComponent } from '../../common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import {
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from 'src/app/data/custom-field-query'
import { CustomFieldQueryAtom } from 'src/app/utils/custom-field-query-element'
const tags: Tag[] = [ const tags: Tag[] = [
{ {
@ -181,6 +188,7 @@ describe('FilterEditorComponent', () => {
ToggleableDropdownButtonComponent, ToggleableDropdownButtonComponent,
DatesDropdownComponent, DatesDropdownComponent,
CustomDatePipe, CustomDatePipe,
CustomFieldsQueryDropdownComponent,
], ],
imports: [ imports: [
RouterModule, RouterModule,
@ -190,6 +198,7 @@ describe('FilterEditorComponent', () => {
NgbDatepickerModule, NgbDatepickerModule,
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
NgbTypeaheadModule, NgbTypeaheadModule,
NgSelectModule,
], ],
providers: [ providers: [
FilterPipe, FilterPipe,
@ -838,108 +847,79 @@ describe('FilterEditorComponent', () => {
] ]
})) }))
it('should ingest filter rules for has all custom fields', fakeAsync(() => { it('should ingest filter rules for custom fields all', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength( expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
0
)
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL, rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '42', value: '42,43',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '43',
}, },
] ]
expect(component.customFieldSelectionModel.logicalOperator).toEqual( expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
LogicalOperator.And CustomFieldQueryLogicalOperator.And
) )
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual( expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(2)
custom_fields expect(
) (
// coverage component.customFieldQueriesModel.queries[0]
component.filterRules = [ .value[0] as CustomFieldQueryAtom
{ ).serialize()
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL, ).toEqual(['42', CustomFieldQueryOperator.Exists, 'true'])
value: null,
},
]
component.toggleTag(2) // coverage
})) }))
it('should ingest filter rules for has any custom fields', fakeAsync(() => { it('should ingest filter rules for has any custom fields', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength( expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
0
)
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY, rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '42', value: '42,43',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '43',
}, },
] ]
expect(component.customFieldSelectionModel.logicalOperator).toEqual( expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
LogicalOperator.Or CustomFieldQueryLogicalOperator.Or
) )
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual( expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(2)
custom_fields expect(
) (
// coverage component.customFieldQueriesModel.queries[0]
component.filterRules = [ .value[0] as CustomFieldQueryAtom
{ ).serialize()
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY, ).toEqual(['42', CustomFieldQueryOperator.Exists, 'true'])
value: null,
},
]
})) }))
it('should ingest filter rules for has any custom field', fakeAsync(() => { it('should ingest filter rules for custom field queries', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength( expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
0
)
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS, rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: '1', value: '["AND", [[42, "exists", "true"],[43, "exists", "true"]]]',
}, },
] ]
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength( expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
1 CustomFieldQueryLogicalOperator.And
) )
expect(component.customFieldSelectionModel.get(null)).toBeTruthy() expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(2)
})) expect(
(
component.customFieldQueriesModel.queries[0]
.value[0] as CustomFieldQueryAtom
).serialize()
).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
it('should ingest filter rules for exclude tag(s)', fakeAsync(() => { // atom
expect(component.customFieldSelectionModel.getExcludedItems()).toHaveLength(
0
)
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS, rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: '42', value: '[42, "exists", "true"]',
},
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: '43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
LogicalOperator.And
)
expect(component.customFieldSelectionModel.getExcludedItems()).toEqual(
custom_fields
)
// coverage
component.filterRules = [
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: null,
}, },
] ]
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(1)
expect(
(
component.customFieldQueriesModel.queries[0]
.value[0] as CustomFieldQueryAtom
).serialize()
).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
})) }))
it('should ingest filter rules for owner', fakeAsync(() => { it('should ingest filter rules for owner', fakeAsync(() => {
@ -1453,71 +1433,37 @@ describe('FilterEditorComponent', () => {
]) ])
})) }))
it('should convert user input to correct filter rules on custom field select not assigned', fakeAsync(() => {
const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent)
)[4]
customFieldsFilterableDropdown.triggerEventHandler('opened')
const customFieldButton = customFieldsFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent)
)[0]
customFieldButton.triggerEventHandler('toggle')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: 'false',
},
])
}))
it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => { it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => {
const customFieldsFilterableDropdown = fixture.debugElement.queryAll( const customFieldsQueryDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent) By.directive(CustomFieldsQueryDropdownComponent)
)[4] // CF dropdown )[0]
customFieldsFilterableDropdown.triggerEventHandler('opened') const customFieldToggleButton = customFieldsQueryDropdown.query(
const customFieldButtons = customFieldsFilterableDropdown.queryAll( By.css('button')
By.directive(ToggleableDropdownButtonComponent)
) )
customFieldButtons[1].triggerEventHandler('toggle') customFieldToggleButton.triggerEventHandler('click')
customFieldButtons[2].triggerEventHandler('toggle')
fixture.detectChanges() fixture.detectChanges()
expect(component.filterRules).toEqual([ const customFieldButtons = customFieldsQueryDropdown.queryAll(
{ By.css('button')
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: custom_fields[0].id.toString(),
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: custom_fields[1].id.toString(),
},
])
const toggleOperatorButtons = customFieldsFilterableDropdown.queryAll(
By.css('input[type=radio]')
) )
toggleOperatorButtons[1].nativeElement.checked = true customFieldButtons[1].triggerEventHandler('click')
toggleOperatorButtons[1].triggerEventHandler('change')
fixture.detectChanges() fixture.detectChanges()
const query = component.customFieldQueriesModel
.queries[0] as CustomFieldQueryAtom
query.field = custom_fields[0].id
const fieldSelect: NgSelectComponent = customFieldsQueryDropdown.queryAll(
By.directive(NgSelectComponent)
)[0].componentInstance
fieldSelect.open()
const options = customFieldsQueryDropdown.queryAll(By.css('.ng-option'))
options[0].nativeElement.click()
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(1)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY, rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: custom_fields[0].id.toString(), value: JSON.stringify([
}, CustomFieldQueryLogicalOperator.Or,
{ [[custom_fields[0].id, 'exists', 'true']],
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY, ]),
value: custom_fields[1].id.toString(),
},
])
customFieldButtons[2].triggerEventHandler('exclude')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: custom_fields[0].id.toString(),
},
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: custom_fields[1].id.toString(),
}, },
]) ])
})) }))
@ -1930,21 +1876,11 @@ describe('FilterEditorComponent', () => {
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL, rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: '42', value: '["AND",[["42","exists","true"],["43","exists","true"]]]',
}, },
] ]
expect(component.generateFilterName()).toEqual( expect(component.generateFilterName()).toEqual(`Custom fields query`)
`Custom fields: ${custom_fields[0].name}`
)
component.filterRules = [
{
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: 'false',
},
]
expect(component.generateFilterName()).toEqual('Without any custom field')
component.filterRules = [ component.filterRules = [
{ {

View File

@ -12,7 +12,7 @@ import {
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
import { Observable, Subject, Subscription, from } from 'rxjs' import { Observable, Subject, from } from 'rxjs'
import { import {
catchError, catchError,
debounceTime, debounceTime,
@ -62,7 +62,7 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ANY, FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_CUSTOM_FIELDS_ALL, FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_ANY_CUSTOM_FIELDS, FILTER_HAS_ANY_CUSTOM_FIELDS,
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS, FILTER_CUSTOM_FIELDS_QUERY,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { import {
FilterableDropdownSelectionModel, FilterableDropdownSelectionModel,
@ -92,6 +92,15 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField } from 'src/app/data/custom-field' import { CustomField } from 'src/app/data/custom-field'
import { SearchService } from 'src/app/services/rest/search.service' import { SearchService } from 'src/app/services/rest/search.service'
import {
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from 'src/app/data/custom-field-query'
import { CustomFieldQueriesModel } from '../../common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import {
CustomFieldQueryExpression,
CustomFieldQueryAtom,
} from 'src/app/utils/custom-field-query-element'
const TEXT_FILTER_TARGET_TITLE = 'title' const TEXT_FILTER_TARGET_TITLE = 'title'
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content' const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
@ -225,15 +234,8 @@ export class FilterEditorComponent
return $localize`Without any tag` return $localize`Without any tag`
} }
case FILTER_HAS_CUSTOM_FIELDS_ALL: case FILTER_CUSTOM_FIELDS_QUERY:
return $localize`Custom fields: ${ return $localize`Custom fields query`
this.customFields.find((f) => f.id == +rule.value)?.name
}`
case FILTER_HAS_ANY_CUSTOM_FIELDS:
if (rule.value == 'false') {
return $localize`Without any custom field`
}
case FILTER_TITLE: case FILTER_TITLE:
return $localize`Title: ${rule.value}` return $localize`Title: ${rule.value}`
@ -321,7 +323,7 @@ export class FilterEditorComponent
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel() storagePathSelectionModel = new FilterableDropdownSelectionModel()
customFieldSelectionModel = new FilterableDropdownSelectionModel() customFieldQueriesModel = new CustomFieldQueriesModel()
dateCreatedBefore: string dateCreatedBefore: string
dateCreatedAfter: string dateCreatedAfter: string
@ -356,7 +358,7 @@ export class FilterEditorComponent
this.storagePathSelectionModel.clear(false) this.storagePathSelectionModel.clear(false)
this.tagSelectionModel.clear(false) this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false) this.correspondentSelectionModel.clear(false)
this.customFieldSelectionModel.clear(false) this.customFieldQueriesModel.clear(false)
this._textFilter = null this._textFilter = null
this._moreLikeId = null this._moreLikeId = null
this.dateAddedBefore = null this.dateAddedBefore = null
@ -523,34 +525,45 @@ export class FilterEditorComponent
false false
) )
break break
case FILTER_CUSTOM_FIELDS_QUERY:
try {
const query = JSON.parse(rule.value)
if (Array.isArray(query)) {
if (query.length === 2) {
// expression
this.customFieldQueriesModel.addExpression(
new CustomFieldQueryExpression(query as any)
)
} else if (query.length === 3) {
// atom
this.customFieldQueriesModel.addAtom(
new CustomFieldQueryAtom(query as any)
)
}
}
} catch (e) {
// error handled by list view service
}
break
// Legacy custom field filters
case FILTER_HAS_CUSTOM_FIELDS_ALL: case FILTER_HAS_CUSTOM_FIELDS_ALL:
this.customFieldSelectionModel.logicalOperator = LogicalOperator.And this.customFieldQueriesModel.addExpression(
this.customFieldSelectionModel.set( new CustomFieldQueryExpression([
rule.value ? +rule.value : null, CustomFieldQueryLogicalOperator.And,
ToggleableItemState.Selected, rule.value
false .split(',')
.map((id) => [id, CustomFieldQueryOperator.Exists, 'true']),
])
) )
break break
case FILTER_HAS_CUSTOM_FIELDS_ANY: case FILTER_HAS_CUSTOM_FIELDS_ANY:
this.customFieldSelectionModel.logicalOperator = LogicalOperator.Or this.customFieldQueriesModel.addExpression(
this.customFieldSelectionModel.set( new CustomFieldQueryExpression([
rule.value ? +rule.value : null, CustomFieldQueryLogicalOperator.Or,
ToggleableItemState.Selected, rule.value
false .split(',')
) .map((id) => [id, CustomFieldQueryOperator.Exists, 'true']),
break ])
case FILTER_HAS_ANY_CUSTOM_FIELDS:
this.customFieldSelectionModel.set(
null,
ToggleableItemState.Selected,
false
)
break
case FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS:
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
) )
break break
case FILTER_ASN_ISNULL: case FILTER_ASN_ISNULL:
@ -768,34 +781,14 @@ export class FilterEditorComponent
}) })
}) })
} }
if (this.customFieldSelectionModel.isNoneSelected()) { let queries = this.customFieldQueriesModel.queries.map((query) =>
query.serialize()
)
if (queries.length > 0) {
filterRules.push({ filterRules.push({
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS, rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: 'false', value: JSON.stringify(queries[0]),
}) })
} else {
const customFieldFilterType =
this.customFieldSelectionModel.logicalOperator == LogicalOperator.And
? FILTER_HAS_CUSTOM_FIELDS_ALL
: FILTER_HAS_CUSTOM_FIELDS_ANY
this.customFieldSelectionModel
.getSelectedItems()
.filter((field) => field.id)
.forEach((field) => {
filterRules.push({
rule_type: customFieldFilterType,
value: field.id?.toString(),
})
})
this.customFieldSelectionModel
.getExcludedItems()
.filter((field) => field.id)
.forEach((field) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: field.id?.toString(),
})
})
} }
if (this.dateCreatedBefore) { if (this.dateCreatedBefore) {
filterRules.push({ filterRules.push({
@ -1079,10 +1072,6 @@ export class FilterEditorComponent
this.storagePathSelectionModel.apply() this.storagePathSelectionModel.apply()
} }
onCustomFieldsDropdownOpen() {
this.customFieldSelectionModel.apply()
}
updateTextFilter(text, updateRules = true) { updateTextFilter(text, updateRules = true) {
this._textFilter = text this._textFilter = text
if (updateRules) { if (updateRules) {

View File

@ -0,0 +1,127 @@
import { CustomFieldDataType } from './custom-field'
export enum CustomFieldQueryLogicalOperator {
And = 'AND',
Or = 'OR',
Not = 'NOT',
}
export enum CustomFieldQueryOperator {
Exact = 'exact',
In = 'in',
IsNull = 'isnull',
Exists = 'exists',
Contains = 'contains',
IContains = 'icontains',
GreaterThan = 'gt',
GreaterThanOrEqual = 'gte',
LessThan = 'lt',
LessThanOrEqual = 'lte',
Range = 'range',
}
export const CUSTOM_FIELD_QUERY_OPERATOR_LABELS = {
[CustomFieldQueryOperator.Exact]: $localize`Equal to`,
[CustomFieldQueryOperator.In]: $localize`In`,
[CustomFieldQueryOperator.IsNull]: $localize`Is null`,
[CustomFieldQueryOperator.Exists]: $localize`Exists`,
[CustomFieldQueryOperator.Contains]: $localize`Contains`,
[CustomFieldQueryOperator.IContains]: $localize`Contains (case-insensitive)`,
[CustomFieldQueryOperator.GreaterThan]: $localize`Greater than`,
[CustomFieldQueryOperator.GreaterThanOrEqual]: $localize`Greater than or equal to`,
[CustomFieldQueryOperator.LessThan]: $localize`Less than`,
[CustomFieldQueryOperator.LessThanOrEqual]: $localize`Less than or equal to`,
[CustomFieldQueryOperator.Range]: $localize`Range`,
}
export enum CustomFieldQueryOperatorGroups {
Basic = 'basic',
String = 'string',
Arithmetic = 'arithmetic',
Containment = 'containment',
Subset = 'subset',
Date = 'date',
}
// Modified from filters.py > SUPPORTED_EXPR_OPERATORS
export const CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP = {
[CustomFieldQueryOperatorGroups.Basic]: [
CustomFieldQueryOperator.Exists,
CustomFieldQueryOperator.IsNull,
CustomFieldQueryOperator.Exact,
],
[CustomFieldQueryOperatorGroups.String]: [CustomFieldQueryOperator.IContains],
[CustomFieldQueryOperatorGroups.Arithmetic]: [
CustomFieldQueryOperator.GreaterThan,
CustomFieldQueryOperator.GreaterThanOrEqual,
CustomFieldQueryOperator.LessThan,
CustomFieldQueryOperator.LessThanOrEqual,
],
[CustomFieldQueryOperatorGroups.Containment]: [
CustomFieldQueryOperator.Contains,
],
[CustomFieldQueryOperatorGroups.Subset]: [CustomFieldQueryOperator.In],
[CustomFieldQueryOperatorGroups.Date]: [
CustomFieldQueryOperator.GreaterThanOrEqual,
CustomFieldQueryOperator.LessThanOrEqual,
],
}
// filters.py > SUPPORTED_EXPR_CATEGORIES
export const CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE = {
[CustomFieldDataType.String]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.String,
],
[CustomFieldDataType.Url]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.String,
],
[CustomFieldDataType.Date]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Date,
],
[CustomFieldDataType.Boolean]: [CustomFieldQueryOperatorGroups.Basic],
[CustomFieldDataType.Integer]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Arithmetic,
],
[CustomFieldDataType.Float]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Arithmetic,
],
[CustomFieldDataType.Monetary]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.String,
CustomFieldQueryOperatorGroups.Arithmetic,
],
[CustomFieldDataType.DocumentLink]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Containment,
],
[CustomFieldDataType.Select]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Subset,
],
}
export const CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR = {
[CustomFieldQueryOperator.Exact]: 'string|boolean',
[CustomFieldQueryOperator.IsNull]: 'boolean',
[CustomFieldQueryOperator.Exists]: 'boolean',
[CustomFieldQueryOperator.IContains]: 'string',
[CustomFieldQueryOperator.GreaterThanOrEqual]: 'string|number',
[CustomFieldQueryOperator.LessThanOrEqual]: 'string|number',
[CustomFieldQueryOperator.GreaterThan]: 'number',
[CustomFieldQueryOperator.LessThan]: 'number',
[CustomFieldQueryOperator.Contains]: 'array',
[CustomFieldQueryOperator.In]: 'array',
}
export const CUSTOM_FIELD_QUERY_MAX_DEPTH = 4
export const CUSTOM_FIELD_QUERY_MAX_ATOMS = 5
export enum CustomFieldQueryElementType {
Atom = 'Atom',
Expression = 'Expression',
}

View File

@ -55,6 +55,8 @@ export const FILTER_HAS_CUSTOM_FIELDS_ANY = 39
export const FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS = 40 export const FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS = 40
export const FILTER_HAS_ANY_CUSTOM_FIELDS = 41 export const FILTER_HAS_ANY_CUSTOM_FIELDS = 41
export const FILTER_CUSTOM_FIELDS_QUERY = 42
export const FILTER_RULE_TYPES: FilterRuleType[] = [ export const FILTER_RULE_TYPES: FilterRuleType[] = [
{ {
id: FILTER_TITLE, id: FILTER_TITLE,
@ -317,6 +319,12 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
multi: false, multi: false,
default: true, default: true,
}, },
{
id: FILTER_CUSTOM_FIELDS_QUERY,
filtervar: 'custom_field_query',
datatype: 'string',
multi: false,
},
] ]
export interface FilterRuleType { export interface FilterRuleType {

View File

@ -0,0 +1,245 @@
import {
CustomFieldQueryElement,
CustomFieldQueryAtom,
CustomFieldQueryExpression,
} from './custom-field-query-element'
import {
CustomFieldQueryElementType,
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from '../data/custom-field-query'
import { fakeAsync, tick } from '@angular/core/testing'
describe('CustomFieldQueryElement', () => {
it('should initialize with correct type and id', () => {
const element = new CustomFieldQueryElement(
CustomFieldQueryElementType.Atom
)
expect(element.type).toBe(CustomFieldQueryElementType.Atom)
expect(element.id).toBeDefined()
})
it('should trigger changed on operator change', () => {
const element = new CustomFieldQueryElement(
CustomFieldQueryElementType.Atom
)
element.changed.subscribe((changedElement) => {
expect(changedElement).toBe(element)
})
element.operator = CustomFieldQueryOperator.Exists
})
it('should trigger changed subject on value change', () => {
const element = new CustomFieldQueryElement(
CustomFieldQueryElementType.Atom
)
element.changed.subscribe((changedElement) => {
expect(changedElement).toBe(element)
})
element.value = 'new value'
})
it('should throw error on serialize call', () => {
const element = new CustomFieldQueryElement(
CustomFieldQueryElementType.Atom
)
expect(() => element.serialize()).toThrow('Implemented in subclass')
})
})
describe('CustomFieldQueryAtom', () => {
it('should initialize with correct field, operator, and value', () => {
const atom = new CustomFieldQueryAtom([1, 'operator', 'value'])
expect(atom.field).toBe(1)
expect(atom.operator).toBe('operator')
expect(atom.value).toBe('value')
})
it('should trigger changed subject on field change', () => {
const atom = new CustomFieldQueryAtom()
atom.changed.subscribe((changedAtom) => {
expect(changedAtom).toBe(atom)
})
atom.field = 2
})
it('should set value to null if operator is not found in CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR', () => {
const atom = new CustomFieldQueryAtom()
atom.operator = 'nonexistent_operator'
expect(atom.value).toBeNull()
})
it('should set value to empty string if new type is string', () => {
const atom = new CustomFieldQueryAtom()
atom.operator = CustomFieldQueryOperator.IContains
expect(atom.value).toBe('')
})
it('should set value to "true" if new type is boolean', () => {
const atom = new CustomFieldQueryAtom()
atom.operator = CustomFieldQueryOperator.Exists
expect(atom.value).toBe('true')
})
it('should set value to empty array if new type is array', () => {
const atom = new CustomFieldQueryAtom()
atom.operator = CustomFieldQueryOperator.In
expect(atom.value).toEqual([])
})
it('should try to set existing value to number if new type is number', () => {
const atom = new CustomFieldQueryAtom()
atom.value = '42'
atom.operator = CustomFieldQueryOperator.GreaterThan
expect(atom.value).toBe('42')
// fallback to null if value is not parseable
atom.value = 'not_a_number'
atom.operator = CustomFieldQueryOperator.GreaterThan
expect(atom.value).toBeNull()
})
it('should change boolean values to empty string if operator is not boolean', () => {
const atom = new CustomFieldQueryAtom()
atom.value = 'true'
atom.operator = CustomFieldQueryOperator.Exact
expect(atom.value).toBe('')
})
it('should serialize correctly', () => {
const atom = new CustomFieldQueryAtom([1, 'operator', 'value'])
expect(atom.serialize()).toEqual([1, 'operator', 'value'])
})
it('should emit changed on value change after debounce', fakeAsync(() => {
const atom = new CustomFieldQueryAtom()
const changeSpy = jest.spyOn(atom.changed, 'next')
atom.value = 'new value'
tick(1000)
expect(changeSpy).toHaveBeenCalled()
}))
})
describe('CustomFieldQueryExpression', () => {
it('should initialize with default operator and empty value', () => {
const expression = new CustomFieldQueryExpression()
expect(expression.operator).toBe(CustomFieldQueryLogicalOperator.Or)
expect(expression.value).toEqual([])
})
it('should initialize with correct operator and value, propagate changes', () => {
const expression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[1, 'exists', 'true'],
[2, 'exists', 'true'],
],
])
expect(expression.operator).toBe(CustomFieldQueryLogicalOperator.And)
expect(expression.value.length).toBe(2)
// propagate changes
const expressionChangeSpy = jest.spyOn(expression.changed, 'next')
;(expression.value[0] as CustomFieldQueryAtom).changed.next(
expression.value[0] as any
)
expect(expressionChangeSpy).toHaveBeenCalled()
const expression2 = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.Not,
[[CustomFieldQueryLogicalOperator.Or, []]],
])
const expressionChangeSpy2 = jest.spyOn(expression2.changed, 'next')
;(expression2.value[0] as CustomFieldQueryExpression).changed.next(
expression2.value[0] as any
)
expect(expressionChangeSpy2).toHaveBeenCalled()
})
it('should initialize with a sub-expression i.e. NOT', () => {
const expression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.Not,
[
'AND',
[
[1, 'exists', 'true'],
[2, 'exists', 'true'],
],
],
])
expect(expression.value).toHaveLength(1)
const changedSpy = jest.spyOn(expression.changed, 'next')
;(expression.value[0] as CustomFieldQueryExpression).changed.next(
expression.value[0] as any
)
expect(changedSpy).toHaveBeenCalled()
})
it('should add atom correctly, propagate changes', () => {
const expression = new CustomFieldQueryExpression()
const atom = new CustomFieldQueryAtom([
1,
CustomFieldQueryOperator.Exists,
'true',
])
expression.addAtom(atom)
expect(expression.value).toContain(atom)
const changeSpy = jest.spyOn(expression.changed, 'next')
atom.changed.next(atom)
expect(changeSpy).toHaveBeenCalled()
// coverage
expression.addAtom()
})
it('should add expression correctly, propagate changes', () => {
const expression = new CustomFieldQueryExpression()
const subExpression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.Or,
[],
])
expression.addExpression(subExpression)
expect(expression.value).toContain(subExpression)
const changeSpy = jest.spyOn(expression.changed, 'next')
subExpression.changed.next(subExpression)
expect(changeSpy).toHaveBeenCalled()
// coverage
expression.addExpression()
})
it('should serialize correctly', () => {
const expression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[[1, 'exists', 'true']],
])
expect(expression.serialize()).toEqual([
CustomFieldQueryLogicalOperator.And,
[[1, 'exists', 'true']],
])
})
it('should serialize NOT expressions correctly', () => {
const expression = new CustomFieldQueryExpression()
expression.addExpression(
new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[1, 'exists', 'true'],
[2, 'exists', 'true'],
],
])
)
expression.operator = CustomFieldQueryLogicalOperator.Not
const serialized = expression.serialize()
expect(serialized[0]).toBe(CustomFieldQueryLogicalOperator.Not)
expect(serialized[1][0]).toBe(CustomFieldQueryLogicalOperator.And)
expect(serialized[1][1].length).toBe(2)
})
it('should be negatable if it has one child which is an expression', () => {
const expression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.Not,
[[CustomFieldQueryLogicalOperator.Or, []]],
])
expect(expression.negatable).toBe(true)
})
})

View File

@ -0,0 +1,210 @@
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
import { v4 as uuidv4 } from 'uuid'
import {
CustomFieldQueryElementType,
CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR,
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from '../data/custom-field-query'
export class CustomFieldQueryElement {
public readonly type: CustomFieldQueryElementType
public changed: Subject<CustomFieldQueryElement>
protected valueModelChanged: Subject<
string | string[] | number[] | CustomFieldQueryElement[]
>
public depth: number = 0
public id: string = uuidv4()
constructor(type: CustomFieldQueryElementType) {
this.type = type
this.changed = new Subject<CustomFieldQueryElement>()
this.valueModelChanged = new Subject<string | CustomFieldQueryElement[]>()
this.connectValueModelChanged()
}
protected connectValueModelChanged() {
// Allows overriding in subclasses
this.valueModelChanged.subscribe(() => {
this.changed.next(this)
})
}
public serialize() {
throw new Error('Implemented in subclass')
}
protected _operator: string = null
public set operator(value: string) {
this._operator = value
this.changed.next(this)
}
public get operator(): string {
return this._operator
}
protected _value: string | string[] | number[] | CustomFieldQueryElement[] =
null
public set value(
value: string | string[] | number[] | CustomFieldQueryElement[]
) {
this._value = value
this.valueModelChanged.next(value)
}
public get value(): string | string[] | number[] | CustomFieldQueryElement[] {
return this._value
}
}
export class CustomFieldQueryAtom extends CustomFieldQueryElement {
protected _field: number
set field(field: any) {
this._field = parseInt(field, 10)
this.changed.next(this)
}
get field(): number {
return this._field
}
override set operator(operator: string) {
const newTypes: string[] =
CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR[operator]?.split('|')
if (!newTypes) {
this.value = null
} else {
if (!newTypes.includes(typeof this.value)) {
switch (newTypes[0]) {
case 'string':
this.value = ''
break
case 'boolean':
this.value = 'true'
break
case 'array':
this.value = []
break
case 'number':
const num = parseFloat(this.value as string)
this.value = isNaN(num) ? null : num.toString()
break
}
} else if (
['true', 'false'].includes(this.value as string) &&
newTypes.includes('string')
) {
this.value = ''
}
}
super.operator = operator
}
override get operator(): string {
// why?
return super.operator
}
constructor(queryArray: [number, string, string] = [null, null, null]) {
super(CustomFieldQueryElementType.Atom)
;[this._field, this._operator, this._value] = queryArray
}
protected override connectValueModelChanged(): void {
this.valueModelChanged
.pipe(debounceTime(1000), distinctUntilChanged())
.subscribe(() => {
this.changed.next(this)
})
}
public override serialize() {
return [this._field, this._operator, this._value]
}
}
export class CustomFieldQueryExpression extends CustomFieldQueryElement {
protected _value: string[] | number[] | CustomFieldQueryElement[]
constructor(
expressionArray: [CustomFieldQueryLogicalOperator, any[]] = [
CustomFieldQueryLogicalOperator.Or,
null,
]
) {
super(CustomFieldQueryElementType.Expression)
let values
;[this._operator, values] = expressionArray
if (!values || values.length === 0) {
this._value = []
} else if (values?.length > 0 && values[0] instanceof Array) {
this._value = values.map((value) => {
if (value.length === 3) {
const atom = new CustomFieldQueryAtom(value)
atom.depth = this.depth + 1
atom.changed.subscribe(() => {
this.changed.next(this)
})
return atom
} else {
const expression = new CustomFieldQueryExpression(value)
expression.depth = this.depth + 1
expression.changed.subscribe(() => {
this.changed.next(this)
})
return expression
}
})
} else {
const expression = new CustomFieldQueryExpression(values as any)
expression.depth = this.depth + 1
expression.changed.subscribe(() => {
this.changed.next(this)
})
this._value = [expression]
}
}
public override serialize() {
let value
value = this._value.map((element) => element.serialize())
// If the expression is negated it should have only one child which is an expression
if (
this._operator === CustomFieldQueryLogicalOperator.Not &&
value.length === 1
) {
value = value[0]
}
return [this._operator, value]
}
public addAtom(
atom: CustomFieldQueryAtom = new CustomFieldQueryAtom([
null,
CustomFieldQueryOperator.Exists,
'true',
])
) {
atom.depth = this.depth + 1
;(this._value as CustomFieldQueryElement[]).push(atom)
atom.changed.subscribe(() => {
this.changed.next(this)
})
}
public addExpression(
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
) {
expression.depth = this.depth + 1
;(this._value as CustomFieldQueryElement[]).push(expression)
expression.changed.subscribe(() => {
this.changed.next(this)
})
}
public get negatable(): boolean {
return (
this.value.length === 1 &&
(this.value[0] as CustomFieldQueryElement).type ===
CustomFieldQueryElementType.Expression
)
}
}

View File

@ -2,13 +2,17 @@ import { convertToParamMap } from '@angular/router'
import { FilterRule } from '../data/filter-rule' import { FilterRule } from '../data/filter-rule'
import { import {
FILTER_CORRESPONDENT, FILTER_CORRESPONDENT,
FILTER_CUSTOM_FIELDS_QUERY,
FILTER_HAS_ANY_TAG, FILTER_HAS_ANY_TAG,
FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ALL,
} from '../data/filter-rule-type' } from '../data/filter-rule-type'
import { paramsToViewState } from './query-params' import { paramsToViewState, transformLegacyFilterRules } from './query-params'
import { paramsFromViewState } from './query-params' import { paramsFromViewState } from './query-params'
import { queryParamsFromFilterRules } from './query-params' import { queryParamsFromFilterRules } from './query-params'
import { filterRulesFromQueryParams } from './query-params' import { filterRulesFromQueryParams } from './query-params'
import { CustomFieldQueryLogicalOperator } from '../data/custom-field-query'
const tags__id__all = '9' const tags__id__all = '9'
const filterRules: FilterRule[] = [ const filterRules: FilterRule[] = [
@ -193,4 +197,58 @@ describe('QueryParams Utils', () => {
}, },
]) ])
}) })
it('should transform legacy filter rules', () => {
let filterRules: FilterRule[] = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '1',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '2',
},
]
let transformedFilterRules = transformLegacyFilterRules(filterRules)
expect(transformedFilterRules).toEqual([
{
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: JSON.stringify([
CustomFieldQueryLogicalOperator.Or,
[
[1, 'exists', true],
[2, 'exists', true],
],
]),
},
])
filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '3',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '4',
},
]
transformedFilterRules = transformLegacyFilterRules(filterRules)
expect(transformedFilterRules).toEqual([
{
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: JSON.stringify([
CustomFieldQueryLogicalOperator.And,
[
[3, 'exists', true],
[4, 'exists', true],
],
]),
},
])
})
}) })

View File

@ -1,7 +1,17 @@
import { ParamMap, Params } from '@angular/router' import { ParamMap, Params } from '@angular/router'
import { FilterRule } from '../data/filter-rule' import { FilterRule } from '../data/filter-rule'
import { FilterRuleType, FILTER_RULE_TYPES } from '../data/filter-rule-type' import {
FilterRuleType,
FILTER_RULE_TYPES,
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_CUSTOM_FIELDS_QUERY,
FILTER_HAS_CUSTOM_FIELDS_ALL,
} from '../data/filter-rule-type'
import { ListViewState } from '../services/document-list-view.service' import { ListViewState } from '../services/document-list-view.service'
import {
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from '../data/custom-field-query'
const SORT_FIELD_PARAMETER = 'sort' const SORT_FIELD_PARAMETER = 'sort'
const SORT_REVERSE_PARAMETER = 'reverse' const SORT_REVERSE_PARAMETER = 'reverse'
@ -40,6 +50,49 @@ export function paramsToViewState(queryParams: ParamMap): ListViewState {
} }
} }
export function transformLegacyFilterRules(
filterRules: FilterRule[]
): FilterRule[] {
const LEGACY_CUSTOM_FIELD_FILTER_RULE_TYPES = [
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_CUSTOM_FIELDS_ALL,
]
if (
filterRules.filter((rule) =>
LEGACY_CUSTOM_FIELD_FILTER_RULE_TYPES.includes(rule.rule_type)
).length
) {
const anyRules = filterRules.filter(
(rule) => rule.rule_type === FILTER_HAS_CUSTOM_FIELDS_ANY
)
const allRules = filterRules.filter(
(rule) => rule.rule_type === FILTER_HAS_CUSTOM_FIELDS_ALL
)
const customFieldQueryLogicalOperator = allRules.length
? CustomFieldQueryLogicalOperator.And
: CustomFieldQueryLogicalOperator.Or
const valueRules = allRules.length ? allRules : anyRules
const customFieldQueryExpression = [
customFieldQueryLogicalOperator,
[
...valueRules.map((rule) => [
parseInt(rule.value),
CustomFieldQueryOperator.Exists,
true,
]),
],
]
filterRules.push({
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: JSON.stringify(customFieldQueryExpression),
})
}
// TODO: can we support FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS or FILTER_HAS_ANY_CUSTOM_FIELDS?
return filterRules.filter(
(rule) => !LEGACY_CUSTOM_FIELD_FILTER_RULE_TYPES.includes(rule.rule_type)
)
}
export function filterRulesFromQueryParams( export function filterRulesFromQueryParams(
queryParams: ParamMap queryParams: ParamMap
): FilterRule[] { ): FilterRule[] {
@ -77,7 +130,9 @@ export function filterRulesFromQueryParams(
}) })
) )
}) })
filterRulesFromQueryParams = transformLegacyFilterRules(
filterRulesFromQueryParams
)
return filterRulesFromQueryParams return filterRulesFromQueryParams
} }

View File

@ -29,13 +29,15 @@ from documents.models import Log
from documents.models import ShareLink from documents.models import ShareLink
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from paperless import settings
CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"] CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"]
ID_KWARGS = ["in", "exact"] ID_KWARGS = ["in", "exact"]
INT_KWARGS = ["exact", "gt", "gte", "lt", "lte", "isnull"] INT_KWARGS = ["exact", "gt", "gte", "lt", "lte", "isnull"]
DATE_KWARGS = ["year", "month", "day", "date__gt", "gt", "date__lt", "lt"] DATE_KWARGS = ["year", "month", "day", "date__gt", "gt", "date__lt", "lt"]
CUSTOM_FIELD_QUERY_MAX_DEPTH = 10
CUSTOM_FIELD_QUERY_MAX_ATOMS = 20
class CorrespondentFilterSet(FilterSet): class CorrespondentFilterSet(FilterSet):
class Meta: class Meta:
@ -234,19 +236,13 @@ def handle_validation_prefix(func: Callable):
return wrapper return wrapper
class CustomFieldLookupParser: class CustomFieldQueryParser:
EXPR_BY_CATEGORY = { EXPR_BY_CATEGORY = {
"basic": ["exact", "in", "isnull", "exists"], "basic": ["exact", "in", "isnull", "exists"],
"string": [ "string": [
"iexact",
"contains",
"icontains", "icontains",
"startswith",
"istartswith", "istartswith",
"endswith",
"iendswith", "iendswith",
"regex",
"iregex",
], ],
"arithmetic": [ "arithmetic": [
"gt", "gt",
@ -258,23 +254,6 @@ class CustomFieldLookupParser:
"containment": ["contains"], "containment": ["contains"],
} }
# These string lookup expressions are problematic. We shall disable
# them by default unless the user explicitly opts in.
STR_EXPR_DISABLED_BY_DEFAULT = [
# SQLite: is case-sensitive outside the ASCII range
"iexact",
# SQLite: behaves the same as icontains
"contains",
# SQLite: behaves the same as istartswith
"startswith",
# SQLite: behaves the same as iendswith
"endswith",
# Syntax depends on database backends, can be exploited for ReDoS
"regex",
# Syntax depends on database backends, can be exploited for ReDoS
"iregex",
]
SUPPORTED_EXPR_CATEGORIES = { SUPPORTED_EXPR_CATEGORIES = {
CustomField.FieldDataType.STRING: ("basic", "string"), CustomField.FieldDataType.STRING: ("basic", "string"),
CustomField.FieldDataType.URL: ("basic", "string"), CustomField.FieldDataType.URL: ("basic", "string"),
@ -282,7 +261,7 @@ class CustomFieldLookupParser:
CustomField.FieldDataType.BOOL: ("basic",), CustomField.FieldDataType.BOOL: ("basic",),
CustomField.FieldDataType.INT: ("basic", "arithmetic"), CustomField.FieldDataType.INT: ("basic", "arithmetic"),
CustomField.FieldDataType.FLOAT: ("basic", "arithmetic"), CustomField.FieldDataType.FLOAT: ("basic", "arithmetic"),
CustomField.FieldDataType.MONETARY: ("basic", "string"), CustomField.FieldDataType.MONETARY: ("basic", "string", "arithmetic"),
CustomField.FieldDataType.DOCUMENTLINK: ("basic", "containment"), CustomField.FieldDataType.DOCUMENTLINK: ("basic", "containment"),
CustomField.FieldDataType.SELECT: ("basic",), CustomField.FieldDataType.SELECT: ("basic",),
} }
@ -371,7 +350,7 @@ class CustomFieldLookupParser:
elif len(expr) == 3: elif len(expr) == 3:
return self._parse_atom(*expr) return self._parse_atom(*expr)
raise serializers.ValidationError( raise serializers.ValidationError(
[_("Invalid custom field lookup expression")], [_("Invalid custom field query expression")],
) )
@handle_validation_prefix @handle_validation_prefix
@ -416,13 +395,7 @@ class CustomFieldLookupParser:
self._atom_count += 1 self._atom_count += 1
if self._atom_count > self._max_atom_count: if self._atom_count > self._max_atom_count:
raise serializers.ValidationError( raise serializers.ValidationError(
[ [_("Maximum number of query conditions exceeded.")],
_(
"Maximum number of query conditions exceeded. You can raise "
"the limit by setting PAPERLESS_CUSTOM_FIELD_LOOKUP_MAX_ATOMS "
"in your configuration file.",
),
],
) )
custom_field = self._get_custom_field(id_or_name, validation_prefix="0") custom_field = self._get_custom_field(id_or_name, validation_prefix="0")
@ -444,6 +417,11 @@ class CustomFieldLookupParser:
value_field_name = CustomFieldInstance.get_value_field_name( value_field_name = CustomFieldInstance.get_value_field_name(
custom_field.data_type, custom_field.data_type,
) )
if (
custom_field.data_type == CustomField.FieldDataType.MONETARY
and op in self.EXPR_BY_CATEGORY["arithmetic"]
):
value_field_name = "value_monetary_amount"
has_field = Q(custom_fields__field=custom_field) has_field = Q(custom_fields__field=custom_field)
# Our special exists operator. # Our special exists operator.
@ -494,22 +472,6 @@ class CustomFieldLookupParser:
# Check if the operator is supported for the current data_type. # Check if the operator is supported for the current data_type.
supported = False supported = False
for category in self.SUPPORTED_EXPR_CATEGORIES[custom_field.data_type]: for category in self.SUPPORTED_EXPR_CATEGORIES[custom_field.data_type]:
if (
category == "string"
and op in self.STR_EXPR_DISABLED_BY_DEFAULT
and op not in settings.CUSTOM_FIELD_LOOKUP_OPT_IN
):
raise serializers.ValidationError(
[
_(
"{expr!r} is disabled by default because it does not "
"behave consistently across database backends, or can "
"cause security risks. If you understand the implications "
"you may enabled it by adding it to "
"`PAPERLESS_CUSTOM_FIELD_LOOKUP_OPT_IN`.",
).format(expr=op),
],
)
if op in self.EXPR_BY_CATEGORY[category]: if op in self.EXPR_BY_CATEGORY[category]:
supported = True supported = True
break break
@ -527,7 +489,7 @@ class CustomFieldLookupParser:
if not supported: if not supported:
raise serializers.ValidationError( raise serializers.ValidationError(
[ [
_("{data_type} does not support lookup expr {expr!r}.").format( _("{data_type} does not support query expr {expr!r}.").format(
data_type=custom_field.data_type, data_type=custom_field.data_type,
expr=raw_op, expr=raw_op,
), ),
@ -548,7 +510,7 @@ class CustomFieldLookupParser:
custom_field.data_type == CustomField.FieldDataType.DATE custom_field.data_type == CustomField.FieldDataType.DATE
and prefix in self.DATE_COMPONENTS and prefix in self.DATE_COMPONENTS
): ):
# DateField admits lookups in the form of `year__exact`, etc. These take integers. # DateField admits queries in the form of `year__exact`, etc. These take integers.
field = serializers.IntegerField() field = serializers.IntegerField()
elif custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK: elif custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
# We can be more specific here and make sure the value is a list. # We can be more specific here and make sure the value is a list.
@ -610,7 +572,7 @@ class CustomFieldLookupParser:
custom_fields__value_document_ids__isnull=False, custom_fields__value_document_ids__isnull=False,
) )
# First we lookup reverse links from the requested documents. # First we look up reverse links from the requested documents.
links = CustomFieldInstance.objects.filter( links = CustomFieldInstance.objects.filter(
document_id__in=value, document_id__in=value,
field__data_type=CustomField.FieldDataType.DOCUMENTLINK, field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
@ -635,22 +597,14 @@ class CustomFieldLookupParser:
# guard against queries that are too deeply nested # guard against queries that are too deeply nested
self._current_depth += 1 self._current_depth += 1
if self._current_depth > self._max_query_depth: if self._current_depth > self._max_query_depth:
raise serializers.ValidationError( raise serializers.ValidationError([_("Maximum nesting depth exceeded.")])
[
_(
"Maximum nesting depth exceeded. You can raise the limit "
"by setting PAPERLESS_CUSTOM_FIELD_LOOKUP_MAX_DEPTH in "
"your configuration file.",
),
],
)
try: try:
yield yield
finally: finally:
self._current_depth -= 1 self._current_depth -= 1
class CustomFieldLookupFilter(Filter): class CustomFieldQueryFilter(Filter):
def __init__(self, validation_prefix): def __init__(self, validation_prefix):
""" """
A filter that filters documents based on custom field name and value. A filter that filters documents based on custom field name and value.
@ -665,10 +619,10 @@ class CustomFieldLookupFilter(Filter):
if not value: if not value:
return qs return qs
parser = CustomFieldLookupParser( parser = CustomFieldQueryParser(
self._validation_prefix, self._validation_prefix,
max_query_depth=settings.CUSTOM_FIELD_LOOKUP_MAX_DEPTH, max_query_depth=CUSTOM_FIELD_QUERY_MAX_DEPTH,
max_atom_count=settings.CUSTOM_FIELD_LOOKUP_MAX_ATOMS, max_atom_count=CUSTOM_FIELD_QUERY_MAX_ATOMS,
) )
q, annotations = parser.parse(value) q, annotations = parser.parse(value)
@ -722,7 +676,7 @@ class DocumentFilterSet(FilterSet):
exclude=True, exclude=True,
) )
custom_field_lookup = CustomFieldLookupFilter("custom_field_lookup") custom_field_query = CustomFieldQueryFilter("custom_field_query")
shared_by__id = SharedByUser() shared_by__id = SharedByUser()

View File

@ -0,0 +1,95 @@
# Generated by Django 5.1.1 on 2024-09-29 16:26
import django.db.models.functions.comparison
import django.db.models.functions.text
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1053_document_page_count"),
]
operations = [
migrations.AddField(
model_name="customfieldinstance",
name="value_monetary_amount",
field=models.GeneratedField(
db_persist=True,
expression=models.Case(
models.When(
then=django.db.models.functions.comparison.Cast(
django.db.models.functions.text.Substr("value_monetary", 1),
output_field=models.DecimalField(
decimal_places=2,
max_digits=65,
),
),
value_monetary__regex="^\\d+",
),
default=django.db.models.functions.comparison.Cast(
django.db.models.functions.text.Substr("value_monetary", 4),
output_field=models.DecimalField(
decimal_places=2,
max_digits=65,
),
),
output_field=models.DecimalField(decimal_places=2, max_digits=65),
),
output_field=models.DecimalField(decimal_places=2, max_digits=65),
),
),
migrations.AlterField(
model_name="savedviewfilterrule",
name="rule_type",
field=models.PositiveIntegerField(
choices=[
(0, "title contains"),
(1, "content contains"),
(2, "ASN is"),
(3, "correspondent is"),
(4, "document type is"),
(5, "is in inbox"),
(6, "has tag"),
(7, "has any tag"),
(8, "created before"),
(9, "created after"),
(10, "created year is"),
(11, "created month is"),
(12, "created day is"),
(13, "added before"),
(14, "added after"),
(15, "modified before"),
(16, "modified after"),
(17, "does not have tag"),
(18, "does not have ASN"),
(19, "title or content contains"),
(20, "fulltext query"),
(21, "more like this"),
(22, "has tags in"),
(23, "ASN greater than"),
(24, "ASN less than"),
(25, "storage path is"),
(26, "has correspondent in"),
(27, "does not have correspondent in"),
(28, "has document type in"),
(29, "does not have document type in"),
(30, "has storage path in"),
(31, "does not have storage path in"),
(32, "owner is"),
(33, "has owner in"),
(34, "does not have owner"),
(35, "does not have owner in"),
(36, "has custom field value"),
(37, "is shared by me"),
(38, "has custom fields"),
(39, "has custom field in"),
(40, "does not have custom field in"),
(41, "does not have custom field"),
(42, "custom fields query"),
],
verbose_name="rule type",
),
),
]

View File

@ -22,6 +22,9 @@ from multiselectfield import MultiSelectField
if settings.AUDIT_LOG_ENABLED: if settings.AUDIT_LOG_ENABLED:
from auditlog.registry import auditlog from auditlog.registry import auditlog
from django.db.models import Case
from django.db.models.functions import Cast
from django.db.models.functions import Substr
from django_softdelete.models import SoftDeleteModel from django_softdelete.models import SoftDeleteModel
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
@ -519,6 +522,7 @@ class SavedViewFilterRule(models.Model):
(39, _("has custom field in")), (39, _("has custom field in")),
(40, _("does not have custom field in")), (40, _("does not have custom field in")),
(41, _("does not have custom field")), (41, _("does not have custom field")),
(42, _("custom fields query")),
] ]
saved_view = models.ForeignKey( saved_view = models.ForeignKey(
@ -921,6 +925,27 @@ class CustomFieldInstance(models.Model):
value_monetary = models.CharField(null=True, max_length=128) value_monetary = models.CharField(null=True, max_length=128)
value_monetary_amount = models.GeneratedField(
expression=Case(
# If the value starts with a number and no currency symbol, use the whole string
models.When(
value_monetary__regex=r"^\d+",
then=Cast(
Substr("value_monetary", 1),
output_field=models.DecimalField(decimal_places=2, max_digits=65),
),
),
# If the value starts with a 3-char currency symbol, use the rest of the string
default=Cast(
Substr("value_monetary", 4),
output_field=models.DecimalField(decimal_places=2, max_digits=65),
),
output_field=models.DecimalField(decimal_places=2, max_digits=65),
),
output_field=models.DecimalField(decimal_places=2, max_digits=65),
db_persist=True,
)
value_document_ids = models.JSONField(null=True) value_document_ids = models.JSONField(null=True)
value_select = models.PositiveSmallIntegerField(null=True) value_select = models.PositiveSmallIntegerField(null=True)

View File

@ -1,11 +1,9 @@
import json import json
import re
from collections.abc import Callable from collections.abc import Callable
from datetime import date from datetime import date
from unittest.mock import Mock from unittest.mock import Mock
from urllib.parse import quote from urllib.parse import quote
import pytest
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@ -13,7 +11,6 @@ from documents.models import CustomField
from documents.models import Document from documents.models import Document
from documents.serialisers import DocumentSerializer from documents.serialisers import DocumentSerializer
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from paperless import settings
class DocumentWrapper: class DocumentWrapper:
@ -31,11 +28,7 @@ class DocumentWrapper:
return self._document.custom_fields.get(field__name=custom_field).value return self._document.custom_fields.get(field__name=custom_field).value
def string_expr_opted_in(op): class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
return op in settings.CUSTOM_FIELD_LOOKUP_OPT_IN
class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@ -111,6 +104,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self._create_document(monetary_field="USD100.00") self._create_document(monetary_field="USD100.00")
self._create_document(monetary_field="USD1.00") self._create_document(monetary_field="USD1.00")
self._create_document(monetary_field="EUR50.00") self._create_document(monetary_field="EUR50.00")
self._create_document(monetary_field="101.00")
# CustomField.FieldDataType.DOCUMENTLINK # CustomField.FieldDataType.DOCUMENTLINK
self._create_document(documentlink_field=None) self._create_document(documentlink_field=None)
@ -188,7 +182,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
"/api/documents/?" "/api/documents/?"
+ "&".join( + "&".join(
( (
f"custom_field_lookup={query_string}", f"custom_field_query={query_string}",
"ordering=archive_serial_number", "ordering=archive_serial_number",
"page=1", "page=1",
f"page_size={len(self.documents)}", f"page_size={len(self.documents)}",
@ -212,7 +206,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
"/api/documents/?" "/api/documents/?"
+ "&".join( + "&".join(
( (
f"custom_field_lookup={query_string}", f"custom_field_query={query_string}",
"ordering=archive_serial_number", "ordering=archive_serial_number",
"page=1", "page=1",
f"page_size={len(self.documents)}", f"page_size={len(self.documents)}",
@ -313,32 +307,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
# ==========================================================# # ==========================================================#
# Expressions for string, URL, and monetary fields # # Expressions for string, URL, and monetary fields #
# ==========================================================# # ==========================================================#
@pytest.mark.skipif(
not string_expr_opted_in("iexact"),
reason="iexact expr is disabled.",
)
def test_iexact(self):
self._assert_query_match_predicate(
["string_field", "iexact", "paperless"],
lambda document: "string_field" in document
and document["string_field"] is not None
and document["string_field"].lower() == "paperless",
)
@pytest.mark.skipif(
not string_expr_opted_in("contains"),
reason="contains expr is disabled.",
)
def test_contains(self):
# WARNING: SQLite treats "contains" as "icontains"!
# You should avoid "contains" unless you know what you are doing!
self._assert_query_match_predicate(
["string_field", "contains", "aper"],
lambda document: "string_field" in document
and document["string_field"] is not None
and "aper" in document["string_field"],
)
def test_icontains(self): def test_icontains(self):
self._assert_query_match_predicate( self._assert_query_match_predicate(
["string_field", "icontains", "aper"], ["string_field", "icontains", "aper"],
@ -347,20 +315,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
and "aper" in document["string_field"].lower(), and "aper" in document["string_field"].lower(),
) )
@pytest.mark.skipif(
not string_expr_opted_in("startswith"),
reason="startswith expr is disabled.",
)
def test_startswith(self):
# WARNING: SQLite treats "startswith" as "istartswith"!
# You should avoid "startswith" unless you know what you are doing!
self._assert_query_match_predicate(
["string_field", "startswith", "paper"],
lambda document: "string_field" in document
and document["string_field"] is not None
and document["string_field"].startswith("paper"),
)
def test_istartswith(self): def test_istartswith(self):
self._assert_query_match_predicate( self._assert_query_match_predicate(
["string_field", "istartswith", "paper"], ["string_field", "istartswith", "paper"],
@ -369,20 +323,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
and document["string_field"].lower().startswith("paper"), and document["string_field"].lower().startswith("paper"),
) )
@pytest.mark.skipif(
not string_expr_opted_in("endswith"),
reason="endswith expr is disabled.",
)
def test_endswith(self):
# WARNING: SQLite treats "endswith" as "iendswith"!
# You should avoid "endswith" unless you know what you are doing!
self._assert_query_match_predicate(
["string_field", "iendswith", "less"],
lambda document: "string_field" in document
and document["string_field"] is not None
and document["string_field"].lower().endswith("less"),
)
def test_iendswith(self): def test_iendswith(self):
self._assert_query_match_predicate( self._assert_query_match_predicate(
["string_field", "iendswith", "less"], ["string_field", "iendswith", "less"],
@ -391,32 +331,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
and document["string_field"].lower().endswith("less"), and document["string_field"].lower().endswith("less"),
) )
@pytest.mark.skipif(
not string_expr_opted_in("regex"),
reason="regex expr is disabled.",
)
def test_regex(self):
# WARNING: the regex syntax is database dependent!
self._assert_query_match_predicate(
["string_field", "regex", r"^p.+s$"],
lambda document: "string_field" in document
and document["string_field"] is not None
and re.match(r"^p.+s$", document["string_field"]),
)
@pytest.mark.skipif(
not string_expr_opted_in("iregex"),
reason="iregex expr is disabled.",
)
def test_iregex(self):
# WARNING: the regex syntax is database dependent!
self._assert_query_match_predicate(
["string_field", "iregex", r"^p.+s$"],
lambda document: "string_field" in document
and document["string_field"] is not None
and re.match(r"^p.+s$", document["string_field"], re.IGNORECASE),
)
def test_url_field_istartswith(self): def test_url_field_istartswith(self):
# URL fields supports all of the expressions above. # URL fields supports all of the expressions above.
# Just showing one of them here. # Just showing one of them here.
@ -427,28 +341,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
and document["url_field"].startswith("http://"), and document["url_field"].startswith("http://"),
) )
@pytest.mark.skipif(
not string_expr_opted_in("iregex"),
reason="regex expr is disabled.",
)
def test_monetary_field_iregex(self):
# Monetary fields supports all of the expressions above.
# Just showing one of them here.
#
# Unfortunately we can't do arithmetic comparisons on monetary field,
# but you are welcome to use regex to do some of that.
# E.g., USD between 100.00 and 999.99:
self._assert_query_match_predicate(
["monetary_field", "regex", r"USD[1-9][0-9]{2}\.[0-9]{2}"],
lambda document: "monetary_field" in document
and document["monetary_field"] is not None
and re.match(
r"USD[1-9][0-9]{2}\.[0-9]{2}",
document["monetary_field"],
re.IGNORECASE,
),
)
# ==========================================================# # ==========================================================#
# Arithmetic comparisons # # Arithmetic comparisons #
# ==========================================================# # ==========================================================#
@ -502,6 +394,17 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
and document["date_field"].year >= 2024, and document["date_field"].year >= 2024,
) )
def test_gt_monetary(self):
self._assert_query_match_predicate(
["monetary_field", "gt", "99"],
lambda document: "monetary_field" in document
and document["monetary_field"] is not None
and (
document["monetary_field"] == "USD100.00" # With currency symbol
or document["monetary_field"] == "101.00" # No currency symbol
),
)
# ==========================================================# # ==========================================================#
# Subset check (document link field only) # # Subset check (document link field only) #
# ==========================================================# # ==========================================================#
@ -586,68 +489,57 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
def test_invalid_json(self): def test_invalid_json(self):
self._assert_validation_error( self._assert_validation_error(
"not valid json", "not valid json",
["custom_field_lookup"], ["custom_field_query"],
"must be valid JSON", "must be valid JSON",
) )
def test_invalid_expression(self): def test_invalid_expression(self):
self._assert_validation_error( self._assert_validation_error(
json.dumps("valid json but not valid expr"), json.dumps("valid json but not valid expr"),
["custom_field_lookup"], ["custom_field_query"],
"Invalid custom field lookup expression", "Invalid custom field query expression",
) )
def test_invalid_custom_field_name(self): def test_invalid_custom_field_name(self):
self._assert_validation_error( self._assert_validation_error(
json.dumps(["invalid name", "iexact", "foo"]), json.dumps(["invalid name", "iexact", "foo"]),
["custom_field_lookup", "0"], ["custom_field_query", "0"],
"is not a valid custom field", "is not a valid custom field",
) )
def test_invalid_operator(self): def test_invalid_operator(self):
self._assert_validation_error( self._assert_validation_error(
json.dumps(["integer_field", "iexact", "foo"]), json.dumps(["integer_field", "iexact", "foo"]),
["custom_field_lookup", "1"], ["custom_field_query", "1"],
"does not support lookup expr", "does not support query expr",
) )
def test_invalid_value(self): def test_invalid_value(self):
self._assert_validation_error( self._assert_validation_error(
json.dumps(["select_field", "exact", "not an option"]), json.dumps(["select_field", "exact", "not an option"]),
["custom_field_lookup", "2"], ["custom_field_query", "2"],
"integer", "integer",
) )
def test_invalid_logical_operator(self): def test_invalid_logical_operator(self):
self._assert_validation_error( self._assert_validation_error(
json.dumps(["invalid op", ["integer_field", "gt", 0]]), json.dumps(["invalid op", ["integer_field", "gt", 0]]),
["custom_field_lookup", "0"], ["custom_field_query", "0"],
"Invalid logical operator", "Invalid logical operator",
) )
def test_invalid_expr_list(self): def test_invalid_expr_list(self):
self._assert_validation_error( self._assert_validation_error(
json.dumps(["AND", "not a list"]), json.dumps(["AND", "not a list"]),
["custom_field_lookup", "1"], ["custom_field_query", "1"],
"Invalid expression list", "Invalid expression list",
) )
def test_invalid_operator_prefix(self): def test_invalid_operator_prefix(self):
self._assert_validation_error( self._assert_validation_error(
json.dumps(["integer_field", "foo__gt", 0]), json.dumps(["integer_field", "foo__gt", 0]),
["custom_field_lookup", "1"], ["custom_field_query", "1"],
"does not support lookup expr", "does not support query expr",
)
@pytest.mark.skipif(
string_expr_opted_in("regex"),
reason="user opted into allowing regex expr",
)
def test_disabled_operator(self):
self._assert_validation_error(
json.dumps(["string_field", "regex", r"^p.+s$"]),
["custom_field_lookup", "1"],
"disabled by default",
) )
def test_query_too_deep(self): def test_query_too_deep(self):
@ -656,7 +548,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
query = ["NOT", query] query = ["NOT", query]
self._assert_validation_error( self._assert_validation_error(
json.dumps(query), json.dumps(query),
["custom_field_lookup", *(["1"] * 10)], ["custom_field_query", *(["1"] * 10)],
"Maximum nesting depth exceeded", "Maximum nesting depth exceeded",
) )
@ -665,6 +557,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
query = ["AND", [atom for _ in range(21)]] query = ["AND", [atom for _ in range(21)]]
self._assert_validation_error( self._assert_validation_error(
json.dumps(query), json.dumps(query),
["custom_field_lookup", "1", "20"], ["custom_field_query", "1", "20"],
"Maximum number of query conditions exceeded", "Maximum number of query conditions exceeded",
) )

View File

@ -1195,20 +1195,3 @@ EMAIL_ENABLE_GPG_DECRYPTOR: Final[bool] = __get_boolean(
# Soft Delete # # Soft Delete #
############################################################################### ###############################################################################
EMPTY_TRASH_DELAY = max(__get_int("PAPERLESS_EMPTY_TRASH_DELAY", 30), 1) EMPTY_TRASH_DELAY = max(__get_int("PAPERLESS_EMPTY_TRASH_DELAY", 30), 1)
###############################################################################
# custom_field_lookup Filter Settings #
###############################################################################
CUSTOM_FIELD_LOOKUP_OPT_IN = __get_list(
"PAPERLESS_CUSTOM_FIELD_LOOKUP_OPT_IN",
default=[],
)
CUSTOM_FIELD_LOOKUP_MAX_DEPTH = __get_int(
"PAPERLESS_CUSTOM_FIELD_LOOKUP_MAX_DEPTH",
default=10,
)
CUSTOM_FIELD_LOOKUP_MAX_ATOMS = __get_int(
"PAPERLESS_CUSTOM_FIELD_LOOKUP_MAX_ATOMS",
default=20,
)