]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: custom fields queries (#7761)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Thu, 3 Oct 2024 00:15:42 +0000 (17:15 -0700)
committerGitHub <noreply@github.com>
Thu, 3 Oct 2024 00:15:42 +0000 (00:15 +0000)
26 files changed:
docs/api.md
paperless.conf.example
src-ui/messages.xlf
src-ui/src/app/app.module.ts
src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html [new file with mode: 0644]
src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/document-link/document-link.component.html
src-ui/src/app/components/common/input/document-link/document-link.component.ts
src-ui/src/app/components/document-list/document-list.component.html
src-ui/src/app/components/document-list/document-list.component.ts
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
src-ui/src/app/data/custom-field-query.ts [new file with mode: 0644]
src-ui/src/app/data/filter-rule-type.ts
src-ui/src/app/utils/custom-field-query-element.spec.ts [new file with mode: 0644]
src-ui/src/app/utils/custom-field-query-element.ts [new file with mode: 0644]
src-ui/src/app/utils/query-params.spec.ts
src-ui/src/app/utils/query-params.ts
src/documents/filters.py
src/documents/migrations/1054_customfieldinstance_value_monetary_amount_and_more.py [new file with mode: 0644]
src/documents/models.py
src/documents/tests/test_api_filter_by_custom_fields.py
src/paperless/settings.py

index bf9e886593d416d2285bffa3bb07a51ef13bcece..e5da43a5cfd8aea835f449955f6a3f500402c739 100644 (file)
@@ -278,39 +278,39 @@ attribute with various information about the search results:
 ### 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
@@ -320,22 +320,6 @@ including `gt` (>), `gte` (>=), `lt` (<), `lte` (<=), and `range`.
 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.
index 5fabbf390d5fd515d30c7088a19597a9a4a23dcb..63ee7be22397cb9743ea2e76ecebd73054bcc0c1 100644 (file)
@@ -81,7 +81,6 @@
 #PAPERLESS_THUMBNAIL_FONT_NAME=
 #PAPERLESS_IGNORE_DATES=
 #PAPERLESS_ENABLE_UPDATE_CHECK=
-#PAPERLESS_ALLOW_CUSTOM_FIELD_LOOKUP=iexact,contains,startswith,endswith,regex,iregex
 
 # Tika settings
 
index 9b588ac6ba8d8988f4c5876ee0fb7f5add83c1d5..3fffe4f6e401747bd209b952beb27d4e35233c2c 100644 (file)
         </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 &amp; 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) =&gt; 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) =&gt; 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) =&gt; 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) =&gt; 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) =&gt; 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">
index 005de5369285bac0ca8bcb85bccb49e8029d4332..93c458ae0318dc878b3de2ea63c5aeec987eade5 100644 (file)
@@ -108,6 +108,7 @@ import { FileDropComponent } from './components/file-drop/file-drop.component'
 import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
 import { 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'
@@ -141,6 +142,7 @@ import {
   arrowRightShort,
   arrowUpRight,
   asterisk,
+  braces,
   bodyText,
   boxArrowUp,
   boxArrowUpRight,
@@ -198,6 +200,7 @@ import {
   link,
   listTask,
   listUl,
+  nodePlus,
   pencil,
   people,
   peopleFill,
@@ -227,6 +230,7 @@ import {
   uiRadios,
   upcScan,
   x,
+  xCircle,
   xLg,
 } from 'ngx-bootstrap-icons'
 
@@ -242,6 +246,7 @@ const icons = {
   arrowRightShort,
   arrowUpRight,
   asterisk,
+  braces,
   bodyText,
   boxArrowUp,
   boxArrowUpRight,
@@ -299,6 +304,7 @@ const icons = {
   link,
   listTask,
   listUl,
+  nodePlus,
   pencil,
   people,
   peopleFill,
@@ -328,6 +334,7 @@ const icons = {
   uiRadios,
   upcScan,
   x,
+  xCircle,
   xLg,
 }
 
@@ -485,6 +492,7 @@ function initializeApp(settings: SettingsService) {
     CustomFieldsComponent,
     CustomFieldEditDialogComponent,
     CustomFieldsDropdownComponent,
+    CustomFieldsQueryDropdownComponent,
     ProfileEditDialogComponent,
     DocumentLinkComponent,
     PreviewPopupComponent,
diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html
new file mode 100644 (file)
index 0000000..9da2886
--- /dev/null
@@ -0,0 +1,163 @@
+<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
+  <button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
+    <i-bs name="{{icon}}"></i-bs>
+    <div class="d-none d-sm-inline">&nbsp;{{title}}</div>
+    @if (isActive) {
+      <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
+    }
+  </button>
+  <div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
+    <div class="list-group list-group-flush">
+      @for (element of selectionModel.queries; track element.id; let i = $index) {
+        <div class="list-group-item px-0 d-flex flex-nowrap">
+          @switch (element.type) {
+            @case (CustomFieldQueryComponentType.Atom) {
+              <ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
+            }
+            @case (CustomFieldQueryComponentType.Expression) {
+              <ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
+            }
+          }
+        </div>
+      }
+    </div>
+  </div>
+</div>
+
+<ng-template #comparisonValueTemplate let-atom="atom">
+  @if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
+    <input class="form-control" placeholder="yyyy-mm-dd"
+      [(ngModel)]="atom.value"
+      ngbDatepicker
+      #d="ngbDatepicker" />
+    <button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button">
+      <i-bs name="calendar-event"></i-bs>
+    </button>
+  } @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
+    <input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [disabled]="disabled">
+  } @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
+    <select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
+      <option value="true" i18n>True</option>
+      <option value="false" i18n>False</option>
+    </select>
+  } @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Select) {
+    <ng-select
+      class="paperless-input-select rounded-end"
+      [items]="getSelectOptionsForField(atom.field)"
+      [(ngModel)]="atom.value"
+      [disabled]="disabled"
+      (mousedown)="$event.stopImmediatePropagation()"
+    ></ng-select>
+  } @else {
+    <input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
+  }
+</ng-template>
+
+<ng-template #queryAtom let-atom="atom">
+  <div class="input-group input-group-sm">
+    <ng-select
+      class="paperless-input-select"
+      [items]="customFields"
+      [(ngModel)]="atom.field"
+      [disabled]="disabled"
+      bindLabel="name"
+      bindValue="id"
+      (mousedown)="$event.stopImmediatePropagation()"
+    ></ng-select>
+    <select class="w-25 form-select" [(ngModel)]="atom.operator" [disabled]="disabled">
+      <option *ngFor="let operator of getOperatorsForField(atom.field)" [ngValue]="operator.value">{{operator.label}}</option>
+    </select>
+    @switch (atom.operator) {
+      @case (CustomFieldQueryOperator.Exists) {
+        <select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
+          <option value="true" i18n>True</option>
+          <option value="false" i18n>False</option>
+        </select>
+      }
+      @case (CustomFieldQueryOperator.IsNull) {
+        <select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
+          <option value="true" i18n>True</option>
+          <option value="false" i18n>False</option>
+        </select>
+      }
+      @case (CustomFieldQueryOperator.GreaterThanOrEqual) {
+        <ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
+      }
+      @case (CustomFieldQueryOperator.LessThanOrEqual) {
+        <ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
+      }
+      @case (CustomFieldQueryOperator.GreaterThan) {
+        <ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
+      }
+      @case (CustomFieldQueryOperator.LessThan) {
+        <ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
+      }
+      @case (CustomFieldQueryOperator.Contains) {
+        <pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true"></pngx-input-document-link>
+      }
+      @case (CustomFieldQueryOperator.In) {
+        <ng-select
+          class="paperless-input-select rounded-end"
+          [items]="getSelectOptionsForField(atom.field)"
+          [(ngModel)]="atom.value"
+          [disabled]="disabled"
+          [multiple]="true"
+          (mousedown)="$event.stopImmediatePropagation()"
+        ></ng-select>
+      }
+      @case (CustomFieldQueryOperator.Exact) {
+        <ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
+      }
+      @default {
+        <input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
+      }
+    }
+    <button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled">
+      <i-bs name="x-circle"></i-bs>
+    </button>
+  </div>
+</ng-template>
+
+<ng-template #queryExpression let-expression="expression">
+  <div class="d-flex w-100">
+    <div class="d-flex flex-grow-1 flex-column">
+      <div class="btn-group btn-group-xs" role="group">
+        <input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorOr_{{expression.id}}" name="logicalOperatorOr_{{expression.id}}" value="OR" [disabled]="expression.depth > 0 && expression.value.length < 2">
+        <label class="btn btn-outline-primary" for="logicalOperatorOr_{{expression.id}}" i18n>Any</label>
+        <input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorAnd_{{expression.id}}" name="logicalOperatorAnd_{{expression.id}}" value="AND" [disabled]="expression.depth > 0 && expression.value.length < 2">
+        <label class="btn btn-outline-primary" for="logicalOperatorAnd_{{expression.id}}" i18n>All</label>
+        @if (expression.negatable)  {
+          <input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorNot_{{expression.id}}" name="logicalOperatorNot_{{expression.id}}" value="NOT">
+          <label class="btn btn-outline-secondary" for="logicalOperatorNot_{{expression.id}}" i18n>Not</label>
+        }
+      </div>
+      <div class="list-group list-group-flush mb-n2">
+        @for (element of expression.value; track element.id; let i = $index) {
+          <div class="list-group-item px-0 d-flex flex-nowrap">
+            @switch (element.type) {
+              @case (CustomFieldQueryComponentType.Atom) {
+                <ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
+              }
+              @case (CustomFieldQueryComponentType.Expression) {
+                <ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
+              }
+            }
+          </div>
+        }
+      </div>
+    </div>
+    <div class="btn-group-vertical ms-2 ps-2 border-start" role="group" aria-label="Vertical button group">
+      <button type="button" class="btn btn-sm btn-outline-secondary text-primary" title="Add query" i18n-title (click)="addAtom(expression)" [disabled]="disabled || expression.value.length === CUSTOM_FIELD_QUERY_MAX_ATOMS">
+        <i-bs name="node-plus"></i-bs>
+      </button>
+      <button type="button" class="btn btn-sm btn-outline-secondary text-primary" title="Add expression" i18n-title (click)="addExpression(expression)" [disabled]="disabled || expression.depth === CUSTOM_FIELD_QUERY_MAX_DEPTH">
+        <i-bs name="braces"></i-bs>
+      </button>
+      @if (expression.depth > 0) {
+        <button type="button" class="btn btn-sm btn-outline-secondary text-danger" (click)="removeElement(expression)" [disabled]="disabled">
+          <i-bs name="x-circle"></i-bs>
+        </button>
+      }
+    </div>
+  </div>
+</ng-template>
diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.scss b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.scss
new file mode 100644 (file)
index 0000000..a10c465
--- /dev/null
@@ -0,0 +1,43 @@
+.dropdown-menu {
+  width: 370px;
+  @media(min-width: 768px) {
+    width: 600px;
+  }
+}
+
+::ng-deep .ng-select-container {
+  border-top-right-radius: 0 !important;
+  border-bottom-right-radius: 0 !important;
+  height: 100% !important;
+}
+
+::ng-deep .rounded-end .ng-select-container {
+  border-top-right-radius: var(--bs-border-radius) !important;
+  border-bottom-right-radius: var(--bs-border-radius) !important;
+  border-top-left-radius: 0 !important;
+  border-bottom-left-radius: 0 !important;
+}
+
+::ng-deep .ng-select {
+  max-width: 100px;
+  min-width: 35%;
+  font-size: 14px;
+}
+
+::ng-deep .doc-link-select {
+  padding-top: 0 !important;
+  border-top-right-radius: var(--bs-border-radius) !important;
+  border-bottom-right-radius: var(--bs-border-radius) !important;
+  background-image: none !important;
+
+  .ng-select-container,
+  .ng-select.ng-select-opened > .ng-select-container {
+    border: none !important;
+    min-height: 34px !important;
+    background: none !important;
+  }
+  .ng-select {
+    max-width: 200px;
+    min-width: 140px;
+  }
+}
diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts
new file mode 100644 (file)
index 0000000..e6199c6
--- /dev/null
@@ -0,0 +1,320 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+  CustomFieldQueriesModel,
+  CustomFieldsQueryDropdownComponent,
+} from './custom-fields-query-dropdown.component'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { of } from 'rxjs'
+import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
+import {
+  CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
+  CustomFieldQueryLogicalOperator,
+  CustomFieldQueryOperatorGroups,
+} from 'src/app/data/custom-field-query'
+import { provideHttpClientTesting } from '@angular/common/http/testing'
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import {
+  CustomFieldQueryExpression,
+  CustomFieldQueryAtom,
+  CustomFieldQueryElement,
+} from 'src/app/utils/custom-field-query-element'
+
+const customFields = [
+  {
+    id: 1,
+    name: 'Test Field',
+    data_type: CustomFieldDataType.String,
+    extra_data: {},
+  },
+  {
+    id: 2,
+    name: 'Test Select Field',
+    data_type: CustomFieldDataType.Select,
+    extra_data: { select_options: ['Option 1', 'Option 2'] },
+  },
+]
+
+describe('CustomFieldsQueryDropdownComponent', () => {
+  let component: CustomFieldsQueryDropdownComponent
+  let fixture: ComponentFixture<CustomFieldsQueryDropdownComponent>
+  let customFieldsService: CustomFieldsService
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [CustomFieldsQueryDropdownComponent],
+      imports: [NgbDropdownModule, NgxBootstrapIconsModule.pick(allIcons)],
+      providers: [
+        provideHttpClient(withInterceptorsFromDi()),
+        provideHttpClientTesting(),
+      ],
+    }).compileComponents()
+
+    customFieldsService = TestBed.inject(CustomFieldsService)
+    jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
+      of({
+        count: customFields.length,
+        all: customFields.map((f) => f.id),
+        results: customFields,
+      })
+    )
+    fixture = TestBed.createComponent(CustomFieldsQueryDropdownComponent)
+    component = fixture.componentInstance
+    component.icon = 'ui-radios'
+    fixture.detectChanges()
+  })
+
+  it('should initialize custom fields on creation', () => {
+    expect(component.customFields).toEqual(customFields)
+  })
+
+  it('should add an expression when opened if queries are empty', () => {
+    component.selectionModel.clear()
+    component.onOpenChange(true)
+    expect(component.selectionModel.queries.length).toBe(1)
+  })
+
+  it('should support reset the selection model', () => {
+    component.selectionModel.addExpression()
+    component.reset()
+    expect(component.selectionModel.isEmpty()).toBeTruthy()
+  })
+
+  it('should get operators for a field', () => {
+    const field: CustomField = {
+      id: 1,
+      name: 'Test Field',
+      data_type: CustomFieldDataType.String,
+      extra_data: {},
+    }
+    component.customFields = [field]
+    const operators = component.getOperatorsForField(1)
+    expect(operators.length).toEqual(
+      [
+        ...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
+          CustomFieldQueryOperatorGroups.Basic
+        ],
+        ...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
+          CustomFieldQueryOperatorGroups.String
+        ],
+      ].length
+    )
+
+    // Fallback to basic operators if field is not found
+    const operators2 = component.getOperatorsForField(2)
+    expect(operators2.length).toEqual(
+      CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
+        CustomFieldQueryOperatorGroups.Basic
+      ].length
+    )
+  })
+
+  it('should get select options for a field', () => {
+    const field: CustomField = {
+      id: 1,
+      name: 'Test Field',
+      data_type: CustomFieldDataType.Select,
+      extra_data: { select_options: ['Option 1', 'Option 2'] },
+    }
+    component.customFields = [field]
+    const options = component.getSelectOptionsForField(1)
+    expect(options).toEqual(['Option 1', 'Option 2'])
+
+    // Fallback to empty array if field is not found
+    const options2 = component.getSelectOptionsForField(2)
+    expect(options2).toEqual([])
+  })
+
+  it('should remove an element from the selection model', () => {
+    const expression = new CustomFieldQueryExpression()
+    const atom = new CustomFieldQueryAtom()
+    ;(expression.value as CustomFieldQueryElement[]).push(atom)
+    component.selectionModel.addExpression(expression)
+    component.removeElement(atom)
+    expect(component.selectionModel.isEmpty()).toBeTruthy()
+    const expression2 = new CustomFieldQueryExpression([
+      CustomFieldQueryLogicalOperator.And,
+      [
+        [1, 'icontains', 'test'],
+        [2, 'icontains', 'test'],
+      ],
+    ])
+    component.selectionModel.addExpression(expression2)
+    component.removeElement(expression2)
+    expect(component.selectionModel.isEmpty()).toBeTruthy()
+  })
+
+  it('should emit selectionModelChange when model changes', () => {
+    const nextSpy = jest.spyOn(component.selectionModelChange, 'next')
+    const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
+    component.selectionModel.addAtom(atom)
+    atom.changed.next(atom)
+    expect(nextSpy).toHaveBeenCalled()
+  })
+
+  it('should complete selection model subscription when new selection model is set', () => {
+    const completeSpy = jest.spyOn(component.selectionModel.changed, 'complete')
+    const selectionModel = new CustomFieldQueriesModel()
+    component.selectionModel = selectionModel
+    expect(completeSpy).toHaveBeenCalled()
+  })
+
+  it('should support adding an atom', () => {
+    const expression = new CustomFieldQueryExpression()
+    component.addAtom(expression)
+    expect(expression.value.length).toBe(1)
+  })
+
+  it('should support adding an expression', () => {
+    const expression = new CustomFieldQueryExpression()
+    component.addExpression(expression)
+    expect(expression.value.length).toBe(1)
+  })
+
+  it('should support getting a custom field by ID', () => {
+    expect(component.getCustomFieldByID(1)).toEqual(customFields[0])
+  })
+
+  it('should sanitize name from title', () => {
+    component.title = 'Test Title'
+    expect(component.name).toBe('test_title')
+  })
+
+  describe('CustomFieldQueriesModel', () => {
+    let model: CustomFieldQueriesModel
+
+    beforeEach(() => {
+      model = new CustomFieldQueriesModel()
+    })
+
+    it('should initialize with empty queries', () => {
+      expect(model.queries).toEqual([])
+    })
+
+    it('should clear queries and fire event', () => {
+      const nextSpy = jest.spyOn(model.changed, 'next')
+      model.addExpression()
+      model.clear()
+      expect(model.queries).toEqual([])
+      expect(nextSpy).toHaveBeenCalledWith(model)
+    })
+
+    it('should clear queries without firing event', () => {
+      const nextSpy = jest.spyOn(model.changed, 'next')
+      model.addExpression()
+      model.clear(false)
+      expect(model.queries).toEqual([])
+      expect(nextSpy).not.toHaveBeenCalled()
+    })
+
+    it('should validate an empty model as invalid', () => {
+      expect(model.isValid()).toBeFalsy()
+    })
+
+    it('should validate a model with valid expression as valid', () => {
+      const expression = new CustomFieldQueryExpression()
+      const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
+      const atom2 = new CustomFieldQueryAtom([2, 'icontains', 'test'])
+      const expression2 = new CustomFieldQueryExpression()
+      expression2.addAtom(atom)
+      expression2.addAtom(atom2)
+      expression.addExpression(expression2)
+      model.addExpression(expression)
+      expect(model.isValid()).toBeTruthy()
+    })
+
+    it('should validate a model with invalid expression as invalid', () => {
+      const expression = new CustomFieldQueryExpression()
+      model.addExpression(expression)
+      expect(model.isValid()).toBeFalsy()
+    })
+
+    it('should validate an atom with in or contains operator', () => {
+      const atom = new CustomFieldQueryAtom([1, 'in', '[1,2,3]'])
+      expect(model['validateAtom'].apply(null, [atom])).toBeTruthy()
+      atom.operator = 'contains'
+      atom.value = [1, 2, 3]
+      expect(model['validateAtom'].apply(null, [atom])).toBeTruthy()
+      atom.value = null
+      expect(model['validateAtom'].apply(null, [atom])).toBeFalsy()
+    })
+
+    it('should check if model is empty', () => {
+      expect(model.isEmpty()).toBeTruthy()
+      model.addExpression()
+      expect(model.isEmpty()).toBeTruthy()
+      const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
+      model.addAtom(atom)
+      expect(model.isEmpty()).toBeFalsy()
+    })
+
+    it('should add an atom to the model', () => {
+      const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
+      model.addAtom(atom)
+      expect(model.queries.length).toBe(1)
+      expect(
+        (model.queries[0] as CustomFieldQueryExpression).value.length
+      ).toBe(1)
+    })
+
+    it('should add an expression to the model, propagate changes', () => {
+      const expression = new CustomFieldQueryExpression()
+      model.addExpression(expression)
+      expect(model.queries.length).toBe(1)
+      const expression2 = new CustomFieldQueryExpression([
+        CustomFieldQueryLogicalOperator.And,
+        [
+          [1, 'icontains', 'test'],
+          [2, 'icontains', 'test'],
+        ],
+      ])
+      model.addExpression(expression2)
+      const nextSpy = jest.spyOn(model.changed, 'next')
+      expression2.changed.next(expression2)
+      expect(nextSpy).toHaveBeenCalled()
+    })
+
+    it('should remove an element from the model', () => {
+      const expression = new CustomFieldQueryExpression([
+        CustomFieldQueryLogicalOperator.And,
+        [
+          [1, 'icontains', 'test'],
+          [2, 'icontains', 'test'],
+        ],
+      ])
+      const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
+      const expression2 = new CustomFieldQueryExpression([
+        CustomFieldQueryLogicalOperator.And,
+        [
+          [3, 'icontains', 'test'],
+          [4, 'icontains', 'test'],
+        ],
+      ])
+      expression.addAtom(atom)
+      expression2.addExpression(expression)
+      model.addExpression(expression2)
+      model.removeElement(atom)
+      expect(model.queries.length).toBe(1)
+      model.removeElement(expression2)
+    })
+
+    it('should fire changed event when an atom changes', () => {
+      const nextSpy = jest.spyOn(model.changed, 'next')
+      const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
+      model.addAtom(atom)
+      atom.changed.next(atom)
+      expect(nextSpy).toHaveBeenCalledWith(model)
+    })
+
+    it('should complete changed subject when element is removed', () => {
+      const expression = new CustomFieldQueryExpression()
+      const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
+      ;(expression.value as CustomFieldQueryElement[]).push(atom)
+      model.addExpression(expression)
+      const completeSpy = jest.spyOn(atom.changed, 'complete')
+      model.removeElement(atom)
+      expect(completeSpy).toHaveBeenCalled()
+    })
+  })
+})
diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts
new file mode 100644 (file)
index 0000000..9239071
--- /dev/null
@@ -0,0 +1,294 @@
+import {
+  Component,
+  EventEmitter,
+  Input,
+  Output,
+  ViewChild,
+} from '@angular/core'
+import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
+import { Subject, first, takeUntil } from 'rxjs'
+import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
+import {
+  CustomFieldQueryElementType,
+  CustomFieldQueryOperator,
+  CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE,
+  CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
+  CustomFieldQueryOperatorGroups,
+  CUSTOM_FIELD_QUERY_OPERATOR_LABELS,
+  CUSTOM_FIELD_QUERY_MAX_DEPTH,
+  CUSTOM_FIELD_QUERY_MAX_ATOMS,
+} from 'src/app/data/custom-field-query'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import {
+  CustomFieldQueryElement,
+  CustomFieldQueryExpression,
+  CustomFieldQueryAtom,
+} from 'src/app/utils/custom-field-query-element'
+import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options'
+
+export class CustomFieldQueriesModel {
+  public queries: CustomFieldQueryElement[] = []
+
+  public readonly changed = new Subject<CustomFieldQueriesModel>()
+
+  public clear(fireEvent = true) {
+    this.queries = []
+    if (fireEvent) {
+      this.changed.next(this)
+    }
+  }
+
+  public isValid(): boolean {
+    return (
+      this.queries.length > 0 &&
+      this.validateExpression(this.queries[0] as CustomFieldQueryExpression)
+    )
+  }
+
+  public isEmpty(): boolean {
+    return (
+      this.queries.length === 0 ||
+      (this.queries.length === 1 && this.queries[0].value.length === 0)
+    )
+  }
+
+  private validateAtom(atom: CustomFieldQueryAtom) {
+    let valid = !!(atom.field && atom.operator && atom.value !== null)
+    if (
+      [
+        CustomFieldQueryOperator.In.valueOf(),
+        CustomFieldQueryOperator.Contains.valueOf(),
+      ].includes(atom.operator) &&
+      atom.value
+    ) {
+      valid = valid && atom.value.length > 0
+    }
+    return valid
+  }
+
+  private validateExpression(expression: CustomFieldQueryExpression) {
+    return (
+      expression.operator &&
+      expression.value.length > 0 &&
+      (expression.value as CustomFieldQueryElement[]).every((e) =>
+        e.type === CustomFieldQueryElementType.Atom
+          ? this.validateAtom(e as CustomFieldQueryAtom)
+          : this.validateExpression(e as CustomFieldQueryExpression)
+      )
+    )
+  }
+
+  public addAtom(atom: CustomFieldQueryAtom) {
+    if (this.queries.length === 0) {
+      this.addExpression()
+    }
+    ;(this.queries[0].value as CustomFieldQueryElement[]).push(atom)
+    atom.changed.subscribe(() => {
+      if (atom.field && atom.operator && atom.value) {
+        this.changed.next(this)
+      }
+    })
+  }
+
+  public addExpression(
+    expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
+  ) {
+    if (this.queries.length > 0) {
+      ;(
+        (this.queries[0] as CustomFieldQueryExpression)
+          .value as CustomFieldQueryElement[]
+      ).push(expression)
+    } else {
+      this.queries.push(expression)
+    }
+    expression.changed.subscribe(() => {
+      this.changed.next(this)
+    })
+  }
+
+  private findElement(
+    queryElement: CustomFieldQueryElement,
+    elements: any[]
+  ): CustomFieldQueryElement {
+    for (let i = 0; i < elements.length; i++) {
+      if (elements[i] === queryElement) {
+        return elements.splice(i, 1)[0]
+      } else if (elements[i].type === CustomFieldQueryElementType.Expression) {
+        return this.findElement(
+          queryElement,
+          elements[i].value as CustomFieldQueryElement[]
+        )
+      }
+    }
+  }
+
+  public removeElement(queryElement: CustomFieldQueryElement) {
+    let foundComponent
+    for (let i = 0; i < this.queries.length; i++) {
+      let query = this.queries[i]
+      if (query === queryElement) {
+        foundComponent = this.queries.splice(i, 1)[0]
+        break
+      } else if (query.type === CustomFieldQueryElementType.Expression) {
+        foundComponent = this.findElement(queryElement, query.value as any[])
+      }
+    }
+    if (foundComponent) {
+      foundComponent.changed.complete()
+      if (this.isEmpty()) {
+        this.clear()
+      }
+      this.changed.next(this)
+    }
+  }
+}
+
+@Component({
+  selector: 'pngx-custom-fields-query-dropdown',
+  templateUrl: './custom-fields-query-dropdown.component.html',
+  styleUrls: ['./custom-fields-query-dropdown.component.scss'],
+})
+export class CustomFieldsQueryDropdownComponent {
+  public CustomFieldQueryComponentType = CustomFieldQueryElementType
+  public CustomFieldQueryOperator = CustomFieldQueryOperator
+  public CustomFieldDataType = CustomFieldDataType
+  public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH
+  public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS
+  public popperOptions = popperOptionsReenablePreventOverflow
+
+  @Input()
+  title: string
+
+  @Input()
+  filterPlaceholder: string = ''
+
+  @Input()
+  icon: string
+
+  @Input()
+  allowSelectNone: boolean = false
+
+  @Input()
+  editing = false
+
+  @Input()
+  applyOnClose = false
+
+  get name(): string {
+    return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
+  }
+
+  @Input()
+  disabled: boolean = false
+
+  @ViewChild('dropdown') dropdown: NgbDropdown
+
+  private _selectionModel: CustomFieldQueriesModel
+
+  @Input()
+  set selectionModel(model: CustomFieldQueriesModel) {
+    if (this._selectionModel) {
+      this._selectionModel.changed.complete()
+    }
+    model.changed.subscribe(() => {
+      this.onModelChange()
+    })
+    this._selectionModel = model
+  }
+
+  get selectionModel(): CustomFieldQueriesModel {
+    return this._selectionModel
+  }
+
+  private onModelChange() {
+    if (this.selectionModel.isEmpty() || this.selectionModel.isValid()) {
+      this.selectionModelChange.next(this.selectionModel)
+      this.selectionModel.isEmpty() && this.dropdown?.close()
+    }
+  }
+
+  @Output()
+  selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
+
+  customFields: CustomField[] = []
+
+  private unsubscribeNotifier: Subject<any> = new Subject()
+
+  constructor(protected customFieldsService: CustomFieldsService) {
+    this.selectionModel = new CustomFieldQueriesModel()
+    this.getFields()
+    this.reset()
+  }
+
+  ngOnDestroy(): void {
+    this.unsubscribeNotifier.next(this)
+    this.unsubscribeNotifier.complete()
+  }
+
+  public onOpenChange(open: boolean) {
+    if (open && this.selectionModel.queries.length === 0) {
+      this.selectionModel.addExpression()
+    }
+  }
+
+  public get isActive(): boolean {
+    return (
+      (this.selectionModel.queries[0] as CustomFieldQueryExpression)?.value
+        ?.length > 0
+    )
+  }
+
+  private getFields() {
+    this.customFieldsService
+      .listAll()
+      .pipe(first(), takeUntil(this.unsubscribeNotifier))
+      .subscribe((result) => {
+        this.customFields = result.results
+      })
+  }
+
+  public getCustomFieldByID(id: number): CustomField {
+    return this.customFields.find((field) => field.id === id)
+  }
+
+  public addAtom(expression: CustomFieldQueryExpression) {
+    expression.addAtom()
+  }
+
+  public addExpression(expression: CustomFieldQueryExpression) {
+    expression.addExpression()
+  }
+
+  public removeElement(element: CustomFieldQueryElement) {
+    this.selectionModel.removeElement(element)
+  }
+
+  public reset() {
+    this.selectionModel.clear(false)
+    this.selectionModel.changed.next(this.selectionModel)
+  }
+
+  getOperatorsForField(
+    fieldID: number
+  ): Array<{ value: string; label: string }> {
+    const field = this.customFields.find((field) => field.id === fieldID)
+    const groups: CustomFieldQueryOperatorGroups[] = field
+      ? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
+      : [CustomFieldQueryOperatorGroups.Basic]
+    const operators = groups.flatMap(
+      (group) => CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[group]
+    )
+    return operators.map((operator) => ({
+      value: operator,
+      label: CUSTOM_FIELD_QUERY_OPERATOR_LABELS[operator],
+    }))
+  }
+
+  getSelectOptionsForField(fieldID: number): string[] {
+    const field = this.customFields.find((field) => field.id === fieldID)
+    if (field) {
+      return field.extra_data['select_options']
+    }
+    return []
+  }
+}
index a8ecce4e6c75cdb18e06c0f7fd7168069e8dd8a7..94f4f21b4912834cb3549c84cb6bec09380b170f 100644 (file)
@@ -1,50 +1,57 @@
-<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>&nbsp;<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>&nbsp;<span>{{document.title}}</span>
-              </a>
-            </div>
-          </ng-template>
-          <ng-template ng-loadingspinner-tmp>
-            <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
-            <div class="visually-hidden" i18n>Loading...</div>
-          </ng-template>
-          <ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
-            <div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
-          </ng-template>
-        </ng-select>
+@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>&nbsp;<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>&nbsp;<span>{{document.title}}</span>
+        </a>
+      </div>
+    </ng-template>
+    <ng-template ng-loadingspinner-tmp>
+      <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
+      <div class="visually-hidden" i18n>Loading...</div>
+    </ng-template>
+    <ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
+      <div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
+    </ng-template>
+  </ng-select>
+</ng-template>
index 83a6a742ef51f42d9541453c0643cc452fd4e6f0..882aacad557eeedf6c9002de474d12a443f914ea 100644 (file)
@@ -46,6 +46,12 @@ export class DocumentLinkComponent
   @Input()
   parentDocumentID: number
 
+  @Input()
+  minimal: boolean = false
+
+  @Input()
+  placeholder: string = $localize`Search for documents`
+
   constructor(private documentsService: DocumentService) {
     super()
   }
index e70f4c710a1488ec717ad0621ce5c21f58f1a100..4eb9d179e3dc9972f838e18da704ede240e21ddf 100644 (file)
   } @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)"
index 03c2501825a10f0c49f7a7b2b4fdcaa1ab2df510..75d80d659421bf9ac6efbe4f725db7b2a1e5cc59 100644 (file)
@@ -383,10 +383,6 @@ export class DocumentListComponent
     ])
   }
 
-  trackByDocumentId(index, item: Document) {
-    return item.id
-  }
-
   get notesEnabled(): boolean {
     return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
   }
index 99ef0cdc74155064db99b138ce66ab13490b7340..39e51a1232c3e1613970d00cfa6bb857fbd08c53 100644 (file)
         }
 
         @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
index 5b95d44072cfe59bc391897f51965784332d6c8a..19a53f76c6c70f758618879d8d5fa9ea4cbf9104 100644 (file)
@@ -17,7 +17,7 @@ import {
   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,
@@ -55,6 +55,7 @@ import {
   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'
@@ -95,6 +96,12 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
 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[] = [
   {
@@ -181,6 +188,7 @@ describe('FilterEditorComponent', () => {
         ToggleableDropdownButtonComponent,
         DatesDropdownComponent,
         CustomDatePipe,
+        CustomFieldsQueryDropdownComponent,
       ],
       imports: [
         RouterModule,
@@ -190,6 +198,7 @@ describe('FilterEditorComponent', () => {
         NgbDatepickerModule,
         NgxBootstrapIconsModule.pick(allIcons),
         NgbTypeaheadModule,
+        NgSelectModule,
       ],
       providers: [
         FilterPipe,
@@ -838,108 +847,79 @@ describe('FilterEditorComponent', () => {
     ]
   }))
 
-  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(() => {
@@ -1453,71 +1433,37 @@ describe('FilterEditorComponent', () => {
     ])
   }))
 
-  it('should convert user input to correct filter rules on custom field select not assigned', fakeAsync(() => {
-    const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
-      By.directive(FilterableDropdownComponent)
-    )[4]
-    customFieldsFilterableDropdown.triggerEventHandler('opened')
-    const customFieldButton = customFieldsFilterableDropdown.queryAll(
-      By.directive(ToggleableDropdownButtonComponent)
-    )[0]
-    customFieldButton.triggerEventHandler('toggle')
-    fixture.detectChanges()
-    expect(component.filterRules).toEqual([
-      {
-        rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
-        value: 'false',
-      },
-    ])
-  }))
-
   it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => {
-    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']],
+        ]),
       },
     ])
   }))
@@ -1930,21 +1876,11 @@ describe('FilterEditorComponent', () => {
 
     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 = [
       {
index fe1f6cc8cac6524f3f4d5901d9b701d74bf677d5..24ef1b3472810d5b663dfd432614f89073f434bc 100644 (file)
@@ -12,7 +12,7 @@ import {
 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,
@@ -62,7 +62,7 @@ import {
   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,
@@ -92,6 +92,15 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
 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'
@@ -225,15 +234,8 @@ export class FilterEditorComponent
             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}`
@@ -321,7 +323,7 @@ export class FilterEditorComponent
   correspondentSelectionModel = new FilterableDropdownSelectionModel()
   documentTypeSelectionModel = new FilterableDropdownSelectionModel()
   storagePathSelectionModel = new FilterableDropdownSelectionModel()
-  customFieldSelectionModel = new FilterableDropdownSelectionModel()
+  customFieldQueriesModel = new CustomFieldQueriesModel()
 
   dateCreatedBefore: string
   dateCreatedAfter: string
@@ -356,7 +358,7 @@ export class FilterEditorComponent
     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
@@ -523,34 +525,45 @@ export class FilterEditorComponent
             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:
@@ -768,34 +781,14 @@ export class FilterEditorComponent
           })
         })
     }
-    if (this.customFieldSelectionModel.isNoneSelected()) {
+    let queries = this.customFieldQueriesModel.queries.map((query) =>
+      query.serialize()
+    )
+    if (queries.length > 0) {
       filterRules.push({
-        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({
@@ -1079,10 +1072,6 @@ export class FilterEditorComponent
     this.storagePathSelectionModel.apply()
   }
 
-  onCustomFieldsDropdownOpen() {
-    this.customFieldSelectionModel.apply()
-  }
-
   updateTextFilter(text, updateRules = true) {
     this._textFilter = text
     if (updateRules) {
diff --git a/src-ui/src/app/data/custom-field-query.ts b/src-ui/src/app/data/custom-field-query.ts
new file mode 100644 (file)
index 0000000..226a106
--- /dev/null
@@ -0,0 +1,127 @@
+import { CustomFieldDataType } from './custom-field'
+
+export enum CustomFieldQueryLogicalOperator {
+  And = 'AND',
+  Or = 'OR',
+  Not = 'NOT',
+}
+
+export enum CustomFieldQueryOperator {
+  Exact = 'exact',
+  In = 'in',
+  IsNull = 'isnull',
+  Exists = 'exists',
+  Contains = 'contains',
+  IContains = 'icontains',
+  GreaterThan = 'gt',
+  GreaterThanOrEqual = 'gte',
+  LessThan = 'lt',
+  LessThanOrEqual = 'lte',
+  Range = 'range',
+}
+
+export const CUSTOM_FIELD_QUERY_OPERATOR_LABELS = {
+  [CustomFieldQueryOperator.Exact]: $localize`Equal to`,
+  [CustomFieldQueryOperator.In]: $localize`In`,
+  [CustomFieldQueryOperator.IsNull]: $localize`Is null`,
+  [CustomFieldQueryOperator.Exists]: $localize`Exists`,
+  [CustomFieldQueryOperator.Contains]: $localize`Contains`,
+  [CustomFieldQueryOperator.IContains]: $localize`Contains (case-insensitive)`,
+  [CustomFieldQueryOperator.GreaterThan]: $localize`Greater than`,
+  [CustomFieldQueryOperator.GreaterThanOrEqual]: $localize`Greater than or equal to`,
+  [CustomFieldQueryOperator.LessThan]: $localize`Less than`,
+  [CustomFieldQueryOperator.LessThanOrEqual]: $localize`Less than or equal to`,
+  [CustomFieldQueryOperator.Range]: $localize`Range`,
+}
+
+export enum CustomFieldQueryOperatorGroups {
+  Basic = 'basic',
+  String = 'string',
+  Arithmetic = 'arithmetic',
+  Containment = 'containment',
+  Subset = 'subset',
+  Date = 'date',
+}
+
+// Modified from filters.py > SUPPORTED_EXPR_OPERATORS
+export const CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP = {
+  [CustomFieldQueryOperatorGroups.Basic]: [
+    CustomFieldQueryOperator.Exists,
+    CustomFieldQueryOperator.IsNull,
+    CustomFieldQueryOperator.Exact,
+  ],
+  [CustomFieldQueryOperatorGroups.String]: [CustomFieldQueryOperator.IContains],
+  [CustomFieldQueryOperatorGroups.Arithmetic]: [
+    CustomFieldQueryOperator.GreaterThan,
+    CustomFieldQueryOperator.GreaterThanOrEqual,
+    CustomFieldQueryOperator.LessThan,
+    CustomFieldQueryOperator.LessThanOrEqual,
+  ],
+  [CustomFieldQueryOperatorGroups.Containment]: [
+    CustomFieldQueryOperator.Contains,
+  ],
+  [CustomFieldQueryOperatorGroups.Subset]: [CustomFieldQueryOperator.In],
+  [CustomFieldQueryOperatorGroups.Date]: [
+    CustomFieldQueryOperator.GreaterThanOrEqual,
+    CustomFieldQueryOperator.LessThanOrEqual,
+  ],
+}
+
+// filters.py > SUPPORTED_EXPR_CATEGORIES
+export const CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE = {
+  [CustomFieldDataType.String]: [
+    CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.String,
+  ],
+  [CustomFieldDataType.Url]: [
+    CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.String,
+  ],
+  [CustomFieldDataType.Date]: [
+    CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.Date,
+  ],
+  [CustomFieldDataType.Boolean]: [CustomFieldQueryOperatorGroups.Basic],
+  [CustomFieldDataType.Integer]: [
+    CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.Arithmetic,
+  ],
+  [CustomFieldDataType.Float]: [
+    CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.Arithmetic,
+  ],
+  [CustomFieldDataType.Monetary]: [
+    CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.String,
+    CustomFieldQueryOperatorGroups.Arithmetic,
+  ],
+  [CustomFieldDataType.DocumentLink]: [
+    CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.Containment,
+  ],
+  [CustomFieldDataType.Select]: [
+    CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.Subset,
+  ],
+}
+
+export const CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR = {
+  [CustomFieldQueryOperator.Exact]: 'string|boolean',
+  [CustomFieldQueryOperator.IsNull]: 'boolean',
+  [CustomFieldQueryOperator.Exists]: 'boolean',
+  [CustomFieldQueryOperator.IContains]: 'string',
+  [CustomFieldQueryOperator.GreaterThanOrEqual]: 'string|number',
+  [CustomFieldQueryOperator.LessThanOrEqual]: 'string|number',
+  [CustomFieldQueryOperator.GreaterThan]: 'number',
+  [CustomFieldQueryOperator.LessThan]: 'number',
+  [CustomFieldQueryOperator.Contains]: 'array',
+  [CustomFieldQueryOperator.In]: 'array',
+}
+
+export const CUSTOM_FIELD_QUERY_MAX_DEPTH = 4
+export const CUSTOM_FIELD_QUERY_MAX_ATOMS = 5
+
+export enum CustomFieldQueryElementType {
+  Atom = 'Atom',
+  Expression = 'Expression',
+}
index 9a87a421c0bc848a6d4a88e100236c1cae739404..1c6b1cdf8551235c5e84da254bbb3889fab6b641 100644 (file)
@@ -55,6 +55,8 @@ export const FILTER_HAS_CUSTOM_FIELDS_ANY = 39
 export const FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS = 40
 export const FILTER_HAS_ANY_CUSTOM_FIELDS = 41
 
+export const FILTER_CUSTOM_FIELDS_QUERY = 42
+
 export const FILTER_RULE_TYPES: FilterRuleType[] = [
   {
     id: FILTER_TITLE,
@@ -317,6 +319,12 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
     multi: false,
     default: true,
   },
+  {
+    id: FILTER_CUSTOM_FIELDS_QUERY,
+    filtervar: 'custom_field_query',
+    datatype: 'string',
+    multi: false,
+  },
 ]
 
 export interface FilterRuleType {
diff --git a/src-ui/src/app/utils/custom-field-query-element.spec.ts b/src-ui/src/app/utils/custom-field-query-element.spec.ts
new file mode 100644 (file)
index 0000000..65be373
--- /dev/null
@@ -0,0 +1,245 @@
+import {
+  CustomFieldQueryElement,
+  CustomFieldQueryAtom,
+  CustomFieldQueryExpression,
+} from './custom-field-query-element'
+import {
+  CustomFieldQueryElementType,
+  CustomFieldQueryLogicalOperator,
+  CustomFieldQueryOperator,
+} from '../data/custom-field-query'
+import { fakeAsync, tick } from '@angular/core/testing'
+
+describe('CustomFieldQueryElement', () => {
+  it('should initialize with correct type and id', () => {
+    const element = new CustomFieldQueryElement(
+      CustomFieldQueryElementType.Atom
+    )
+    expect(element.type).toBe(CustomFieldQueryElementType.Atom)
+    expect(element.id).toBeDefined()
+  })
+
+  it('should trigger changed on operator change', () => {
+    const element = new CustomFieldQueryElement(
+      CustomFieldQueryElementType.Atom
+    )
+    element.changed.subscribe((changedElement) => {
+      expect(changedElement).toBe(element)
+    })
+    element.operator = CustomFieldQueryOperator.Exists
+  })
+
+  it('should trigger changed subject on value change', () => {
+    const element = new CustomFieldQueryElement(
+      CustomFieldQueryElementType.Atom
+    )
+    element.changed.subscribe((changedElement) => {
+      expect(changedElement).toBe(element)
+    })
+    element.value = 'new value'
+  })
+
+  it('should throw error on serialize call', () => {
+    const element = new CustomFieldQueryElement(
+      CustomFieldQueryElementType.Atom
+    )
+    expect(() => element.serialize()).toThrow('Implemented in subclass')
+  })
+})
+
+describe('CustomFieldQueryAtom', () => {
+  it('should initialize with correct field, operator, and value', () => {
+    const atom = new CustomFieldQueryAtom([1, 'operator', 'value'])
+    expect(atom.field).toBe(1)
+    expect(atom.operator).toBe('operator')
+    expect(atom.value).toBe('value')
+  })
+
+  it('should trigger changed subject on field change', () => {
+    const atom = new CustomFieldQueryAtom()
+    atom.changed.subscribe((changedAtom) => {
+      expect(changedAtom).toBe(atom)
+    })
+    atom.field = 2
+  })
+
+  it('should set value to null if operator is not found in CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR', () => {
+    const atom = new CustomFieldQueryAtom()
+    atom.operator = 'nonexistent_operator'
+    expect(atom.value).toBeNull()
+  })
+
+  it('should set value to empty string if new type is string', () => {
+    const atom = new CustomFieldQueryAtom()
+    atom.operator = CustomFieldQueryOperator.IContains
+    expect(atom.value).toBe('')
+  })
+
+  it('should set value to "true" if new type is boolean', () => {
+    const atom = new CustomFieldQueryAtom()
+    atom.operator = CustomFieldQueryOperator.Exists
+    expect(atom.value).toBe('true')
+  })
+
+  it('should set value to empty array if new type is array', () => {
+    const atom = new CustomFieldQueryAtom()
+    atom.operator = CustomFieldQueryOperator.In
+    expect(atom.value).toEqual([])
+  })
+
+  it('should try to set existing value to number if new type is number', () => {
+    const atom = new CustomFieldQueryAtom()
+    atom.value = '42'
+    atom.operator = CustomFieldQueryOperator.GreaterThan
+    expect(atom.value).toBe('42')
+
+    // fallback to null if value is not parseable
+    atom.value = 'not_a_number'
+    atom.operator = CustomFieldQueryOperator.GreaterThan
+    expect(atom.value).toBeNull()
+  })
+
+  it('should change boolean values to empty string if operator is not boolean', () => {
+    const atom = new CustomFieldQueryAtom()
+    atom.value = 'true'
+    atom.operator = CustomFieldQueryOperator.Exact
+    expect(atom.value).toBe('')
+  })
+
+  it('should serialize correctly', () => {
+    const atom = new CustomFieldQueryAtom([1, 'operator', 'value'])
+    expect(atom.serialize()).toEqual([1, 'operator', 'value'])
+  })
+
+  it('should emit changed on value change after debounce', fakeAsync(() => {
+    const atom = new CustomFieldQueryAtom()
+    const changeSpy = jest.spyOn(atom.changed, 'next')
+    atom.value = 'new value'
+    tick(1000)
+    expect(changeSpy).toHaveBeenCalled()
+  }))
+})
+
+describe('CustomFieldQueryExpression', () => {
+  it('should initialize with default operator and empty value', () => {
+    const expression = new CustomFieldQueryExpression()
+    expect(expression.operator).toBe(CustomFieldQueryLogicalOperator.Or)
+    expect(expression.value).toEqual([])
+  })
+
+  it('should initialize with correct operator and value, propagate changes', () => {
+    const expression = new CustomFieldQueryExpression([
+      CustomFieldQueryLogicalOperator.And,
+      [
+        [1, 'exists', 'true'],
+        [2, 'exists', 'true'],
+      ],
+    ])
+    expect(expression.operator).toBe(CustomFieldQueryLogicalOperator.And)
+    expect(expression.value.length).toBe(2)
+
+    // propagate changes
+    const expressionChangeSpy = jest.spyOn(expression.changed, 'next')
+    ;(expression.value[0] as CustomFieldQueryAtom).changed.next(
+      expression.value[0] as any
+    )
+    expect(expressionChangeSpy).toHaveBeenCalled()
+
+    const expression2 = new CustomFieldQueryExpression([
+      CustomFieldQueryLogicalOperator.Not,
+      [[CustomFieldQueryLogicalOperator.Or, []]],
+    ])
+    const expressionChangeSpy2 = jest.spyOn(expression2.changed, 'next')
+    ;(expression2.value[0] as CustomFieldQueryExpression).changed.next(
+      expression2.value[0] as any
+    )
+    expect(expressionChangeSpy2).toHaveBeenCalled()
+  })
+
+  it('should initialize with a sub-expression i.e. NOT', () => {
+    const expression = new CustomFieldQueryExpression([
+      CustomFieldQueryLogicalOperator.Not,
+      [
+        'AND',
+        [
+          [1, 'exists', 'true'],
+          [2, 'exists', 'true'],
+        ],
+      ],
+    ])
+    expect(expression.value).toHaveLength(1)
+    const changedSpy = jest.spyOn(expression.changed, 'next')
+    ;(expression.value[0] as CustomFieldQueryExpression).changed.next(
+      expression.value[0] as any
+    )
+    expect(changedSpy).toHaveBeenCalled()
+  })
+
+  it('should add atom correctly, propagate changes', () => {
+    const expression = new CustomFieldQueryExpression()
+    const atom = new CustomFieldQueryAtom([
+      1,
+      CustomFieldQueryOperator.Exists,
+      'true',
+    ])
+    expression.addAtom(atom)
+    expect(expression.value).toContain(atom)
+    const changeSpy = jest.spyOn(expression.changed, 'next')
+    atom.changed.next(atom)
+    expect(changeSpy).toHaveBeenCalled()
+    // coverage
+    expression.addAtom()
+  })
+
+  it('should add expression correctly, propagate changes', () => {
+    const expression = new CustomFieldQueryExpression()
+    const subExpression = new CustomFieldQueryExpression([
+      CustomFieldQueryLogicalOperator.Or,
+      [],
+    ])
+    expression.addExpression(subExpression)
+    expect(expression.value).toContain(subExpression)
+    const changeSpy = jest.spyOn(expression.changed, 'next')
+    subExpression.changed.next(subExpression)
+    expect(changeSpy).toHaveBeenCalled()
+    // coverage
+    expression.addExpression()
+  })
+
+  it('should serialize correctly', () => {
+    const expression = new CustomFieldQueryExpression([
+      CustomFieldQueryLogicalOperator.And,
+      [[1, 'exists', 'true']],
+    ])
+    expect(expression.serialize()).toEqual([
+      CustomFieldQueryLogicalOperator.And,
+      [[1, 'exists', 'true']],
+    ])
+  })
+
+  it('should serialize NOT expressions correctly', () => {
+    const expression = new CustomFieldQueryExpression()
+    expression.addExpression(
+      new CustomFieldQueryExpression([
+        CustomFieldQueryLogicalOperator.And,
+        [
+          [1, 'exists', 'true'],
+          [2, 'exists', 'true'],
+        ],
+      ])
+    )
+    expression.operator = CustomFieldQueryLogicalOperator.Not
+    const serialized = expression.serialize()
+    expect(serialized[0]).toBe(CustomFieldQueryLogicalOperator.Not)
+    expect(serialized[1][0]).toBe(CustomFieldQueryLogicalOperator.And)
+    expect(serialized[1][1].length).toBe(2)
+  })
+
+  it('should be negatable if it has one child which is an expression', () => {
+    const expression = new CustomFieldQueryExpression([
+      CustomFieldQueryLogicalOperator.Not,
+      [[CustomFieldQueryLogicalOperator.Or, []]],
+    ])
+    expect(expression.negatable).toBe(true)
+  })
+})
diff --git a/src-ui/src/app/utils/custom-field-query-element.ts b/src-ui/src/app/utils/custom-field-query-element.ts
new file mode 100644 (file)
index 0000000..696853f
--- /dev/null
@@ -0,0 +1,210 @@
+import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
+import { v4 as uuidv4 } from 'uuid'
+import {
+  CustomFieldQueryElementType,
+  CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR,
+  CustomFieldQueryLogicalOperator,
+  CustomFieldQueryOperator,
+} from '../data/custom-field-query'
+
+export class CustomFieldQueryElement {
+  public readonly type: CustomFieldQueryElementType
+  public changed: Subject<CustomFieldQueryElement>
+  protected valueModelChanged: Subject<
+    string | string[] | number[] | CustomFieldQueryElement[]
+  >
+  public depth: number = 0
+  public id: string = uuidv4()
+
+  constructor(type: CustomFieldQueryElementType) {
+    this.type = type
+    this.changed = new Subject<CustomFieldQueryElement>()
+    this.valueModelChanged = new Subject<string | CustomFieldQueryElement[]>()
+    this.connectValueModelChanged()
+  }
+
+  protected connectValueModelChanged() {
+    // Allows overriding in subclasses
+    this.valueModelChanged.subscribe(() => {
+      this.changed.next(this)
+    })
+  }
+
+  public serialize() {
+    throw new Error('Implemented in subclass')
+  }
+
+  protected _operator: string = null
+  public set operator(value: string) {
+    this._operator = value
+    this.changed.next(this)
+  }
+  public get operator(): string {
+    return this._operator
+  }
+
+  protected _value: string | string[] | number[] | CustomFieldQueryElement[] =
+    null
+  public set value(
+    value: string | string[] | number[] | CustomFieldQueryElement[]
+  ) {
+    this._value = value
+    this.valueModelChanged.next(value)
+  }
+  public get value(): string | string[] | number[] | CustomFieldQueryElement[] {
+    return this._value
+  }
+}
+
+export class CustomFieldQueryAtom extends CustomFieldQueryElement {
+  protected _field: number
+  set field(field: any) {
+    this._field = parseInt(field, 10)
+    this.changed.next(this)
+  }
+  get field(): number {
+    return this._field
+  }
+
+  override set operator(operator: string) {
+    const newTypes: string[] =
+      CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR[operator]?.split('|')
+    if (!newTypes) {
+      this.value = null
+    } else {
+      if (!newTypes.includes(typeof this.value)) {
+        switch (newTypes[0]) {
+          case 'string':
+            this.value = ''
+            break
+          case 'boolean':
+            this.value = 'true'
+            break
+          case 'array':
+            this.value = []
+            break
+          case 'number':
+            const num = parseFloat(this.value as string)
+            this.value = isNaN(num) ? null : num.toString()
+            break
+        }
+      } else if (
+        ['true', 'false'].includes(this.value as string) &&
+        newTypes.includes('string')
+      ) {
+        this.value = ''
+      }
+    }
+    super.operator = operator
+  }
+
+  override get operator(): string {
+    // why?
+    return super.operator
+  }
+
+  constructor(queryArray: [number, string, string] = [null, null, null]) {
+    super(CustomFieldQueryElementType.Atom)
+    ;[this._field, this._operator, this._value] = queryArray
+  }
+
+  protected override connectValueModelChanged(): void {
+    this.valueModelChanged
+      .pipe(debounceTime(1000), distinctUntilChanged())
+      .subscribe(() => {
+        this.changed.next(this)
+      })
+  }
+
+  public override serialize() {
+    return [this._field, this._operator, this._value]
+  }
+}
+
+export class CustomFieldQueryExpression extends CustomFieldQueryElement {
+  protected _value: string[] | number[] | CustomFieldQueryElement[]
+
+  constructor(
+    expressionArray: [CustomFieldQueryLogicalOperator, any[]] = [
+      CustomFieldQueryLogicalOperator.Or,
+      null,
+    ]
+  ) {
+    super(CustomFieldQueryElementType.Expression)
+    let values
+    ;[this._operator, values] = expressionArray
+    if (!values || values.length === 0) {
+      this._value = []
+    } else if (values?.length > 0 && values[0] instanceof Array) {
+      this._value = values.map((value) => {
+        if (value.length === 3) {
+          const atom = new CustomFieldQueryAtom(value)
+          atom.depth = this.depth + 1
+          atom.changed.subscribe(() => {
+            this.changed.next(this)
+          })
+          return atom
+        } else {
+          const expression = new CustomFieldQueryExpression(value)
+          expression.depth = this.depth + 1
+          expression.changed.subscribe(() => {
+            this.changed.next(this)
+          })
+          return expression
+        }
+      })
+    } else {
+      const expression = new CustomFieldQueryExpression(values as any)
+      expression.depth = this.depth + 1
+      expression.changed.subscribe(() => {
+        this.changed.next(this)
+      })
+      this._value = [expression]
+    }
+  }
+
+  public override serialize() {
+    let value
+    value = this._value.map((element) => element.serialize())
+    // If the expression is negated it should have only one child which is an expression
+    if (
+      this._operator === CustomFieldQueryLogicalOperator.Not &&
+      value.length === 1
+    ) {
+      value = value[0]
+    }
+    return [this._operator, value]
+  }
+
+  public addAtom(
+    atom: CustomFieldQueryAtom = new CustomFieldQueryAtom([
+      null,
+      CustomFieldQueryOperator.Exists,
+      'true',
+    ])
+  ) {
+    atom.depth = this.depth + 1
+    ;(this._value as CustomFieldQueryElement[]).push(atom)
+    atom.changed.subscribe(() => {
+      this.changed.next(this)
+    })
+  }
+
+  public addExpression(
+    expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
+  ) {
+    expression.depth = this.depth + 1
+    ;(this._value as CustomFieldQueryElement[]).push(expression)
+    expression.changed.subscribe(() => {
+      this.changed.next(this)
+    })
+  }
+
+  public get negatable(): boolean {
+    return (
+      this.value.length === 1 &&
+      (this.value[0] as CustomFieldQueryElement).type ===
+        CustomFieldQueryElementType.Expression
+    )
+  }
+}
index a1bc0cdcd0079eb44d38bb0a2882ca2bee357b40..64a89efecfcda561f6906f8fbf30807a64853837 100644 (file)
@@ -2,13 +2,17 @@ import { convertToParamMap } from '@angular/router'
 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[] = [
@@ -193,4 +197,58 @@ describe('QueryParams Utils', () => {
       },
     ])
   })
+
+  it('should transform legacy filter rules', () => {
+    let filterRules: FilterRule[] = [
+      {
+        rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
+        value: '1',
+      },
+      {
+        rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
+        value: '2',
+      },
+    ]
+
+    let transformedFilterRules = transformLegacyFilterRules(filterRules)
+
+    expect(transformedFilterRules).toEqual([
+      {
+        rule_type: FILTER_CUSTOM_FIELDS_QUERY,
+        value: JSON.stringify([
+          CustomFieldQueryLogicalOperator.Or,
+          [
+            [1, 'exists', true],
+            [2, 'exists', true],
+          ],
+        ]),
+      },
+    ])
+
+    filterRules = [
+      {
+        rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
+        value: '3',
+      },
+      {
+        rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
+        value: '4',
+      },
+    ]
+
+    transformedFilterRules = transformLegacyFilterRules(filterRules)
+
+    expect(transformedFilterRules).toEqual([
+      {
+        rule_type: FILTER_CUSTOM_FIELDS_QUERY,
+        value: JSON.stringify([
+          CustomFieldQueryLogicalOperator.And,
+          [
+            [3, 'exists', true],
+            [4, 'exists', true],
+          ],
+        ]),
+      },
+    ])
+  })
 })
index 1121bd6a396b94207344ef05dfed85063932a822..608d4edfb4374426223a6c141f3ab3c1b28f9a88 100644 (file)
@@ -1,7 +1,17 @@
 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'
@@ -40,6 +50,49 @@ export function paramsToViewState(queryParams: ParamMap): ListViewState {
   }
 }
 
+export function transformLegacyFilterRules(
+  filterRules: FilterRule[]
+): FilterRule[] {
+  const LEGACY_CUSTOM_FIELD_FILTER_RULE_TYPES = [
+    FILTER_HAS_CUSTOM_FIELDS_ANY,
+    FILTER_HAS_CUSTOM_FIELDS_ALL,
+  ]
+  if (
+    filterRules.filter((rule) =>
+      LEGACY_CUSTOM_FIELD_FILTER_RULE_TYPES.includes(rule.rule_type)
+    ).length
+  ) {
+    const anyRules = filterRules.filter(
+      (rule) => rule.rule_type === FILTER_HAS_CUSTOM_FIELDS_ANY
+    )
+    const allRules = filterRules.filter(
+      (rule) => rule.rule_type === FILTER_HAS_CUSTOM_FIELDS_ALL
+    )
+    const customFieldQueryLogicalOperator = allRules.length
+      ? CustomFieldQueryLogicalOperator.And
+      : CustomFieldQueryLogicalOperator.Or
+    const valueRules = allRules.length ? allRules : anyRules
+    const customFieldQueryExpression = [
+      customFieldQueryLogicalOperator,
+      [
+        ...valueRules.map((rule) => [
+          parseInt(rule.value),
+          CustomFieldQueryOperator.Exists,
+          true,
+        ]),
+      ],
+    ]
+    filterRules.push({
+      rule_type: FILTER_CUSTOM_FIELDS_QUERY,
+      value: JSON.stringify(customFieldQueryExpression),
+    })
+  }
+  // TODO: can we support FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS or FILTER_HAS_ANY_CUSTOM_FIELDS?
+  return filterRules.filter(
+    (rule) => !LEGACY_CUSTOM_FIELD_FILTER_RULE_TYPES.includes(rule.rule_type)
+  )
+}
+
 export function filterRulesFromQueryParams(
   queryParams: ParamMap
 ): FilterRule[] {
@@ -77,7 +130,9 @@ export function filterRulesFromQueryParams(
         })
       )
     })
-
+  filterRulesFromQueryParams = transformLegacyFilterRules(
+    filterRulesFromQueryParams
+  )
   return filterRulesFromQueryParams
 }
 
index 25e840141c4e59499a25c5a4bd53f2e8dea6ea3e..f0a9a55b360493ef03d6eada58687bbf1f98f5c7 100644 (file)
@@ -29,13 +29,15 @@ from documents.models import Log
 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:
@@ -234,19 +236,13 @@ def handle_validation_prefix(func: Callable):
     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",
@@ -258,23 +254,6 @@ class CustomFieldLookupParser:
         "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"),
@@ -282,7 +261,7 @@ class CustomFieldLookupParser:
         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",),
     }
@@ -371,7 +350,7 @@ class CustomFieldLookupParser:
                 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
@@ -416,13 +395,7 @@ class CustomFieldLookupParser:
         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")
@@ -444,6 +417,11 @@ class CustomFieldLookupParser:
         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.
@@ -494,22 +472,6 @@ class CustomFieldLookupParser:
         # 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
@@ -527,7 +489,7 @@ class CustomFieldLookupParser:
         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,
                     ),
@@ -548,7 +510,7 @@ class CustomFieldLookupParser:
             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.
@@ -610,7 +572,7 @@ class CustomFieldLookupParser:
                 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,
@@ -635,22 +597,14 @@ class CustomFieldLookupParser:
         # 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.
@@ -665,10 +619,10 @@ class CustomFieldLookupFilter(Filter):
         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)
 
@@ -722,7 +676,7 @@ class DocumentFilterSet(FilterSet):
         exclude=True,
     )
 
-    custom_field_lookup = CustomFieldLookupFilter("custom_field_lookup")
+    custom_field_query = CustomFieldQueryFilter("custom_field_query")
 
     shared_by__id = SharedByUser()
 
diff --git a/src/documents/migrations/1054_customfieldinstance_value_monetary_amount_and_more.py b/src/documents/migrations/1054_customfieldinstance_value_monetary_amount_and_more.py
new file mode 100644 (file)
index 0000000..92d45de
--- /dev/null
@@ -0,0 +1,95 @@
+# Generated by Django 5.1.1 on 2024-09-29 16:26
+
+import django.db.models.functions.comparison
+import django.db.models.functions.text
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("documents", "1053_document_page_count"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="customfieldinstance",
+            name="value_monetary_amount",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        then=django.db.models.functions.comparison.Cast(
+                            django.db.models.functions.text.Substr("value_monetary", 1),
+                            output_field=models.DecimalField(
+                                decimal_places=2,
+                                max_digits=65,
+                            ),
+                        ),
+                        value_monetary__regex="^\\d+",
+                    ),
+                    default=django.db.models.functions.comparison.Cast(
+                        django.db.models.functions.text.Substr("value_monetary", 4),
+                        output_field=models.DecimalField(
+                            decimal_places=2,
+                            max_digits=65,
+                        ),
+                    ),
+                    output_field=models.DecimalField(decimal_places=2, max_digits=65),
+                ),
+                output_field=models.DecimalField(decimal_places=2, max_digits=65),
+            ),
+        ),
+        migrations.AlterField(
+            model_name="savedviewfilterrule",
+            name="rule_type",
+            field=models.PositiveIntegerField(
+                choices=[
+                    (0, "title contains"),
+                    (1, "content contains"),
+                    (2, "ASN is"),
+                    (3, "correspondent is"),
+                    (4, "document type is"),
+                    (5, "is in inbox"),
+                    (6, "has tag"),
+                    (7, "has any tag"),
+                    (8, "created before"),
+                    (9, "created after"),
+                    (10, "created year is"),
+                    (11, "created month is"),
+                    (12, "created day is"),
+                    (13, "added before"),
+                    (14, "added after"),
+                    (15, "modified before"),
+                    (16, "modified after"),
+                    (17, "does not have tag"),
+                    (18, "does not have ASN"),
+                    (19, "title or content contains"),
+                    (20, "fulltext query"),
+                    (21, "more like this"),
+                    (22, "has tags in"),
+                    (23, "ASN greater than"),
+                    (24, "ASN less than"),
+                    (25, "storage path is"),
+                    (26, "has correspondent in"),
+                    (27, "does not have correspondent in"),
+                    (28, "has document type in"),
+                    (29, "does not have document type in"),
+                    (30, "has storage path in"),
+                    (31, "does not have storage path in"),
+                    (32, "owner is"),
+                    (33, "has owner in"),
+                    (34, "does not have owner"),
+                    (35, "does not have owner in"),
+                    (36, "has custom field value"),
+                    (37, "is shared by me"),
+                    (38, "has custom fields"),
+                    (39, "has custom field in"),
+                    (40, "does not have custom field in"),
+                    (41, "does not have custom field"),
+                    (42, "custom fields query"),
+                ],
+                verbose_name="rule type",
+            ),
+        ),
+    ]
index 6dae8ba65028933a8b1089d1c0408f0ce8754b5c..80476bffa8c020d82452ebf0b26b90d0c2ef526e 100644 (file)
@@ -22,6 +22,9 @@ from multiselectfield import MultiSelectField
 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
@@ -519,6 +522,7 @@ class SavedViewFilterRule(models.Model):
         (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(
@@ -921,6 +925,27 @@ class CustomFieldInstance(models.Model):
 
     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)
index 0f7da0a6115651726a8553efced6a611d4d7df44..c9a0cdcfc4c68dba6d96cbc6f809e9cd127dc747 100644 (file)
@@ -1,11 +1,9 @@
 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
 
@@ -13,7 +11,6 @@ from documents.models import CustomField
 from documents.models import Document
 from documents.serialisers import DocumentSerializer
 from documents.tests.utils import DirectoriesMixin
-from paperless import settings
 
 
 class DocumentWrapper:
@@ -31,11 +28,7 @@ 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()
 
@@ -111,6 +104,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
         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)
@@ -188,7 +182,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
             "/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)}",
@@ -212,7 +206,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
             "/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)}",
@@ -313,32 +307,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
     # ==========================================================#
     # 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"],
@@ -347,20 +315,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
             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"],
@@ -369,20 +323,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
             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"],
@@ -391,32 +331,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
             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.
@@ -427,28 +341,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
             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                                    #
     # ==========================================================#
@@ -502,6 +394,17 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
             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)                   #
     # ==========================================================#
@@ -586,68 +489,57 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
     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):
@@ -656,7 +548,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
             query = ["NOT", query]
         self._assert_validation_error(
             json.dumps(query),
-            ["custom_field_lookup", *(["1"] * 10)],
+            ["custom_field_query", *(["1"] * 10)],
             "Maximum nesting depth exceeded",
         )
 
@@ -665,6 +557,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
         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",
         )
index 023e826c9505cc119c90fd07f31b6b01166dd28c..ab943f30f660301c709236b9d84c80fd675210a7 100644 (file)
@@ -1195,20 +1195,3 @@ EMAIL_ENABLE_GPG_DECRYPTOR: Final[bool] = __get_boolean(
 # 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,
-)