### Filtering by custom fields
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:
1. Documents with a custom field "due" (date) between Aug 1, 2024 and
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"
(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`:
- `?custom_field_lookup=["answered", "exact", true]`
+ `?custom_field_query=["answered", "exact", true]`
4. Documents with a custom field "favorite animal" (select) set to either
"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:
- `?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":
- `?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:
- `?custom_field_lookup=["references", "contains", [3, 7]]`
+ `?custom_field_query=["references", "contains", [3, 7]]`
All field types support basic operations including `exact`, `in`, `isnull`,
and `exists`. String, URL, and monetary fields support case-insensitive
Lastly, document link fields support a `contains` operator that behaves
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/`
Get auto completions for a partial search term.
#PAPERLESS_THUMBNAIL_FONT_NAME=
#PAPERLESS_IGNORE_DATES=
#PAPERLESS_ENABLE_UPDATE_CHECK=
-#PAPERLESS_ALLOW_CUSTOM_FIELD_LOOKUP=iexact,contains,startswith,endswith,regex,iregex
# Tika settings
</context-group>
<context-group purpose="location">
<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 purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<trans-unit id="8104421162933956065" datatype="html">
</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">110</context>
+ <context context-type="linenumber">105</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">63</context>
</context-group>
</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">
<source>now</source>
<context-group purpose="location">
<context context-type="linenumber">146</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/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">
<source>Include</source>
<context-group purpose="location">
</context-group>
<context-group purpose="location">
<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 purpose="location">
<context context-type="sourcefile">src/app/components/common/input/file/file.component.html</context>
<source>Remove link</source>
<context-group purpose="location">
<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>
</trans-unit>
<trans-unit id="1388712764439031120" datatype="html">
<source>Open link</source>
<context-group purpose="location">
<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 purpose="location">
<context context-type="sourcefile">src/app/components/common/input/url/url.component.html</context>
<context context-type="linenumber">44</context>
</context-group>
</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">
<source>Selected items</source>
<context-group purpose="location">
</context-group>
<context-group purpose="location">
<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 purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
</context-group>
<context-group purpose="location">
<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>
</trans-unit>
<trans-unit id="6475890479659129881" datatype="html">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">83</context>
</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 id="3206542606001340679" datatype="html">
<source>Merge</source>
</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">116</context>
+ <context context-type="linenumber">111</context>
</context-group>
</trans-unit>
<trans-unit id="1559883523769732271" datatype="html">
</context-group>
<context-group purpose="location">
<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 purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
<source>Dates</source>
<context-group purpose="location">
<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>
</trans-unit>
<trans-unit id="3100631071441658964" datatype="html">
<source>Title & content</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">134</context>
+ <context context-type="linenumber">143</context>
</context-group>
</trans-unit>
<trans-unit id="2649431021108393503" datatype="html">
<source>More like</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">149</context>
+ <context context-type="linenumber">158</context>
</context-group>
</trans-unit>
<trans-unit id="3697582909018473071" datatype="html">
<source>equals</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">155</context>
+ <context context-type="linenumber">164</context>
</context-group>
</trans-unit>
<trans-unit id="5325481293405718739" datatype="html">
<source>is empty</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">159</context>
+ <context context-type="linenumber">168</context>
</context-group>
</trans-unit>
<trans-unit id="6166785695326182482" datatype="html">
<source>is not empty</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">163</context>
+ <context context-type="linenumber">172</context>
</context-group>
</trans-unit>
<trans-unit id="4686622206659266699" datatype="html">
<source>greater than</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">167</context>
+ <context context-type="linenumber">176</context>
</context-group>
</trans-unit>
<trans-unit id="8014012170270529279" datatype="html">
<source>less than</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">171</context>
+ <context context-type="linenumber">180</context>
</context-group>
</trans-unit>
<trans-unit id="5195932016807797291" datatype="html">
<source>Correspondent: <x id="PH" equiv-text="this.correspondents.find((c) => c.id == +rule.value)?.name"/></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">191,193</context>
+ <context context-type="linenumber">200,202</context>
</context-group>
</trans-unit>
<trans-unit id="8170755470576301659" datatype="html">
<source>Without correspondent</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">195</context>
+ <context context-type="linenumber">204</context>
</context-group>
</trans-unit>
<trans-unit id="317796810569008208" datatype="html">
<source>Document type: <x id="PH" equiv-text="this.documentTypes.find((dt) => dt.id == +rule.value)?.name"/></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">201,203</context>
+ <context context-type="linenumber">210,212</context>
</context-group>
</trans-unit>
<trans-unit id="4362173610367509215" datatype="html">
<source>Without document type</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">205</context>
+ <context context-type="linenumber">214</context>
</context-group>
</trans-unit>
<trans-unit id="232202047340644471" datatype="html">
<source>Storage path: <x id="PH" equiv-text="this.storagePaths.find((sp) => sp.id == +rule.value)?.name"/></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">211,213</context>
+ <context context-type="linenumber">220,222</context>
</context-group>
</trans-unit>
<trans-unit id="1562820715074533164" datatype="html">
<source>Without storage path</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">215</context>
+ <context context-type="linenumber">224</context>
</context-group>
</trans-unit>
<trans-unit id="8180755793012580465" datatype="html">
<source>Tag: <x id="PH" equiv-text="this.tags.find((t) => t.id == +rule.value)?.name"/></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">219,221</context>
+ <context context-type="linenumber">228,230</context>
</context-group>
</trans-unit>
<trans-unit id="6494566478302448576" datatype="html">
<source>Without any tag</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">225</context>
- </context-group>
- </trans-unit>
- <trans-unit id="6370692707013694620" datatype="html">
- <source>Custom fields: <x id="PH" equiv-text="this.customFields.find((f) => f.id == +rule.value)?.name"/></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">229,231</context>
+ <context context-type="linenumber">234</context>
</context-group>
</trans-unit>
- <trans-unit id="5297600960590041873" datatype="html">
- <source>Without any custom field</source>
+ <trans-unit id="8644099678903817943" datatype="html">
+ <source>Custom fields query</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 context-type="linenumber">238</context>
</context-group>
</trans-unit>
<trans-unit id="6523384805359286307" datatype="html">
<source>Title: <x id="PH" equiv-text="rule.value"/></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">239</context>
+ <context context-type="linenumber">241</context>
</context-group>
</trans-unit>
<trans-unit id="1872523635812236432" datatype="html">
<source>ASN: <x id="PH" equiv-text="rule.value"/></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">242</context>
+ <context context-type="linenumber">244</context>
</context-group>
</trans-unit>
<trans-unit id="102674688969746976" datatype="html">
<source>Owner: <x id="PH" equiv-text="rule.value"/></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">245</context>
+ <context context-type="linenumber">247</context>
</context-group>
</trans-unit>
<trans-unit id="3550877650686009106" datatype="html">
<source>Owner not in: <x id="PH" equiv-text="rule.value"/></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">248</context>
+ <context context-type="linenumber">250</context>
</context-group>
</trans-unit>
<trans-unit id="1082034558646673343" datatype="html">
<source>Without an owner</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">251</context>
+ <context context-type="linenumber">253</context>
</context-group>
</trans-unit>
<trans-unit id="7210076240260527720" datatype="html">
<context context-type="linenumber">9</context>
</context-group>
</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">
<source>Boolean</source>
<context-group purpose="location">
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 { 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 { PdfViewerModule } from 'ng2-pdf-viewer'
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
arrowRightShort,
arrowUpRight,
asterisk,
+ braces,
bodyText,
boxArrowUp,
boxArrowUpRight,
link,
listTask,
listUl,
+ nodePlus,
pencil,
people,
peopleFill,
uiRadios,
upcScan,
x,
+ xCircle,
xLg,
} from 'ngx-bootstrap-icons'
arrowRightShort,
arrowUpRight,
asterisk,
+ braces,
bodyText,
boxArrowUp,
boxArrowUpRight,
link,
listTask,
listUl,
+ nodePlus,
pencil,
people,
peopleFill,
uiRadios,
upcScan,
x,
+ xCircle,
xLg,
}
CustomFieldsComponent,
CustomFieldEditDialogComponent,
CustomFieldsDropdownComponent,
+ CustomFieldsQueryDropdownComponent,
ProfileEditDialogComponent,
DocumentLinkComponent,
PreviewPopupComponent,
--- /dev/null
+<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"> {{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>
--- /dev/null
+.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;
+ }
+}
--- /dev/null
+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()
+ })
+ })
+})
--- /dev/null
+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 []
+ }
+}
-<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
- <div class="row">
- <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
- @if (title) {
- <label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
- }
- @if (removable) {
- <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
- <i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
- </button>
- }
- </div>
- <div [class.col-md-9]="horizontal">
- <div>
- <ng-select name="inputId" [(ngModel)]="selectedDocuments"
- [disabled]="disabled"
- [items]="foundDocuments$ | async"
- placeholder="Search for documents"
- [notFoundText]="notFoundText"
- [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> <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>
+@if (minimal) {
+ <ng-container *ngTemplateOutlet="select"></ng-container>
+} @else {
+ <div class="mb-3 paperless-input-select" [class.disabled]="disabled">
+ <div class="row">
+ <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
+ @if (title) {
+ <label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
+ }
+ @if (removable) {
+ <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+ <i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
+ </button>
+ }
+ </div>
+ <div [class.col-md-9]="horizontal">
+ <ng-container *ngTemplateOutlet="select"></ng-container>
+ @if (hint) {
+ <small class="form-text text-muted">{{hint}}</small>
+ }
</div>
- @if (hint) {
- <small class="form-text text-muted">{{hint}}</small>
- }
</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> <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>
@Input()
parentDocumentID: number
+ @Input()
+ minimal: boolean = false
+
+ @Input()
+ placeholder: string = $localize`Search for documents`
+
constructor(private documentsService: DocumentService) {
super()
}
} @else {
@if (list.displayMode === DisplayMode.LARGE_CARDS) {
<div>
- @for (d of list.documents; track trackByDocumentId($index, d)) {
+ @for (d of list.documents; track d.id) {
<pngx-document-card-large
[selected]="list.isSelected(d)"
(toggleSelected)="toggleSelected(d, $event)"
</tr>
</thead>
<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' : ''">
<td>
<div class="form-check">
}
@if (list.displayMode === DisplayMode.SMALL_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"
[selected]="list.isSelected(d)"
(toggleSelected)="toggleSelected(d, $event)"
])
}
- trackByDocumentId(index, item: Document) {
- return item.id
- }
-
get notesEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
}
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
- <pngx-filterable-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title
- filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
- [items]="customFields"
- [manyToOne]="true"
- [(selectionModel)]="customFieldSelectionModel"
+ <pngx-custom-fields-query-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title
+ [(selectionModel)]="customFieldQueriesModel"
(selectionModelChange)="updateRules()"
- (opened)="onCustomFieldsDropdownOpen()"
- [documentCounts]="customFieldDocumentCounts"
- [allowSelectNone]="true"></pngx-filterable-dropdown>
+ ></pngx-custom-fields-query-dropdown>
}
<pngx-dates-dropdown
title="Dates" i18n-title
NgbDropdownItem,
NgbTypeaheadModule,
} 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 {
FILTER_TITLE,
FILTER_HAS_ANY_CUSTOM_FIELDS,
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
FILTER_HAS_CUSTOM_FIELDS_ALL,
+ FILTER_CUSTOM_FIELDS_QUERY,
} from 'src/app/data/filter-rule-type'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
import { RouterModule } from '@angular/router'
import { SearchService } from 'src/app/services/rest/search.service'
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[] = [
{
ToggleableDropdownButtonComponent,
DatesDropdownComponent,
CustomDatePipe,
+ CustomFieldsQueryDropdownComponent,
],
imports: [
RouterModule,
NgbDatepickerModule,
NgxBootstrapIconsModule.pick(allIcons),
NgbTypeaheadModule,
+ NgSelectModule,
],
providers: [
FilterPipe,
]
}))
- it('should ingest filter rules for has all custom fields', fakeAsync(() => {
- expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
- 0
- )
+ it('should ingest filter rules for custom fields all', fakeAsync(() => {
+ expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
- value: '42',
- },
- {
- rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
- value: '43',
+ value: '42,43',
},
]
- expect(component.customFieldSelectionModel.logicalOperator).toEqual(
- LogicalOperator.And
+ expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
+ CustomFieldQueryLogicalOperator.And
)
- expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
- custom_fields
- )
- // coverage
- component.filterRules = [
- {
- rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
- value: null,
- },
- ]
- component.toggleTag(2) // coverage
+ 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 has any custom fields', fakeAsync(() => {
- expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
- 0
- )
+ expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
- value: '42',
- },
- {
- rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
- value: '43',
+ value: '42,43',
},
]
- expect(component.customFieldSelectionModel.logicalOperator).toEqual(
- LogicalOperator.Or
+ expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
+ CustomFieldQueryLogicalOperator.Or
)
- expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
- custom_fields
- )
- // coverage
- component.filterRules = [
- {
- rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
- value: null,
- },
- ]
+ 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 has any custom field', fakeAsync(() => {
- expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
- 0
- )
+ it('should ingest filter rules for custom field queries', fakeAsync(() => {
+ expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
component.filterRules = [
{
- rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
- value: '1',
+ rule_type: FILTER_CUSTOM_FIELDS_QUERY,
+ value: '["AND", [[42, "exists", "true"],[43, "exists", "true"]]]',
},
]
- expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
- 1
+ expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
+ 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(() => {
- expect(component.customFieldSelectionModel.getExcludedItems()).toHaveLength(
- 0
- )
- component.filterRules = [
- {
- rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
- value: '42',
- },
- {
- 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
+ // atom
component.filterRules = [
{
- rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
- value: null,
+ rule_type: FILTER_CUSTOM_FIELDS_QUERY,
+ value: '[42, "exists", "true"]',
},
]
+ 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 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(() => {
- const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
- By.directive(FilterableDropdownComponent)
- )[4] // CF dropdown
- customFieldsFilterableDropdown.triggerEventHandler('opened')
- const customFieldButtons = customFieldsFilterableDropdown.queryAll(
- By.directive(ToggleableDropdownButtonComponent)
+ const customFieldsQueryDropdown = fixture.debugElement.queryAll(
+ By.directive(CustomFieldsQueryDropdownComponent)
+ )[0]
+ const customFieldToggleButton = customFieldsQueryDropdown.query(
+ By.css('button')
)
- customFieldButtons[1].triggerEventHandler('toggle')
- customFieldButtons[2].triggerEventHandler('toggle')
+ customFieldToggleButton.triggerEventHandler('click')
fixture.detectChanges()
- expect(component.filterRules).toEqual([
- {
- 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]')
+ const customFieldButtons = customFieldsQueryDropdown.queryAll(
+ By.css('button')
)
- toggleOperatorButtons[1].nativeElement.checked = true
- toggleOperatorButtons[1].triggerEventHandler('change')
+ customFieldButtons[1].triggerEventHandler('click')
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([
{
- rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
- value: custom_fields[0].id.toString(),
- },
- {
- 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(),
+ rule_type: FILTER_CUSTOM_FIELDS_QUERY,
+ value: JSON.stringify([
+ CustomFieldQueryLogicalOperator.Or,
+ [[custom_fields[0].id, 'exists', 'true']],
+ ]),
},
])
}))
component.filterRules = [
{
- rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
- value: '42',
- },
- ]
- expect(component.generateFilterName()).toEqual(
- `Custom fields: ${custom_fields[0].name}`
- )
-
- component.filterRules = [
- {
- rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
- value: 'false',
+ rule_type: FILTER_CUSTOM_FIELDS_QUERY,
+ value: '["AND",[["42","exists","true"],["43","exists","true"]]]',
},
]
- expect(component.generateFilterName()).toEqual('Without any custom field')
+ expect(component.generateFilterName()).toEqual(`Custom fields query`)
component.filterRules = [
{
import { Tag } from 'src/app/data/tag'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
-import { Observable, Subject, Subscription, from } from 'rxjs'
+import { Observable, Subject, from } from 'rxjs'
import {
catchError,
debounceTime,
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_ANY_CUSTOM_FIELDS,
- FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
+ FILTER_CUSTOM_FIELDS_QUERY,
} from 'src/app/data/filter-rule-type'
import {
FilterableDropdownSelectionModel,
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField } from 'src/app/data/custom-field'
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_CONTENT = 'title-content'
return $localize`Without any tag`
}
- case FILTER_HAS_CUSTOM_FIELDS_ALL:
- return $localize`Custom fields: ${
- 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_CUSTOM_FIELDS_QUERY:
+ return $localize`Custom fields query`
case FILTER_TITLE:
return $localize`Title: ${rule.value}`
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel()
- customFieldSelectionModel = new FilterableDropdownSelectionModel()
+ customFieldQueriesModel = new CustomFieldQueriesModel()
dateCreatedBefore: string
dateCreatedAfter: string
this.storagePathSelectionModel.clear(false)
this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false)
- this.customFieldSelectionModel.clear(false)
+ this.customFieldQueriesModel.clear(false)
this._textFilter = null
this._moreLikeId = null
this.dateAddedBefore = null
false
)
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:
- this.customFieldSelectionModel.logicalOperator = LogicalOperator.And
- this.customFieldSelectionModel.set(
- rule.value ? +rule.value : null,
- ToggleableItemState.Selected,
- false
+ this.customFieldQueriesModel.addExpression(
+ new CustomFieldQueryExpression([
+ CustomFieldQueryLogicalOperator.And,
+ rule.value
+ .split(',')
+ .map((id) => [id, CustomFieldQueryOperator.Exists, 'true']),
+ ])
)
break
case FILTER_HAS_CUSTOM_FIELDS_ANY:
- this.customFieldSelectionModel.logicalOperator = LogicalOperator.Or
- this.customFieldSelectionModel.set(
- rule.value ? +rule.value : null,
- ToggleableItemState.Selected,
- false
- )
- 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
+ this.customFieldQueriesModel.addExpression(
+ new CustomFieldQueryExpression([
+ CustomFieldQueryLogicalOperator.Or,
+ rule.value
+ .split(',')
+ .map((id) => [id, CustomFieldQueryOperator.Exists, 'true']),
+ ])
)
break
case FILTER_ASN_ISNULL:
})
})
}
- if (this.customFieldSelectionModel.isNoneSelected()) {
+ let queries = this.customFieldQueriesModel.queries.map((query) =>
+ query.serialize()
+ )
+ if (queries.length > 0) {
filterRules.push({
- rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
- value: 'false',
+ rule_type: FILTER_CUSTOM_FIELDS_QUERY,
+ 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) {
filterRules.push({
this.storagePathSelectionModel.apply()
}
- onCustomFieldsDropdownOpen() {
- this.customFieldSelectionModel.apply()
- }
-
updateTextFilter(text, updateRules = true) {
this._textFilter = text
if (updateRules) {
--- /dev/null
+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',
+}
export const FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS = 40
export const FILTER_HAS_ANY_CUSTOM_FIELDS = 41
+export const FILTER_CUSTOM_FIELDS_QUERY = 42
+
export const FILTER_RULE_TYPES: FilterRuleType[] = [
{
id: FILTER_TITLE,
multi: false,
default: true,
},
+ {
+ id: FILTER_CUSTOM_FIELDS_QUERY,
+ filtervar: 'custom_field_query',
+ datatype: 'string',
+ multi: false,
+ },
]
export interface FilterRuleType {
--- /dev/null
+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)
+ })
+})
--- /dev/null
+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
+ )
+ }
+}
import { FilterRule } from '../data/filter-rule'
import {
FILTER_CORRESPONDENT,
+ FILTER_CUSTOM_FIELDS_QUERY,
FILTER_HAS_ANY_TAG,
+ FILTER_HAS_CUSTOM_FIELDS_ALL,
+ FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_TAGS_ALL,
} from '../data/filter-rule-type'
-import { paramsToViewState } from './query-params'
+import { paramsToViewState, transformLegacyFilterRules } from './query-params'
import { paramsFromViewState } from './query-params'
import { queryParamsFromFilterRules } from './query-params'
import { filterRulesFromQueryParams } from './query-params'
+import { CustomFieldQueryLogicalOperator } from '../data/custom-field-query'
const tags__id__all = '9'
const filterRules: FilterRule[] = [
},
])
})
+
+ 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],
+ ],
+ ]),
+ },
+ ])
+ })
})
import { ParamMap, Params } from '@angular/router'
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 {
+ CustomFieldQueryLogicalOperator,
+ CustomFieldQueryOperator,
+} from '../data/custom-field-query'
const SORT_FIELD_PARAMETER = 'sort'
const SORT_REVERSE_PARAMETER = 'reverse'
}
}
+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(
queryParams: ParamMap
): FilterRule[] {
})
)
})
-
+ filterRulesFromQueryParams = transformLegacyFilterRules(
+ filterRulesFromQueryParams
+ )
return filterRulesFromQueryParams
}
from documents.models import ShareLink
from documents.models import StoragePath
from documents.models import Tag
-from paperless import settings
CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"]
ID_KWARGS = ["in", "exact"]
INT_KWARGS = ["exact", "gt", "gte", "lt", "lte", "isnull"]
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 Meta:
return wrapper
-class CustomFieldLookupParser:
+class CustomFieldQueryParser:
EXPR_BY_CATEGORY = {
"basic": ["exact", "in", "isnull", "exists"],
"string": [
- "iexact",
- "contains",
"icontains",
- "startswith",
"istartswith",
- "endswith",
"iendswith",
- "regex",
- "iregex",
],
"arithmetic": [
"gt",
"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 = {
CustomField.FieldDataType.STRING: ("basic", "string"),
CustomField.FieldDataType.URL: ("basic", "string"),
CustomField.FieldDataType.BOOL: ("basic",),
CustomField.FieldDataType.INT: ("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.SELECT: ("basic",),
}
elif len(expr) == 3:
return self._parse_atom(*expr)
raise serializers.ValidationError(
- [_("Invalid custom field lookup expression")],
+ [_("Invalid custom field query expression")],
)
@handle_validation_prefix
self._atom_count += 1
if self._atom_count > self._max_atom_count:
raise serializers.ValidationError(
- [
- _(
- "Maximum number of query conditions exceeded. You can raise "
- "the limit by setting PAPERLESS_CUSTOM_FIELD_LOOKUP_MAX_ATOMS "
- "in your configuration file.",
- ),
- ],
+ [_("Maximum number of query conditions exceeded.")],
)
custom_field = self._get_custom_field(id_or_name, validation_prefix="0")
value_field_name = CustomFieldInstance.get_value_field_name(
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)
# Our special exists operator.
# Check if the operator is supported for the current data_type.
supported = False
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]:
supported = True
break
if not supported:
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,
expr=raw_op,
),
custom_field.data_type == CustomField.FieldDataType.DATE
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()
elif custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
# We can be more specific here and make sure the value is a list.
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(
document_id__in=value,
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
# guard against queries that are too deeply nested
self._current_depth += 1
if self._current_depth > self._max_query_depth:
- raise serializers.ValidationError(
- [
- _(
- "Maximum nesting depth exceeded. You can raise the limit "
- "by setting PAPERLESS_CUSTOM_FIELD_LOOKUP_MAX_DEPTH in "
- "your configuration file.",
- ),
- ],
- )
+ raise serializers.ValidationError([_("Maximum nesting depth exceeded.")])
try:
yield
finally:
self._current_depth -= 1
-class CustomFieldLookupFilter(Filter):
+class CustomFieldQueryFilter(Filter):
def __init__(self, validation_prefix):
"""
A filter that filters documents based on custom field name and value.
if not value:
return qs
- parser = CustomFieldLookupParser(
+ parser = CustomFieldQueryParser(
self._validation_prefix,
- max_query_depth=settings.CUSTOM_FIELD_LOOKUP_MAX_DEPTH,
- max_atom_count=settings.CUSTOM_FIELD_LOOKUP_MAX_ATOMS,
+ max_query_depth=CUSTOM_FIELD_QUERY_MAX_DEPTH,
+ max_atom_count=CUSTOM_FIELD_QUERY_MAX_ATOMS,
)
q, annotations = parser.parse(value)
exclude=True,
)
- custom_field_lookup = CustomFieldLookupFilter("custom_field_lookup")
+ custom_field_query = CustomFieldQueryFilter("custom_field_query")
shared_by__id = SharedByUser()
--- /dev/null
+# 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",
+ ),
+ ),
+ ]
if settings.AUDIT_LOG_ENABLED:
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 documents.data_models import DocumentSource
(39, _("has custom field in")),
(40, _("does not have custom field in")),
(41, _("does not have custom field")),
+ (42, _("custom fields query")),
]
saved_view = models.ForeignKey(
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_select = models.PositiveSmallIntegerField(null=True)
import json
-import re
from collections.abc import Callable
from datetime import date
from unittest.mock import Mock
from urllib.parse import quote
-import pytest
from django.contrib.auth.models import User
from rest_framework.test import APITestCase
from documents.models import Document
from documents.serialisers import DocumentSerializer
from documents.tests.utils import DirectoriesMixin
-from paperless import settings
class DocumentWrapper:
return self._document.custom_fields.get(field__name=custom_field).value
-def string_expr_opted_in(op):
- return op in settings.CUSTOM_FIELD_LOOKUP_OPT_IN
-
-
-class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
+class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
def setUp(self):
super().setUp()
self._create_document(monetary_field="USD100.00")
self._create_document(monetary_field="USD1.00")
self._create_document(monetary_field="EUR50.00")
+ self._create_document(monetary_field="101.00")
# CustomField.FieldDataType.DOCUMENTLINK
self._create_document(documentlink_field=None)
"/api/documents/?"
+ "&".join(
(
- f"custom_field_lookup={query_string}",
+ f"custom_field_query={query_string}",
"ordering=archive_serial_number",
"page=1",
f"page_size={len(self.documents)}",
"/api/documents/?"
+ "&".join(
(
- f"custom_field_lookup={query_string}",
+ f"custom_field_query={query_string}",
"ordering=archive_serial_number",
"page=1",
f"page_size={len(self.documents)}",
# ==========================================================#
# 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):
self._assert_query_match_predicate(
["string_field", "icontains", "aper"],
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):
self._assert_query_match_predicate(
["string_field", "istartswith", "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):
self._assert_query_match_predicate(
["string_field", "iendswith", "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):
# URL fields supports all of the expressions above.
# Just showing one of them here.
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 #
# ==========================================================#
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) #
# ==========================================================#
def test_invalid_json(self):
self._assert_validation_error(
"not valid json",
- ["custom_field_lookup"],
+ ["custom_field_query"],
"must be valid JSON",
)
def test_invalid_expression(self):
self._assert_validation_error(
json.dumps("valid json but not valid expr"),
- ["custom_field_lookup"],
- "Invalid custom field lookup expression",
+ ["custom_field_query"],
+ "Invalid custom field query expression",
)
def test_invalid_custom_field_name(self):
self._assert_validation_error(
json.dumps(["invalid name", "iexact", "foo"]),
- ["custom_field_lookup", "0"],
+ ["custom_field_query", "0"],
"is not a valid custom field",
)
def test_invalid_operator(self):
self._assert_validation_error(
json.dumps(["integer_field", "iexact", "foo"]),
- ["custom_field_lookup", "1"],
- "does not support lookup expr",
+ ["custom_field_query", "1"],
+ "does not support query expr",
)
def test_invalid_value(self):
self._assert_validation_error(
json.dumps(["select_field", "exact", "not an option"]),
- ["custom_field_lookup", "2"],
+ ["custom_field_query", "2"],
"integer",
)
def test_invalid_logical_operator(self):
self._assert_validation_error(
json.dumps(["invalid op", ["integer_field", "gt", 0]]),
- ["custom_field_lookup", "0"],
+ ["custom_field_query", "0"],
"Invalid logical operator",
)
def test_invalid_expr_list(self):
self._assert_validation_error(
json.dumps(["AND", "not a list"]),
- ["custom_field_lookup", "1"],
+ ["custom_field_query", "1"],
"Invalid expression list",
)
def test_invalid_operator_prefix(self):
self._assert_validation_error(
json.dumps(["integer_field", "foo__gt", 0]),
- ["custom_field_lookup", "1"],
- "does not support lookup 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",
+ ["custom_field_query", "1"],
+ "does not support query expr",
)
def test_query_too_deep(self):
query = ["NOT", query]
self._assert_validation_error(
json.dumps(query),
- ["custom_field_lookup", *(["1"] * 10)],
+ ["custom_field_query", *(["1"] * 10)],
"Maximum nesting depth exceeded",
)
query = ["AND", [atom for _ in range(21)]]
self._assert_validation_error(
json.dumps(query),
- ["custom_field_lookup", "1", "20"],
+ ["custom_field_query", "1", "20"],
"Maximum number of query conditions exceeded",
)
# Soft Delete #
###############################################################################
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,
-)