Compare commits

...
Author SHA1 Message Date
shamoon 7b75333819 custom field regex matching
[ci skip]
2025-03-20 23:17:57 -07:00
shamoon 71fdc2a36d limit the frontend algo select too 2025-03-20 23:17:44 -07:00
shamoon dbe58672ed limit matching options in serializer
[ci skip]
2025-03-20 21:54:05 -07:00
shamoon 8a907c2868 Fix some merge stuff
[ci skip]
2025-03-20 21:54:05 -07:00
shamoon 6dc6c6c7bb Add to handler, matching, retagger 2025-03-20 21:54:05 -07:00
shamoon a632b6b711 Add custom fields to classifier 2025-03-20 16:21:34 -07:00
shamoon b8c618abbe Make frontend list a generic management list 2025-03-20 16:21:14 -07:00
shamoon 7a46806643 Migrate to matching model 2025-03-20 16:20:16 -07:00
5d6cfa7349 New Crowdin translations by GitHub Action (#9430)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-03-18 19:17:49 -07:00
shamoon 3105317137 Fix: close email dialog after email send 2025-03-18 19:17:49 -07:00
1456169d7f New Crowdin translations by GitHub Action (#9408)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-03-17 23:36:52 -07:00
shamoonandGitHub 22a6fe5e10 Enhancement: support more 'not assigned' filtering, refactor (#9429) 2025-03-17 23:35:03 -07:00
github-actions[bot]andGitHub a0c1a19263 New Crowdin translations by GitHub Action (#9401) 2025-03-14 10:30:45 -07:00
1f5d1b6f26 New Crowdin translations by GitHub Action (#8929)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-03-14 17:22:24 +00:00
116 changed files with 92507 additions and 61541 deletions
+5 -1
View File
@@ -372,17 +372,19 @@ currently-imported docs. This problem is common enough that there are
tools for it.
```
document_retagger [-h] [-c] [-T] [-t] [-i] [--id-range] [--use-first] [-f]
document_retagger [-h] [-c] [-T] [-t] [-cf] [-i] [--id-range] [--use-first] [-f] [--suggest]
optional arguments:
-c, --correspondent
-T, --tags
-t, --document_type
-s, --storage_path
-cf, --custom_fields
-i, --inbox-only
--id-range
--use-first
-f, --overwrite
--suggest
```
Run this after changing or adding matching rules. It'll loop over all
@@ -408,6 +410,8 @@ to override this behavior and just use the first correspondent or type
it finds. This option does not apply to tags, since any amount of tags
can be applied to a document.
If you want to suggest changes but not apply them, specify `--suggest`.
Finally, `-f` specifies that you wish to overwrite already assigned
correspondents, types and/or tags. The default behavior is to not assign
correspondents and types to documents that have this data already
+117 -111
View File
@@ -1120,7 +1120,7 @@
</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">193</context>
<context context-type="linenumber">190</context>
</context-group>
</trans-unit>
<trans-unit id="8508424367627989968" datatype="html">
@@ -1194,11 +1194,11 @@
</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">104</context>
<context context-type="linenumber">97</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">106</context>
<context context-type="linenumber">101</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
@@ -1793,7 +1793,7 @@
</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">110</context>
<context context-type="linenumber">103</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
@@ -2086,7 +2086,7 @@
</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">164</context>
<context context-type="linenumber">157</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
@@ -2553,15 +2553,15 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">796</context>
<context context-type="linenumber">794</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">829</context>
<context context-type="linenumber">827</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">848</context>
<context context-type="linenumber">846</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
@@ -2986,7 +2986,7 @@
</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">136</context>
<context context-type="linenumber">129</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
@@ -3161,27 +3161,27 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">439</context>
<context context-type="linenumber">437</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">479</context>
<context context-type="linenumber">477</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">517</context>
<context context-type="linenumber">515</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">555</context>
<context context-type="linenumber">553</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">617</context>
<context context-type="linenumber">615</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">750</context>
<context context-type="linenumber">748</context>
</context-group>
</trans-unit>
<trans-unit id="1407560924967345762" datatype="html">
@@ -5177,14 +5177,14 @@
<source>Email sent</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.ts</context>
<context context-type="linenumber">65</context>
<context context-type="linenumber">66</context>
</context-group>
</trans-unit>
<trans-unit id="3742745894977668908" datatype="html">
<source>Error emailing document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.ts</context>
<context context-type="linenumber">69</context>
<context context-type="linenumber">70</context>
</context-group>
</trans-unit>
<trans-unit id="6381578200008167206" datatype="html">
@@ -5246,7 +5246,7 @@
<source>Not assigned</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
<context context-type="linenumber">392</context>
<context context-type="linenumber">81</context>
</context-group>
<note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note>
</trans-unit>
@@ -5254,7 +5254,7 @@
<source>Open <x id="PH" equiv-text="this.title"/> filter</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
<context context-type="linenumber">513</context>
<context context-type="linenumber">554</context>
</context-group>
</trans-unit>
<trans-unit id="7005745151564974365" datatype="html">
@@ -6415,7 +6415,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">384</context>
<context context-type="linenumber">382</context>
</context-group>
<note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note>
</trans-unit>
@@ -6490,7 +6490,7 @@
</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">114</context>
<context context-type="linenumber">107</context>
</context-group>
</trans-unit>
<trans-unit id="1418444397960583910" datatype="html">
@@ -6519,7 +6519,7 @@
</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">117</context>
<context context-type="linenumber">110</context>
</context-group>
</trans-unit>
<trans-unit id="4399672576012609374" datatype="html">
@@ -6562,7 +6562,7 @@
</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">180</context>
<context context-type="linenumber">177</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
@@ -6595,7 +6595,7 @@
</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">37</context>
<context context-type="linenumber">35</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -6603,7 +6603,7 @@
</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">52</context>
<context context-type="linenumber">50</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
@@ -6622,7 +6622,7 @@
</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">52</context>
<context context-type="linenumber">49</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -6630,7 +6630,7 @@
</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">64</context>
<context context-type="linenumber">61</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
@@ -6649,7 +6649,7 @@
</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">67</context>
<context context-type="linenumber">63</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -6657,7 +6657,7 @@
</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">76</context>
<context context-type="linenumber">72</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
@@ -6940,7 +6940,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">752</context>
<context context-type="linenumber">750</context>
</context-group>
</trans-unit>
<trans-unit id="2048798344356757326" datatype="html">
@@ -6951,7 +6951,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">754</context>
<context context-type="linenumber">752</context>
</context-group>
</trans-unit>
<trans-unit id="7295637485862454066" datatype="html">
@@ -6969,7 +6969,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">792</context>
<context context-type="linenumber">790</context>
</context-group>
</trans-unit>
<trans-unit id="2951161989614003846" datatype="html">
@@ -7050,7 +7050,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">826</context>
<context context-type="linenumber">824</context>
</context-group>
</trans-unit>
<trans-unit id="857641176955257111" datatype="html">
@@ -7149,122 +7149,122 @@
<source>Filter correspondents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">38</context>
<context context-type="linenumber">36</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">53</context>
<context context-type="linenumber">51</context>
</context-group>
</trans-unit>
<trans-unit id="2947613869920454977" datatype="html">
<source>Filter document types</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">53</context>
<context context-type="linenumber">50</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">65</context>
<context context-type="linenumber">62</context>
</context-group>
</trans-unit>
<trans-unit id="8816999377397522522" datatype="html">
<source>Filter storage paths</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">68</context>
<context context-type="linenumber">64</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">77</context>
<context context-type="linenumber">73</context>
</context-group>
</trans-unit>
<trans-unit id="9149498548977462220" datatype="html">
<source>Custom fields</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">82</context>
<context context-type="linenumber">77</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">89</context>
<context context-type="linenumber">84</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">188</context>
<context context-type="linenumber">185</context>
</context-group>
</trans-unit>
<trans-unit id="6475890479659129881" datatype="html">
<source>Filter custom fields</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">83</context>
<context context-type="linenumber">78</context>
</context-group>
</trans-unit>
<trans-unit id="5139192806922838657" datatype="html">
<source>Set values</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">93</context>
<context context-type="linenumber">86</context>
</context-group>
</trans-unit>
<trans-unit id="3206542606001340679" datatype="html">
<source>Merge</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">120</context>
<context context-type="linenumber">113</context>
</context-group>
</trans-unit>
<trans-unit id="1015374532025907183" datatype="html">
<source>Include:</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">142</context>
<context context-type="linenumber">135</context>
</context-group>
</trans-unit>
<trans-unit id="1537670659786159738" datatype="html">
<source>Archived files</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">146</context>
<context context-type="linenumber">139</context>
</context-group>
</trans-unit>
<trans-unit id="2520291319362448498" datatype="html">
<source>Original files</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">150</context>
<context context-type="linenumber">143</context>
</context-group>
</trans-unit>
<trans-unit id="8009862506882713059" datatype="html">
<source>Use formatted filename</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">155</context>
<context context-type="linenumber">148</context>
</context-group>
</trans-unit>
<trans-unit id="1215215387232313677" datatype="html">
<source>Error executing bulk operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">288</context>
<context context-type="linenumber">286</context>
</context-group>
</trans-unit>
<trans-unit id="7894972847287473517" datatype="html">
<source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot;</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">376</context>
<context context-type="linenumber">374</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">382</context>
<context context-type="linenumber">380</context>
</context-group>
</trans-unit>
<trans-unit id="8639884465898458690" datatype="html">
<source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot; and &quot;<x id="PH_1" equiv-text="items[1].name"/>&quot;</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">378</context>
<context context-type="linenumber">376</context>
</context-group>
<note priority="1" from="description">This is for messages like &apos;modify &quot;tag1&quot; and &quot;tag2&quot;&apos;</note>
</trans-unit>
@@ -7272,7 +7272,7 @@
<source><x id="PH" equiv-text="list"/> and &quot;<x id="PH_1" equiv-text="items[items.length - 1].name"/>&quot;</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">386,388</context>
<context context-type="linenumber">384,386</context>
</context-group>
<note priority="1" from="description">this is for messages like &apos;modify &quot;tag1&quot;, &quot;tag2&quot; and &quot;tag3&quot;&apos;</note>
</trans-unit>
@@ -7280,14 +7280,14 @@
<source>Confirm tags assignment</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">403</context>
<context context-type="linenumber">401</context>
</context-group>
</trans-unit>
<trans-unit id="6619516195038467207" datatype="html">
<source>This operation will add the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">409</context>
<context context-type="linenumber">407</context>
</context-group>
</trans-unit>
<trans-unit id="1894412783609570695" datatype="html">
@@ -7296,14 +7296,14 @@
)"/> to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">414,416</context>
<context context-type="linenumber">412,414</context>
</context-group>
</trans-unit>
<trans-unit id="7181166515756808573" datatype="html">
<source>This operation will remove the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">422</context>
<context context-type="linenumber">420</context>
</context-group>
</trans-unit>
<trans-unit id="3819792277998068944" datatype="html">
@@ -7312,7 +7312,7 @@
)"/> from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">427,429</context>
<context context-type="linenumber">425,427</context>
</context-group>
</trans-unit>
<trans-unit id="2739066218579571288" datatype="html">
@@ -7323,84 +7323,84 @@
)"/> on <x id="PH_2" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">431,435</context>
<context context-type="linenumber">429,433</context>
</context-group>
</trans-unit>
<trans-unit id="2996713129519325161" datatype="html">
<source>Confirm correspondent assignment</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">472</context>
<context context-type="linenumber">470</context>
</context-group>
</trans-unit>
<trans-unit id="6900893559485781849" datatype="html">
<source>This operation will assign the correspondent &quot;<x id="PH" equiv-text="correspondent.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">474</context>
<context context-type="linenumber">472</context>
</context-group>
</trans-unit>
<trans-unit id="1257522660364398440" datatype="html">
<source>This operation will remove the correspondent from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">476</context>
<context context-type="linenumber">474</context>
</context-group>
</trans-unit>
<trans-unit id="5393409374423140648" datatype="html">
<source>Confirm document type assignment</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">510</context>
<context context-type="linenumber">508</context>
</context-group>
</trans-unit>
<trans-unit id="332180123895325027" datatype="html">
<source>This operation will assign the document type &quot;<x id="PH" equiv-text="documentType.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">512</context>
<context context-type="linenumber">510</context>
</context-group>
</trans-unit>
<trans-unit id="2236642492594872779" datatype="html">
<source>This operation will remove the document type from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">514</context>
<context context-type="linenumber">512</context>
</context-group>
</trans-unit>
<trans-unit id="6386555513013840736" datatype="html">
<source>Confirm storage path assignment</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">548</context>
<context context-type="linenumber">546</context>
</context-group>
</trans-unit>
<trans-unit id="8750527458618415924" datatype="html">
<source>This operation will assign the storage path &quot;<x id="PH" equiv-text="storagePath.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">550</context>
<context context-type="linenumber">548</context>
</context-group>
</trans-unit>
<trans-unit id="60728365335056946" datatype="html">
<source>This operation will remove the storage path from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">552</context>
<context context-type="linenumber">550</context>
</context-group>
</trans-unit>
<trans-unit id="4187352575310415704" datatype="html">
<source>Confirm custom field assignment</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">581</context>
<context context-type="linenumber">579</context>
</context-group>
</trans-unit>
<trans-unit id="7966494636326273856" datatype="html">
<source>This operation will assign the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">587</context>
<context context-type="linenumber">585</context>
</context-group>
</trans-unit>
<trans-unit id="5789455969634598553" datatype="html">
@@ -7409,14 +7409,14 @@
)"/> to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">592,594</context>
<context context-type="linenumber">590,592</context>
</context-group>
</trans-unit>
<trans-unit id="5648572354333199245" datatype="html">
<source>This operation will remove the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">600</context>
<context context-type="linenumber">598</context>
</context-group>
</trans-unit>
<trans-unit id="6666899594015948817" datatype="html">
@@ -7425,7 +7425,7 @@
)"/> from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">605,607</context>
<context context-type="linenumber">603,605</context>
</context-group>
</trans-unit>
<trans-unit id="8050047262594964176" datatype="html">
@@ -7436,56 +7436,56 @@
)"/> on <x id="PH_2" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">609,613</context>
<context context-type="linenumber">607,611</context>
</context-group>
</trans-unit>
<trans-unit id="8615059324209654051" datatype="html">
<source>Move <x id="PH" equiv-text="this.list.selected.size"/> selected document(s) to the trash?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">751</context>
<context context-type="linenumber">749</context>
</context-group>
</trans-unit>
<trans-unit id="8585195717323764335" datatype="html">
<source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">793</context>
<context context-type="linenumber">791</context>
</context-group>
</trans-unit>
<trans-unit id="7366623494074776040" datatype="html">
<source>The archive files will be re-generated with the current settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">794</context>
<context context-type="linenumber">792</context>
</context-group>
</trans-unit>
<trans-unit id="6390006284731990222" datatype="html">
<source>This operation will permanently rotate the original version of <x id="PH" equiv-text="this.list.selected.size"/> document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">827</context>
<context context-type="linenumber">825</context>
</context-group>
</trans-unit>
<trans-unit id="7910756456450124185" datatype="html">
<source>Merge confirm</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">846</context>
<context context-type="linenumber">844</context>
</context-group>
</trans-unit>
<trans-unit id="7643543647233874431" datatype="html">
<source>This operation will merge <x id="PH" equiv-text="this.list.selected.size"/> selected documents into a new document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">847</context>
<context context-type="linenumber">845</context>
</context-group>
</trans-unit>
<trans-unit id="7869008840945899895" datatype="html">
<source>Merged document will be queued for consumption.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">866</context>
<context context-type="linenumber">864</context>
</context-group>
</trans-unit>
<trans-unit id="476913782630693351" datatype="html">
@@ -7767,7 +7767,7 @@
</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">112</context>
<context context-type="linenumber">107</context>
</context-group>
</trans-unit>
<trans-unit id="1559883523769732271" datatype="html">
@@ -7792,7 +7792,7 @@
</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">185</context>
<context context-type="linenumber">182</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
@@ -7981,161 +7981,167 @@
<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">95</context>
<context context-type="linenumber">90</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">183</context>
<context context-type="linenumber">180</context>
</context-group>
</trans-unit>
<trans-unit id="7408932238599462499" datatype="html">
<source>File 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">190</context>
<context context-type="linenumber">187</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">199</context>
<context context-type="linenumber">196</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">205</context>
<context context-type="linenumber">202</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">209</context>
<context context-type="linenumber">206</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">213</context>
<context context-type="linenumber">210</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">217</context>
<context context-type="linenumber">214</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">221</context>
<context context-type="linenumber">218</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>
<source>Correspondent: <x id="PH" equiv-text="this.correspondentSelectionModel.items.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">253,255</context>
<context context-type="linenumber">250,254</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">257</context>
<context context-type="linenumber">256</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>
<source>Document type: <x id="PH" equiv-text="this.documentTypeSelectionModel.items.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">263,265</context>
<context context-type="linenumber">262,266</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">267</context>
<context context-type="linenumber">268</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>
<source>Storage path: <x id="PH" equiv-text="this.storagePathSelectionModel.items.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">273,275</context>
<context context-type="linenumber">274,278</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">277</context>
<context context-type="linenumber">280</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>
<source>Tag: <x id="PH" equiv-text="this.tagSelectionModel.items.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">281,283</context>
<context context-type="linenumber">284,286</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">287</context>
<context context-type="linenumber">290</context>
</context-group>
</trans-unit>
<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">291</context>
<context context-type="linenumber">294</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">294</context>
<context context-type="linenumber">297</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">297</context>
<context context-type="linenumber">300</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">300</context>
<context context-type="linenumber">303</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">303</context>
<context context-type="linenumber">306</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">306</context>
<context context-type="linenumber">309</context>
</context-group>
</trans-unit>
<trans-unit id="7210076240260527720" datatype="html">
+2 -2
View File
@@ -12,7 +12,7 @@ import { DocumentAsnComponent } from './components/document-asn/document-asn.com
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
import { DocumentListComponent } from './components/document-list/document-list.component'
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
import { CustomFieldsListComponent } from './components/manage/custom-fields-list/custom-fields-list.component'
import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'
import { MailComponent } from './components/manage/mail/mail.component'
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
@@ -239,7 +239,7 @@ export const routes: Routes = [
},
{
path: 'customfields',
component: CustomFieldsComponent,
component: CustomFieldsListComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
@@ -11,7 +11,7 @@
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
<pngx-input-select i18n-title title="Data type" [items]="getDataTypes()" formControlName="data_type"></pngx-input-select>
@if (typeFieldDisabled) {
<small class="d-block mt-n2" i18n>Data type cannot be changed after a field is created</small>
<small class="d-block mt-n2 fst-italic text-muted" i18n>Data type cannot be changed after a field is created</small>
}
<div [formGroup]="objectForm.controls.extra_data">
@switch (objectForm.get('data_type').value) {
@@ -39,6 +39,14 @@
}
}
</div>
<hr/>
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@if (patternRequired) {
<pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
}
@if (patternRequired) {
<pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive" novalidate></pngx-input-check>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
@@ -21,6 +21,7 @@ import {
CustomFieldDataType,
DATA_TYPE_LABELS,
} from 'src/app/data/custom-field'
import { MATCH_NONE, MATCHING_ALGORITHMS } from 'src/app/data/matching-model'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
@@ -28,6 +29,27 @@ import { SelectComponent } from '../../input/select/select.component'
import { TextComponent } from '../../input/text/text.component'
import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
const FIELDS_WITH_DISCRETE_MATCHING = [
CustomFieldDataType.Boolean,
CustomFieldDataType.Select,
]
const MATCHING_ALGORITHMS_FOR_ALL_FIELDS = [
// MATCH_NONE
MATCHING_ALGORITHMS[6],
// MATCH_REGEX
MATCHING_ALGORITHMS[4],
]
const MATCHING_ALGORITHMS_FOR_DISCRETE_FIELDS = [
// MATCH_NONE
MATCHING_ALGORITHMS[6],
// MATCH_AUTO
MATCHING_ALGORITHMS[0],
// MATCH_REGEX
MATCHING_ALGORITHMS[4],
]
@Component({
selector: 'pngx-custom-field-edit-dialog',
templateUrl: './custom-field-edit-dialog.component.html',
@@ -107,6 +129,9 @@ export class CustomFieldEditDialogComponent
select_options: new FormArray([]),
default_currency: new FormControl(null),
}),
matching_algorithm: new FormControl(MATCH_NONE),
match: new FormControl(''),
is_insensitive: new FormControl(true),
})
}
@@ -127,4 +152,15 @@ export class CustomFieldEditDialogComponent
public removeSelectOption(index: number) {
this.selectOptions.removeAt(index)
}
public getMatchingAlgorithms() {
if (
FIELDS_WITH_DISCRETE_MATCHING.includes(this.getForm().value.data_type) ||
FIELDS_WITH_DISCRETE_MATCHING.includes(this.object?.data_type)
) {
return MATCHING_ALGORITHMS_FOR_DISCRETE_FIELDS
} else {
return MATCHING_ALGORITHMS_FOR_ALL_FIELDS
}
}
}
@@ -62,6 +62,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
this.emailAddress = ''
this.emailSubject = ''
this.emailMessage = ''
this.close()
this.toastService.showInfo($localize`Email sent`)
},
error: (e) => {
@@ -7,6 +7,7 @@ import {
tick,
} from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import {
DEFAULT_MATCHING_ALGORITHM,
MATCH_ALL,
@@ -44,6 +45,11 @@ const nullItem = {
name: 'Not assigned',
}
const negativeNullItem = {
id: NEGATIVE_NULL_FILTER_VALUE,
name: 'Not assigned',
}
let selectionModel: FilterableDropdownSelectionModel
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
@@ -64,6 +70,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
hotkeyService = TestBed.inject(HotKeyService)
fixture = TestBed.createComponent(FilterableDropdownComponent)
component = fixture.componentInstance
component.selectionModel = new FilterableDropdownSelectionModel()
selectionModel = new FilterableDropdownSelectionModel()
})
@@ -74,7 +81,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should support reset', () => {
component.items = items
component.selectionModel.items = items
component.selectionModel = selectionModel
selectionModel.set(items[0].id, ToggleableItemState.Selected)
expect(selectionModel.getSelectedItems()).toHaveLength(1)
@@ -96,7 +103,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should emit change when items selected', () => {
component.items = items
component.selectionModel.items = items
component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model))
@@ -110,11 +117,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
expect(newModel.getSelectedItems()).toEqual([])
expect(component.items).toEqual([nullItem, ...items])
expect(component.selectionModel.items).toEqual([nullItem, ...items])
})
it('should emit change when items excluded', () => {
component.items = items
component.selectionModel.items = items
component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model))
@@ -124,7 +131,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should emit change when items excluded', () => {
component.items = items
component.selectionModel.items = items
component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model))
@@ -139,8 +146,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should exclude items when excluded and not editing', () => {
component.items = items
component.manyToOne = true
component.selectionModel.items = items
component.selectionModel.manyToOne = true
component.selectionModel = selectionModel
selectionModel.set(items[0].id, ToggleableItemState.Selected)
component.excludeClicked(items[0].id)
@@ -149,8 +156,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should toggle when items excluded and editing', () => {
component.items = items
component.manyToOne = true
component.selectionModel.items = items
component.selectionModel.manyToOne = true
component.editing = true
component.selectionModel = selectionModel
selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
@@ -160,8 +167,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should hide count for item if adding will increase size of set', () => {
component.items = items
component.manyToOne = true
component.selectionModel.items = items
component.selectionModel.manyToOne = true
component.selectionModel = selectionModel
expect(component.hideCount(items[0])).toBeFalsy()
selectionModel.logicalOperator = LogicalOperator.Or
@@ -170,7 +177,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
it('should enforce single select when editing', () => {
component.editing = true
component.items = items
component.selectionModel.items = items
component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model))
@@ -182,11 +189,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should support manyToOne selecting', () => {
component.items = items
component.selectionModel.items = items
selectionModel.manyToOne = false
component.selectionModel = selectionModel
component.manyToOne = true
expect(component.manyToOne).toBeTruthy()
component.selectionModel.manyToOne = true
expect(component.selectionModel.manyToOne).toBeTruthy()
let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model))
@@ -197,12 +204,10 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should dynamically enable / disable modifier toggle', () => {
component.items = items
component.selectionModel.items = items
component.selectionModel = selectionModel
expect(component.modifierToggleEnabled).toBeTruthy()
selectionModel.toggle(null)
expect(component.modifierToggleEnabled).toBeFalsy()
component.manyToOne = true
component.selectionModel.manyToOne = true
expect(component.modifierToggleEnabled).toBeFalsy()
selectionModel.toggle(items[0].id)
selectionModel.toggle(items[1].id)
@@ -210,7 +215,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should apply changes and close when apply button clicked', () => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
component.editing = true
component.selectionModel = selectionModel
@@ -232,7 +237,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should apply on close if enabled', () => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
component.editing = true
component.applyOnClose = true
@@ -250,7 +255,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
@@ -277,7 +282,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}))
it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
expect(component.selectionModel.getSelectedItems()).toEqual([])
fixture.nativeElement
@@ -297,7 +302,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}))
it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
component.editing = true
let applyResult: ChangedItems
@@ -319,7 +324,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}))
it('should support arrow keyboard navigation', fakeAsync(() => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
@@ -364,7 +369,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}))
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
@@ -400,7 +405,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}))
it('should support arrow keyboard navigation after click', fakeAsync(() => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
@@ -425,9 +430,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}))
it('should toggle logical operator', fakeAsync(() => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
component.manyToOne = true
component.selectionModel.manyToOne = true
selectionModel.set(items[0].id, ToggleableItemState.Selected)
selectionModel.set(items[1].id, ToggleableItemState.Selected)
component.selectionModel = selectionModel
@@ -454,7 +459,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}))
it('should toggle intersection include / exclude', fakeAsync(() => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
selectionModel.set(items[0].id, ToggleableItemState.Selected)
selectionModel.set(items[1].id, ToggleableItemState.Selected)
@@ -483,22 +488,55 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(changedResult.getExcludedItems()).toEqual(items)
}))
it('selection model should sort items by state', () => {
component.items = items.concat([{ id: null, name: 'Null B' }])
it('should update null item selection on toggleIntersection', () => {
component.selectionModel.items = items
component.selectionModel = selectionModel
component.selectionModel.intersection = Intersection.Include
console.log(component.selectionModel.items[0])
component.selectionModel.set(null, ToggleableItemState.Selected)
component.selectionModel.intersection = Intersection.Exclude
component.selectionModel.toggleIntersection()
console.log(component.selectionModel)
expect(component.selectionModel.getExcludedItems()).toEqual([
negativeNullItem,
])
component.selectionModel.intersection = Intersection.Include
component.selectionModel.toggleIntersection()
expect(component.selectionModel.getSelectedItems()).toEqual([nullItem])
})
it('selection model should sort items by state', () => {
component.selectionModel = selectionModel
component.selectionModel.items = items.concat([{ id: 3, name: 'Item3' }])
selectionModel.toggle(items[1].id)
selectionModel.apply()
expect(selectionModel.items.length).toEqual(4)
expect(selectionModel.items).toEqual([
nullItem,
{ id: null, name: 'Null B' },
items[1],
{ id: 3, name: 'Item3' },
items[0],
])
selectionModel.intersection = Intersection.Exclude
selectionModel.toggleIntersection()
selectionModel.apply()
expect(selectionModel.items).toEqual([
negativeNullItem,
items[1],
{ id: 3, name: 'Item3' },
items[0],
])
// coverage
selectionModel.items = selectionModel.items.reverse()
selectionModel.apply()
})
it('selection model should sort items by state and document counts = 0, if set', () => {
const tagA = { id: 4, name: 'Tag A' }
component.items = items.concat([tagA])
component.selectionModel.items = items.concat([tagA])
component.selectionModel = selectionModel
component.documentCounts = [
{ id: 1, document_count: 0 }, // Tag1
@@ -529,7 +567,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
component.selectionModel = selectionModel
fixture.nativeElement
@@ -549,7 +587,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}))
it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
component.editing = true
component.createRef = jest.fn()
@@ -569,7 +607,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
const id = 1
const state = ToggleableItemState.Selected
component.selectionModel = selectionModel
component.manyToOne = true
component.selectionModel.manyToOne = true
component.selectionModel.singleSelect = true
component.selectionModel.intersection = Intersection.Include
component.selectionModel['temporarySelectionStates'].set(id, state)
@@ -596,7 +634,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should support shortcut keys', () => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
component.shortcutKey = 't'
fixture.detectChanges()
@@ -606,7 +644,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})
it('should support an extra button and not apply changes when clicked', () => {
component.items = items
component.selectionModel.items = items
component.icon = 'tag-fill'
component.extraButtonTitle = 'Extra'
component.selectionModel = selectionModel
@@ -12,6 +12,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, filter, takeUntil } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { MatchingModel } from 'src/app/data/matching-model'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { FilterPipe } from 'src/app/pipes/filter.pipe'
@@ -61,15 +62,56 @@ export class FilterableDropdownSelectionModel {
}
set items(items: MatchingModel[]) {
this._items = items
this.sortItems()
if (items) {
this._items = Array.from(items)
this.sortItems()
this.setNullItem()
}
}
private setNullItem() {
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
if (this._items[0]?.id === null) {
this._items.shift()
}
return
}
const item = {
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
id:
this.manyToOne || this.intersection === Intersection.Include
? null
: NEGATIVE_NULL_FILTER_VALUE,
}
if (
this._items[0]?.id === null ||
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
) {
this._items[0] = item
} else if (this._items) {
this._items.unshift(item)
}
}
constructor(manyToOne: boolean = false) {
this.manyToOne = manyToOne
}
private sortItems() {
this._items.sort((a, b) => {
if (a.id == null && b.id != null) {
if (
(a.id == null && b.id != null) ||
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
b.id != NEGATIVE_NULL_FILTER_VALUE)
) {
return -1
} else if (a.id != null && b.id == null) {
} else if (
(a.id != null && b.id == null) ||
(a.id != NEGATIVE_NULL_FILTER_VALUE &&
b.id == NEGATIVE_NULL_FILTER_VALUE)
) {
return 1
} else if (
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
@@ -230,6 +272,7 @@ export class FilterableDropdownSelectionModel {
set logicalOperator(operator: LogicalOperator) {
this.temporaryLogicalOperator = operator
this.setNullItem()
}
toggleOperator() {
@@ -242,6 +285,7 @@ export class FilterableDropdownSelectionModel {
set intersection(intersection: Intersection) {
this.temporaryIntersection = intersection
this.setNullItem()
}
toggleIntersection() {
@@ -250,9 +294,20 @@ export class FilterableDropdownSelectionModel {
this.intersection == Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded
this.temporarySelectionStates.forEach((state, key) => {
this.temporarySelectionStates.set(key, newState)
if (key === null && this.intersection === Intersection.Exclude) {
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
} else if (
key === NEGATIVE_NULL_FILTER_VALUE &&
this.intersection === Intersection.Include
) {
this.temporarySelectionStates.set(null, newState)
} else {
this.temporarySelectionStates.set(key, newState)
}
})
this.changed.next(this)
}
@@ -274,6 +329,7 @@ export class FilterableDropdownSelectionModel {
this.temporarySelectionStates.clear()
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
this.temporaryIntersection = this._intersection = Intersection.Include
this.setNullItem()
if (fireEvent) {
this.changed.next(this)
}
@@ -305,8 +361,10 @@ export class FilterableDropdownSelectionModel {
isNoneSelected() {
return (
this.selectionSize() == 1 &&
this.get(null) == ToggleableItemState.Selected
(this.selectionSize() == 1 &&
this.get(null) == ToggleableItemState.Selected) ||
(this.intersection == Intersection.Exclude &&
this.get(NEGATIVE_NULL_FILTER_VALUE) == ToggleableItemState.Excluded)
)
}
@@ -384,25 +442,13 @@ export class FilterableDropdownComponent
filterText: string
@Input()
set items(items: MatchingModel[]) {
if (items) {
this._selectionModel.items = Array.from(items)
this._selectionModel.items.unshift({
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
id: null,
})
}
}
_selectionModel: FilterableDropdownSelectionModel
get items(): MatchingModel[] {
return this._selectionModel.items
}
_selectionModel: FilterableDropdownSelectionModel =
new FilterableDropdownSelectionModel()
@Input()
@Input({ required: true })
set selectionModel(model: FilterableDropdownSelectionModel) {
if (this.selectionModel) {
this.selectionModel.changed.complete()
@@ -423,11 +469,6 @@ export class FilterableDropdownComponent
@Output()
selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>()
@Input()
set manyToOne(manyToOne: boolean) {
this.selectionModel.manyToOne = manyToOne
}
get manyToOne() {
return this.selectionModel.manyToOne
}
@@ -484,7 +525,7 @@ export class FilterableDropdownComponent
return this.manyToOne
? this.selectionModel.selectionSize() > 1 &&
this.selectionModel.getExcludedItems().length == 0
: !this.selectionModel.isNoneSelected()
: true
}
get name(): string {
@@ -20,10 +20,8 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[disabled]="!userCanEditAll || disabled"
[editing]="true"
[manyToOne]="true"
[applyOnClose]="applyOnClose"
[createRef]="createTag.bind(this)"
(opened)="openTagsDropdown()"
@@ -36,7 +34,6 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[disabled]="!userCanEditAll || disabled"
[editing]="true"
[applyOnClose]="applyOnClose"
@@ -51,7 +48,6 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[disabled]="!userCanEditAll || disabled"
[editing]="true"
[applyOnClose]="applyOnClose"
@@ -66,7 +62,6 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[disabled]="!userCanEditAll || disabled"
[editing]="true"
[applyOnClose]="applyOnClose"
@@ -81,10 +76,8 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
[items]="customFields"
[disabled]="!userCanEditAll"
[editing]="true"
[manyToOne]="true"
[applyOnClose]="applyOnClose"
[createRef]="createCustomField.bind(this)"
(opened)="openCustomFieldsDropdown()"
@@ -1150,10 +1150,10 @@ describe('BulkEditorComponent', () => {
it('should not attempt to retrieve objects if user does not have permissions', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
expect(component.tags).toBeUndefined()
expect(component.correspondents).toBeUndefined()
expect(component.documentTypes).toBeUndefined()
expect(component.storagePaths).toBeUndefined()
expect(component.tagSelectionModel.items.length).toEqual(0)
expect(component.correspondentSelectionModel.items.length).toEqual(0)
expect(component.documentTypeSelectionModel.items.length).toEqual(0)
expect(component.storagePathsSelectionModel.items.length).toEqual(0)
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/correspondents/`
@@ -1204,7 +1204,9 @@ describe('BulkEditorComponent', () => {
expect(tagListAllSpy).toHaveBeenCalled()
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
expect(component.tags).toEqual(tags.results)
expect(component.tagSelectionModel.items).toEqual(
[{ id: null, name: 'Not assigned' }].concat(tags.results as any)
)
})
it('should support create new correspondent', () => {
@@ -1251,7 +1253,9 @@ describe('BulkEditorComponent', () => {
expect(correspondentSelectionModelToggleSpy).toHaveBeenCalledWith(
newCorrespondent.id
)
expect(component.correspondents).toEqual(correspondents.results)
expect(component.correspondentSelectionModel.items).toEqual(
[{ id: null, name: 'Not assigned' }].concat(correspondents.results as any)
)
})
it('should support create new document type', () => {
@@ -1295,7 +1299,9 @@ describe('BulkEditorComponent', () => {
expect(documentTypeSelectionModelToggleSpy).toHaveBeenCalledWith(
newDocumentType.id
)
expect(component.documentTypes).toEqual(documentTypes.results)
expect(component.documentTypeSelectionModel.items).toEqual(
[{ id: null, name: 'Not assigned' }].concat(documentTypes.results as any)
)
})
it('should support create new storage path', () => {
@@ -1339,7 +1345,9 @@ describe('BulkEditorComponent', () => {
expect(storagePathsSelectionModelToggleSpy).toHaveBeenCalledWith(
newStoragePath.id
)
expect(component.storagePaths).toEqual(storagePaths.results)
expect(component.storagePathsSelectionModel.items).toEqual(
[{ id: null, name: 'Not assigned' }].concat(storagePaths.results as any)
)
})
it('should support create new custom field', () => {
@@ -1391,7 +1399,9 @@ describe('BulkEditorComponent', () => {
expect(customFieldsSelectionModelToggleSpy).toHaveBeenCalledWith(
newCustomField.id
)
expect(component.customFields).toEqual(customFields.results)
expect(component.customFieldsSelectionModel.items).toEqual(
[{ id: null, name: 'Not assigned' }].concat(customFields.results as any)
)
})
it('should open the bulk edit custom field values dialog with correct parameters', () => {
@@ -1416,17 +1426,17 @@ describe('BulkEditorComponent', () => {
const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError')
const listReloadSpy = jest.spyOn(documentListViewService, 'reload')
component.customFields = [
component.customFieldsSelectionModel.items = [
{ id: 1, name: 'Custom Field 1', data_type: CustomFieldDataType.String },
{ id: 2, name: 'Custom Field 2', data_type: CustomFieldDataType.String },
]
] as any
component.setCustomFieldValues({
itemsToAdd: [{ id: 1 }, { id: 2 }],
itemsToRemove: [1],
} as any)
expect(modal.componentInstance.customFields).toEqual(component.customFields)
expect(modal.componentInstance.customFields.length).toEqual(2)
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
expect(modal.componentInstance.documents).toEqual([3, 4])
@@ -14,12 +14,8 @@ import { saveAs } from 'file-saver'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, map, Subject, switchMap, takeUntil } from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomField } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type'
import { MatchingModel } from 'src/app/data/matching-model'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@@ -75,17 +71,11 @@ export class BulkEditorComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
tags: Tag[]
correspondents: Correspondent[]
documentTypes: DocumentType[]
storagePaths: StoragePath[]
customFields: CustomField[]
tagSelectionModel = new FilterableDropdownSelectionModel()
tagSelectionModel = new FilterableDropdownSelectionModel(true)
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[]
@@ -176,7 +166,7 @@ export class BulkEditorComponent
this.tagService
.listAll()
.pipe(first())
.subscribe((result) => (this.tags = result.results))
.subscribe((result) => (this.tagSelectionModel.items = result.results))
}
if (
this.permissionService.currentUserCan(
@@ -187,7 +177,9 @@ export class BulkEditorComponent
this.correspondentService
.listAll()
.pipe(first())
.subscribe((result) => (this.correspondents = result.results))
.subscribe(
(result) => (this.correspondentSelectionModel.items = result.results)
)
}
if (
this.permissionService.currentUserCan(
@@ -198,7 +190,9 @@ export class BulkEditorComponent
this.documentTypeService
.listAll()
.pipe(first())
.subscribe((result) => (this.documentTypes = result.results))
.subscribe(
(result) => (this.documentTypeSelectionModel.items = result.results)
)
}
if (
this.permissionService.currentUserCan(
@@ -209,7 +203,9 @@ export class BulkEditorComponent
this.storagePathService
.listAll()
.pipe(first())
.subscribe((result) => (this.storagePaths = result.results))
.subscribe(
(result) => (this.storagePathsSelectionModel.items = result.results)
)
}
if (
this.permissionService.currentUserCan(
@@ -220,7 +216,9 @@ export class BulkEditorComponent
this.customFieldService
.listAll()
.pipe(first())
.subscribe((result) => (this.customFields = result.results))
.subscribe(
(result) => (this.customFieldsSelectionModel.items = result.results)
)
}
this.downloadForm
@@ -651,7 +649,7 @@ export class BulkEditorComponent
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newTag, tags }) => {
this.tags = tags.results
this.tagSelectionModel.items = tags.results
this.tagSelectionModel.toggle(newTag.id)
})
}
@@ -674,7 +672,7 @@ export class BulkEditorComponent
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newCorrespondent, correspondents }) => {
this.correspondents = correspondents.results
this.correspondentSelectionModel.items = correspondents.results
this.correspondentSelectionModel.toggle(newCorrespondent.id)
})
}
@@ -695,7 +693,7 @@ export class BulkEditorComponent
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newDocumentType, documentTypes }) => {
this.documentTypes = documentTypes.results
this.documentTypeSelectionModel.items = documentTypes.results
this.documentTypeSelectionModel.toggle(newDocumentType.id)
})
}
@@ -716,7 +714,7 @@ export class BulkEditorComponent
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newStoragePath, storagePaths }) => {
this.storagePaths = storagePaths.results
this.storagePathsSelectionModel.items = storagePaths.results
this.storagePathsSelectionModel.toggle(newStoragePath.id)
})
}
@@ -737,7 +735,7 @@ export class BulkEditorComponent
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newCustomField, customFields }) => {
this.customFields = customFields.results
this.customFieldsSelectionModel.items = customFields.results
this.customFieldsSelectionModel.toggle(newCustomField.id)
})
}
@@ -875,7 +873,9 @@ export class BulkEditorComponent
})
const dialog =
modal.componentInstance as CustomFieldsBulkEditDialogComponent
dialog.customFields = this.customFields
dialog.customFields = (
this.customFieldsSelectionModel.items as CustomField[]
).filter((f) => f.id !== null)
dialog.fieldsToAddIds = changedCustomFields.itemsToAdd.map(
(item) => item.id
)
@@ -35,11 +35,9 @@
<div class="col-auto">
<div class="d-flex flex-wrap gap-3">
<div class="d-flex flex-wrap gap-2">
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag) && tags.length > 0) {
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag) && tagSelectionModel.items.length > 0) {
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[manyToOne]="true"
[(selectionModel)]="tagSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onTagsDropdownOpen()"
@@ -48,10 +46,9 @@
[disabled]="disabled"
shortcutKey="t"></pngx-filterable-dropdown>
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && correspondents.length > 0) {
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && correspondentSelectionModel.items.length > 0) {
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[(selectionModel)]="correspondentSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onCorrespondentDropdownOpen()"
@@ -60,10 +57,9 @@
[disabled]="disabled"
shortcutKey="y"></pngx-filterable-dropdown>
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType) && documentTypes.length > 0) {
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType) && documentTypeSelectionModel.items.length > 0) {
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[(selectionModel)]="documentTypeSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onDocumentTypeDropdownOpen()"
@@ -72,10 +68,9 @@
[disabled]="disabled"
shortcutKey="u"></pngx-filterable-dropdown>
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath) && storagePaths.length > 0) {
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath) && storagePathSelectionModel.items.length > 0) {
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[(selectionModel)]="storagePathSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onStoragePathDropdownOpen()"
@@ -69,6 +69,7 @@ import {
FILTER_STORAGE_PATH,
FILTER_TITLE,
FILTER_TITLE_CONTENT,
NEGATIVE_NULL_FILTER_VALUE,
} from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
@@ -671,9 +672,6 @@ describe('FilterEditorComponent', () => {
value: '12',
},
]
expect(component.correspondentSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.correspondentSelectionModel.intersection).toEqual(
Intersection.Include
)
@@ -681,6 +679,19 @@ describe('FilterEditorComponent', () => {
correspondents[0],
])
component.toggleCorrespondent(12) // coverage
component.filterRules = [
{
rule_type: FILTER_CORRESPONDENT,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
]
expect(component.correspondentSelectionModel.intersection).toEqual(
Intersection.Exclude
)
expect(component.correspondentSelectionModel.getExcludedItems()).toEqual([
{ id: NEGATIVE_NULL_FILTER_VALUE, name: 'Not assigned' },
])
}))
it('should ingest filter rules for has any of correspondents', fakeAsync(() => {
@@ -754,9 +765,6 @@ describe('FilterEditorComponent', () => {
value: '22',
},
]
expect(component.documentTypeSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.documentTypeSelectionModel.intersection).toEqual(
Intersection.Include
)
@@ -764,6 +772,19 @@ describe('FilterEditorComponent', () => {
document_types[0],
])
component.toggleDocumentType(22) // coverage
component.filterRules = [
{
rule_type: FILTER_DOCUMENT_TYPE,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
]
expect(component.documentTypeSelectionModel.intersection).toEqual(
Intersection.Exclude
)
expect(component.documentTypeSelectionModel.getExcludedItems()).toEqual([
{ id: NEGATIVE_NULL_FILTER_VALUE, name: 'Not assigned' },
])
}))
it('should ingest filter rules for has any of document types', fakeAsync(() => {
@@ -780,9 +801,6 @@ describe('FilterEditorComponent', () => {
value: '23',
},
]
expect(component.documentTypeSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.documentTypeSelectionModel.intersection).toEqual(
Intersection.Include
)
@@ -837,9 +855,6 @@ describe('FilterEditorComponent', () => {
value: '32',
},
]
expect(component.storagePathSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.storagePathSelectionModel.intersection).toEqual(
Intersection.Include
)
@@ -847,6 +862,19 @@ describe('FilterEditorComponent', () => {
storage_paths[0],
])
component.toggleStoragePath(32) // coverage
component.filterRules = [
{
rule_type: FILTER_STORAGE_PATH,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
]
expect(component.storagePathSelectionModel.intersection).toEqual(
Intersection.Exclude
)
expect(component.storagePathSelectionModel.getExcludedItems()).toEqual([
{ id: NEGATIVE_NULL_FILTER_VALUE, name: 'Not assigned' },
])
}))
it('should ingest filter rules for has any of storage paths', fakeAsync(() => {
@@ -1398,6 +1426,19 @@ describe('FilterEditorComponent', () => {
value: null,
},
])
const excludeButton = correspondentsFilterableDropdown.queryAll(
By.css('input[value=exclude]')
)[0]
excludeButton.nativeElement.checked = true
excludeButton.triggerEventHandler('change')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_CORRESPONDENT,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
])
}))
it('should convert user input to correct filter rules on document type selections', fakeAsync(() => {
@@ -1455,6 +1496,19 @@ describe('FilterEditorComponent', () => {
value: null,
},
])
const excludeButton = docTypesFilterableDropdown.queryAll(
By.css('input[value=exclude]')
)[0]
excludeButton.nativeElement.checked = true
excludeButton.triggerEventHandler('change')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_DOCUMENT_TYPE,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
])
}))
it('should convert user input to correct filter rules on storage path selections', fakeAsync(() => {
@@ -1512,6 +1566,19 @@ describe('FilterEditorComponent', () => {
value: null,
},
])
const excludeButton = storagePathsFilterableDropdown.queryAll(
By.css('input[value=exclude]')
)[0]
excludeButton.nativeElement.checked = true
excludeButton.triggerEventHandler('change')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_STORAGE_PATH,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
])
}))
it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => {
@@ -26,14 +26,12 @@ import {
switchMap,
takeUntil,
} from 'rxjs/operators'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomField } from 'src/app/data/custom-field'
import {
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from 'src/app/data/custom-field-query'
import { Document } from 'src/app/data/document'
import { DocumentType } from 'src/app/data/document-type'
import { FilterRule } from 'src/app/data/filter-rule'
import {
FILTER_ADDED_AFTER,
@@ -75,9 +73,8 @@ import {
FILTER_STORAGE_PATH,
FILTER_TITLE,
FILTER_TITLE_CONTENT,
NEGATIVE_NULL_FILTER_VALUE,
} from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import {
PermissionAction,
PermissionType,
@@ -251,7 +248,9 @@ export class FilterEditorComponent
case FILTER_HAS_CORRESPONDENT_ANY:
if (rule.value) {
return $localize`Correspondent: ${
this.correspondents.find((c) => c.id == +rule.value)?.name
this.correspondentSelectionModel.items.find(
(c) => c.id == +rule.value
)?.name
}`
} else {
return $localize`Without correspondent`
@@ -261,7 +260,9 @@ export class FilterEditorComponent
case FILTER_HAS_DOCUMENT_TYPE_ANY:
if (rule.value) {
return $localize`Document type: ${
this.documentTypes.find((dt) => dt.id == +rule.value)?.name
this.documentTypeSelectionModel.items.find(
(dt) => dt.id == +rule.value
)?.name
}`
} else {
return $localize`Without document type`
@@ -271,7 +272,9 @@ export class FilterEditorComponent
case FILTER_HAS_STORAGE_PATH_ANY:
if (rule.value) {
return $localize`Storage path: ${
this.storagePaths.find((sp) => sp.id == +rule.value)?.name
this.storagePathSelectionModel.items.find(
(sp) => sp.id == +rule.value
)?.name
}`
} else {
return $localize`Without storage path`
@@ -279,7 +282,7 @@ export class FilterEditorComponent
case FILTER_HAS_TAGS_ALL:
return $localize`Tag: ${
this.tags.find((t) => t.id == +rule.value)?.name
this.tagSelectionModel.items.find((t) => t.id == +rule.value)?.name
}`
case FILTER_HAS_ANY_TAG:
@@ -326,10 +329,6 @@ export class FilterEditorComponent
@ViewChild('textFilterInput')
textFilterInput: ElementRef
tags: Tag[] = []
correspondents: Correspondent[] = []
documentTypes: DocumentType[] = []
storagePaths: StoragePath[] = []
customFields: CustomField[] = []
tagDocumentCounts: SelectionDataItem[]
@@ -370,7 +369,7 @@ export class FilterEditorComponent
)
}
tagSelectionModel = new FilterableDropdownSelectionModel()
tagSelectionModel = new FilterableDropdownSelectionModel(true)
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel()
@@ -551,6 +550,19 @@ export class FilterEditorComponent
)
break
case FILTER_CORRESPONDENT:
this.correspondentSelectionModel.intersection =
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
? Intersection.Exclude
: Intersection.Include
this.correspondentSelectionModel.set(
rule.value ? +rule.value : null,
this.correspondentSelectionModel.intersection ==
Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded,
false
)
break
case FILTER_HAS_CORRESPONDENT_ANY:
this.correspondentSelectionModel.logicalOperator = LogicalOperator.Or
this.correspondentSelectionModel.intersection = Intersection.Include
@@ -569,6 +581,18 @@ export class FilterEditorComponent
)
break
case FILTER_DOCUMENT_TYPE:
this.documentTypeSelectionModel.intersection =
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
? Intersection.Exclude
: Intersection.Include
this.documentTypeSelectionModel.set(
rule.value ? +rule.value : null,
this.documentTypeSelectionModel.intersection == Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded,
false
)
break
case FILTER_HAS_DOCUMENT_TYPE_ANY:
this.documentTypeSelectionModel.logicalOperator = LogicalOperator.Or
this.documentTypeSelectionModel.intersection = Intersection.Include
@@ -587,6 +611,18 @@ export class FilterEditorComponent
)
break
case FILTER_STORAGE_PATH:
this.storagePathSelectionModel.intersection =
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
? Intersection.Exclude
: Intersection.Include
this.storagePathSelectionModel.set(
rule.value ? +rule.value : null,
this.storagePathSelectionModel.intersection == Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded,
false
)
break
case FILTER_HAS_STORAGE_PATH_ANY:
this.storagePathSelectionModel.logicalOperator = LogicalOperator.Or
this.storagePathSelectionModel.intersection = Intersection.Include
@@ -809,9 +845,21 @@ export class FilterEditorComponent
})
})
}
if (this.correspondentSelectionModel.isNoneSelected()) {
if (
this.correspondentSelectionModel.isNoneSelected() &&
this.correspondentSelectionModel.intersection == Intersection.Include
) {
filterRules.push({ rule_type: FILTER_CORRESPONDENT, value: null })
} else {
if (
this.correspondentSelectionModel.isNoneSelected() &&
this.correspondentSelectionModel.intersection == Intersection.Exclude
) {
filterRules.push({
rule_type: FILTER_CORRESPONDENT,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
})
}
this.correspondentSelectionModel
.getSelectedItems()
.forEach((correspondent) => {
@@ -822,6 +870,7 @@ export class FilterEditorComponent
})
this.correspondentSelectionModel
.getExcludedItems()
.filter((correspondent) => correspondent.id > 0)
.forEach((correspondent) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
@@ -829,9 +878,21 @@ export class FilterEditorComponent
})
})
}
if (this.documentTypeSelectionModel.isNoneSelected()) {
if (
this.documentTypeSelectionModel.isNoneSelected() &&
this.documentTypeSelectionModel.intersection === Intersection.Include
) {
filterRules.push({ rule_type: FILTER_DOCUMENT_TYPE, value: null })
} else {
if (
this.documentTypeSelectionModel.isNoneSelected() &&
this.documentTypeSelectionModel.intersection == Intersection.Exclude
) {
filterRules.push({
rule_type: FILTER_DOCUMENT_TYPE,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
})
}
this.documentTypeSelectionModel
.getSelectedItems()
.forEach((documentType) => {
@@ -842,6 +903,7 @@ export class FilterEditorComponent
})
this.documentTypeSelectionModel
.getExcludedItems()
.filter((documentType) => documentType.id > 0)
.forEach((documentType) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
@@ -849,9 +911,21 @@ export class FilterEditorComponent
})
})
}
if (this.storagePathSelectionModel.isNoneSelected()) {
if (
this.storagePathSelectionModel.isNoneSelected() &&
this.storagePathSelectionModel.intersection == Intersection.Include
) {
filterRules.push({ rule_type: FILTER_STORAGE_PATH, value: null })
} else {
if (
this.storagePathSelectionModel.isNoneSelected() &&
this.storagePathSelectionModel.intersection == Intersection.Exclude
) {
filterRules.push({
rule_type: FILTER_STORAGE_PATH,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
})
}
this.storagePathSelectionModel
.getSelectedItems()
.forEach((storagePath) => {
@@ -862,6 +936,7 @@ export class FilterEditorComponent
})
this.storagePathSelectionModel
.getExcludedItems()
.filter((storagePath) => storagePath.id > 0)
.forEach((storagePath) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
@@ -1062,7 +1137,7 @@ export class FilterEditorComponent
) {
this.loadingCountTotal++
this.tagService.listAll().subscribe((result) => {
this.tags = result.results
this.tagSelectionModel.items = result.results
this.maybeCompleteLoading()
})
}
@@ -1074,7 +1149,7 @@ export class FilterEditorComponent
) {
this.loadingCountTotal++
this.correspondentService.listAll().subscribe((result) => {
this.correspondents = result.results
this.correspondentSelectionModel.items = result.results
this.maybeCompleteLoading()
})
}
@@ -1086,7 +1161,7 @@ export class FilterEditorComponent
) {
this.loadingCountTotal++
this.documentTypeService.listAll().subscribe((result) => {
this.documentTypes = result.results
this.documentTypeSelectionModel.items = result.results
this.maybeCompleteLoading()
})
}
@@ -1098,7 +1173,7 @@ export class FilterEditorComponent
) {
this.loadingCountTotal++
this.storagePathService.listAll().subscribe((result) => {
this.storagePaths = result.results
this.storagePathSelectionModel.items = result.results
this.maybeCompleteLoading()
})
}
@@ -28,7 +28,7 @@ import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { CustomFieldsComponent } from './custom-fields.component'
import { CustomFieldsListComponent } from './custom-fields-list.component'
const fields: CustomField[] = [
{
@@ -43,9 +43,9 @@ const fields: CustomField[] = [
},
]
describe('CustomFieldsComponent', () => {
let component: CustomFieldsComponent
let fixture: ComponentFixture<CustomFieldsComponent>
describe('CustomFieldsListComponent', () => {
let component: CustomFieldsListComponent
let fixture: ComponentFixture<CustomFieldsListComponent>
let customFieldsService: CustomFieldsService
let modalService: NgbModal
let toastService: ToastService
@@ -61,7 +61,7 @@ describe('CustomFieldsComponent', () => {
NgbModalModule,
NgbPopoverModule,
NgxBootstrapIconsModule.pick(allIcons),
CustomFieldsComponent,
CustomFieldsListComponent,
IfPermissionsDirective,
PageHeaderComponent,
ConfirmDialogComponent,
@@ -94,7 +94,7 @@ describe('CustomFieldsComponent', () => {
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 0, username: 'test' }
fixture = TestBed.createComponent(CustomFieldsComponent)
fixture = TestBed.createComponent(CustomFieldsListComponent)
component = fixture.componentInstance
fixture.detectChanges()
jest.useFakeTimers()
@@ -106,7 +106,7 @@ describe('CustomFieldsComponent', () => {
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reload')
const reloadSpy = jest.spyOn(component, 'reloadData')
const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
createButton.triggerEventHandler('click')
@@ -131,7 +131,7 @@ describe('CustomFieldsComponent', () => {
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reload')
const reloadSpy = jest.spyOn(component, 'reloadData')
const editButton = fixture.debugElement.queryAll(By.css('button'))[2]
editButton.triggerEventHandler('click')
@@ -156,7 +156,7 @@ describe('CustomFieldsComponent', () => {
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const deleteSpy = jest.spyOn(customFieldsService, 'delete')
const reloadSpy = jest.spyOn(component, 'reload')
const reloadSpy = jest.spyOn(component, 'reloadData')
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[5]
deleteButton.triggerEventHandler('click')
@@ -0,0 +1,96 @@
import { NgClass, TitleCasePipe } from '@angular/common'
import { Component } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
NgbDropdownModule,
NgbModal,
NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
import {
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from 'src/app/data/custom-field-query'
import { FILTER_CUSTOM_FIELDS_QUERY } from 'src/app/data/filter-rule-type'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import {
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { ToastService } from 'src/app/services/toast.service'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ManagementListComponent } from '../management-list/management-list.component'
@Component({
selector: 'pngx-custom-fields-list',
templateUrl: './../management-list/management-list.component.html',
styleUrls: ['./../management-list/management-list.component.scss'],
imports: [
SortableDirective,
PageHeaderComponent,
TitleCasePipe,
IfPermissionsDirective,
SafeHtmlPipe,
FormsModule,
ReactiveFormsModule,
NgClass,
NgbDropdownModule,
NgbPaginationModule,
NgxBootstrapIconsModule,
],
})
export class CustomFieldsListComponent extends ManagementListComponent<CustomField> {
permissionsDisabled = true
constructor(
customFieldsService: CustomFieldsService,
modalService: NgbModal,
toastService: ToastService,
documentListViewService: DocumentListViewService,
permissionsService: PermissionsService
) {
super(
customFieldsService,
modalService,
CustomFieldEditDialogComponent,
toastService,
documentListViewService,
permissionsService,
0, // see filterDocuments override below
$localize`custom field`,
$localize`custom fields`,
PermissionType.CustomField,
[
{
key: 'data_type',
name: $localize`Data Type`,
valueFn: (field: CustomField) => {
return DATA_TYPE_LABELS.find((l) => l.id === field.data_type).name
},
},
]
)
}
filterDocuments(field: CustomField) {
this.documentListViewService.quickFilter([
{
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: JSON.stringify([
CustomFieldQueryLogicalOperator.Or,
[[field.id, CustomFieldQueryOperator.Exists, true]],
]),
},
])
}
getDeleteMessage(object: CustomField) {
return $localize`Do you really want to delete the field "${object.name}"?`
}
}
@@ -1,72 +0,0 @@
<pngx-page-header
title="Custom Fields"
i18n-title
info="Customize the data fields that can be attached to documents."
i18n-info
infoLink="usage/#custom-fields"
>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Field</ng-container>
</button>
</pngx-page-header>
<ul class="list-group">
<li class="list-group-item">
<div class="row">
<div class="col" i18n>Name</div>
<div class="col" i18n>Data Type</div>
<div class="col" i18n>Actions</div>
</div>
</li>
@if (loading) {
<li class="list-group-item">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</li>
}
@for (field of fields; track field) {
<li class="list-group-item">
<div class="row fade" [class.show]="show">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
<div class="col d-flex align-items-center">{{getDataType(field)}}</div>
<div class="col">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<i-bs name="three-dots-vertical"></i-bs>
</button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
@if (field.document_count > 0) {
<button (click)="filterDocuments(field)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ field.document_count }})</button>
}
</div>
</div>
</div>
<div class="btn-group d-none d-sm-inline-block">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
<i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
<i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
@if (field.document_count > 0) {
<div class="btn-group d-none d-sm-inline-block ms-2">
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="filterDocuments(field)">
<i-bs width="1em" height="1em" name="filter"></i-bs>&nbsp;<ng-container i18n>Documents</ng-container><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
</button>
</div>
}
</div>
</div>
</li>
}
@if (!loading && fields.length === 0) {
<li class="list-group-item" i18n>No fields defined.</li>
}
</ul>
@@ -1,4 +0,0 @@
// hide caret on mobile dropdown
.d-block.d-sm-none .dropdown-toggle::after {
display: none;
}
@@ -1,148 +0,0 @@
import { Component, OnInit } from '@angular/core'
import {
NgbDropdownModule,
NgbModal,
NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { delay, takeUntil, tap } from 'rxjs'
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
import {
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from 'src/app/data/custom-field-query'
import { FILTER_CUSTOM_FIELDS_QUERY } from 'src/app/data/filter-rule-type'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@Component({
selector: 'pngx-custom-fields',
templateUrl: './custom-fields.component.html',
styleUrls: ['./custom-fields.component.scss'],
imports: [
PageHeaderComponent,
IfPermissionsDirective,
NgbDropdownModule,
NgbPaginationModule,
NgxBootstrapIconsModule,
],
})
export class CustomFieldsComponent
extends LoadingComponentWithPermissions
implements OnInit
{
public fields: CustomField[] = []
constructor(
private customFieldsService: CustomFieldsService,
public permissionsService: PermissionsService,
private modalService: NgbModal,
private toastService: ToastService,
private documentListViewService: DocumentListViewService,
private settingsService: SettingsService,
private documentService: DocumentService,
private savedViewService: SavedViewService
) {
super()
}
ngOnInit() {
this.reload()
}
reload() {
this.customFieldsService
.listAll()
.pipe(
takeUntil(this.unsubscribeNotifier),
tap((r) => {
this.fields = r.results
}),
delay(100)
)
.subscribe(() => {
this.show = true
this.loading = false
})
}
editField(field: CustomField) {
const modal = this.modalService.open(CustomFieldEditDialogComponent)
modal.componentInstance.dialogMode = field
? EditDialogMode.EDIT
: EditDialogMode.CREATE
modal.componentInstance.object = field
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => {
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
this.customFieldsService.clearCache()
this.settingsService.initializeDisplayFields()
this.documentService.reload()
this.reload()
})
modal.componentInstance.failed
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((e) => {
this.toastService.showError($localize`Error saving field.`, e)
})
}
deleteField(field: CustomField) {
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm delete field`
modal.componentInstance.messageBold = $localize`This operation will permanently delete this field.`
modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.customFieldsService.delete(field).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted field "${field.name}"`)
this.customFieldsService.clearCache()
this.settingsService.initializeDisplayFields()
this.documentService.reload()
this.savedViewService.reload()
this.reload()
},
error: (e) => {
this.toastService.showError(
$localize`Error deleting field "${field.name}".`,
e
)
},
})
})
}
getDataType(field: CustomField): string {
return DATA_TYPE_LABELS.find((l) => l.id === field.data_type).name
}
filterDocuments(field: CustomField) {
this.documentListViewService.quickFilter([
{
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: JSON.stringify([
CustomFieldQueryLogicalOperator.Or,
[[field.id, CustomFieldQueryOperator.Exists, true]],
]),
},
])
}
}
@@ -2,7 +2,7 @@
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Clear selection</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
<button *ngIf="!permissionsDisabled" type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
<i-bs name="person-fill-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
@@ -64,7 +64,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
private modalService: NgbModal,
private editDialogComponent: any,
private toastService: ToastService,
private documentListViewService: DocumentListViewService,
protected documentListViewService: DocumentListViewService,
private permissionsService: PermissionsService,
protected filterRuleType: number,
public typeName: string,
@@ -93,6 +93,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
public selectedObjects: Set<number> = new Set()
public togggleAll: boolean = false
protected permissionsDisabled: boolean = false
ngOnInit(): void {
this.reloadData()
+2 -4
View File
@@ -1,4 +1,4 @@
import { ObjectWithId } from './object-with-id'
import { MatchingModel } from './matching-model'
export enum CustomFieldDataType {
String = 'string',
@@ -51,13 +51,11 @@ export const DATA_TYPE_LABELS = [
},
]
export interface CustomField extends ObjectWithId {
export interface CustomField extends MatchingModel {
data_type: CustomFieldDataType
name: string
created?: Date
extra_data?: {
select_options?: Array<{ label: string; id: string }>
default_currency?: string
}
document_count?: number
}
+2
View File
@@ -1,5 +1,7 @@
import { DataType } from './datatype'
export const NEGATIVE_NULL_FILTER_VALUE = -1
// These correspond to src/documents/models.py and changes here require a DB migration (and vice versa)
export const FILTER_TITLE = 0
export const FILTER_CONTENT = 1
@@ -1,12 +1,12 @@
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { CustomField } from 'src/app/data/custom-field'
import { AbstractPaperlessService } from './abstract-paperless-service'
import { AbstractNameFilterService } from './abstract-name-filter-service'
@Injectable({
providedIn: 'root',
})
export class CustomFieldsService extends AbstractPaperlessService<CustomField> {
export class CustomFieldsService extends AbstractNameFilterService<CustomField> {
constructor(http: HttpClient) {
super(http, 'custom_fields')
}
+11
View File
@@ -8,6 +8,7 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_TAGS_ALL,
NEGATIVE_NULL_FILTER_VALUE,
} from '../data/filter-rule-type'
import {
filterRulesFromQueryParams,
@@ -97,6 +98,16 @@ describe('QueryParams Utils', () => {
correspondent__isnull: 1,
})
params = queryParamsFromFilterRules([
{
rule_type: FILTER_CORRESPONDENT,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
])
expect(params).toEqual({
correspondent__isnull: 0,
})
params = queryParamsFromFilterRules([
{
rule_type: FILTER_HAS_ANY_TAG,
+11 -1
View File
@@ -10,6 +10,7 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_RULE_TYPES,
FilterRuleType,
NEGATIVE_NULL_FILTER_VALUE,
} from '../data/filter-rule-type'
import { ListViewState } from '../services/document-list-view.service'
@@ -113,6 +114,10 @@ export function filterRulesFromQueryParams(
rt.isnull_filtervar == filterQueryParamName
)
const isNullRuleType = rule_type.isnull_filtervar == filterQueryParamName
const nullRuleValue =
queryParams.get(filterQueryParamName) == '1'
? null
: NEGATIVE_NULL_FILTER_VALUE.toString()
const valueURIComponent: string = queryParams.get(filterQueryParamName)
const filterQueryParamValues: string[] = rule_type.multi
? valueURIComponent.split(',')
@@ -125,7 +130,7 @@ export function filterRulesFromQueryParams(
val = val.replace('1', 'true').replace('0', 'false')
return {
rule_type: rule_type.id,
value: isNullRuleType ? null : val,
value: isNullRuleType ? nullRuleValue : val,
}
})
)
@@ -143,6 +148,11 @@ export function queryParamsFromFilterRules(filterRules: FilterRule[]): Params {
let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type)
if (ruleType.isnull_filtervar && rule.value == null) {
params[ruleType.isnull_filtervar] = 1
} else if (
ruleType.isnull_filtervar &&
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
) {
params[ruleType.isnull_filtervar] = 0
} else if (ruleType.multi) {
params[ruleType.filtervar] = params[ruleType.filtervar]
? params[ruleType.filtervar] + ',' + rule.value
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -15,6 +15,7 @@ class DocumentsConfig(AppConfig):
from documents.signals.handlers import run_workflows_added
from documents.signals.handlers import run_workflows_updated
from documents.signals.handlers import set_correspondent
from documents.signals.handlers import set_custom_fields
from documents.signals.handlers import set_document_type
from documents.signals.handlers import set_storage_path
from documents.signals.handlers import set_tags
@@ -24,6 +25,7 @@ class DocumentsConfig(AppConfig):
document_consumption_finished.connect(set_document_type)
document_consumption_finished.connect(set_tags)
document_consumption_finished.connect(set_storage_path)
document_consumption_finished.connect(set_custom_fields)
document_consumption_finished.connect(add_to_index)
document_consumption_finished.connect(run_workflows_added)
document_updated.connect(run_workflows_updated)
+83 -4
View File
@@ -97,6 +97,8 @@ class DocumentClassifier:
self.correspondent_classifier = None
self.document_type_classifier = None
self.storage_path_classifier = None
self.custom_fields_binarizer = None
self.custom_fields_classifier = None
self._stemmer = None
self._stop_words = None
@@ -120,11 +122,12 @@ class DocumentClassifier:
self.data_vectorizer = pickle.load(f)
self.tags_binarizer = pickle.load(f)
self.tags_classifier = pickle.load(f)
self.correspondent_classifier = pickle.load(f)
self.document_type_classifier = pickle.load(f)
self.storage_path_classifier = pickle.load(f)
self.custom_fields_binarizer = pickle.load(f)
self.custom_fields_classifier = pickle.load(f)
except Exception as err:
raise ClassifierModelCorruptError from err
@@ -162,6 +165,9 @@ class DocumentClassifier:
pickle.dump(self.document_type_classifier, f)
pickle.dump(self.storage_path_classifier, f)
pickle.dump(self.custom_fields_binarizer, f)
pickle.dump(self.custom_fields_classifier, f)
target_file_temp.rename(target_file)
def train(self) -> bool:
@@ -183,6 +189,7 @@ class DocumentClassifier:
labels_correspondent = []
labels_document_type = []
labels_storage_path = []
labels_custom_fields = []
# Step 1: Extract and preprocess training data from the database.
logger.debug("Gathering data from database...")
@@ -218,13 +225,25 @@ class DocumentClassifier:
hasher.update(y.to_bytes(4, "little", signed=True))
labels_storage_path.append(y)
labels_tags_unique = {tag for tags in labels_tags for tag in tags}
custom_fields = sorted(
cf.pk
for cf in doc.custom_fields.filter(
field__matching_algorithm=MatchingModel.MATCH_AUTO,
)
)
for cf in custom_fields:
hasher.update(cf.to_bytes(4, "little", signed=True))
labels_custom_fields.append(custom_fields)
labels_tags_unique = {tag for tags in labels_tags for tag in tags}
num_tags = len(labels_tags_unique)
labels_custom_fields_unique = {cf for cfs in labels_custom_fields for cf in cfs}
num_custom_fields = len(labels_custom_fields_unique)
# Check if retraining is actually required.
# A document has been updated since the classifier was trained
# New auto tags, types, correspondent, storage paths exist
# New auto tags, types, correspondent, storage paths or custom fields exist
latest_doc_change = docs_queryset.latest("modified").modified
if (
self.last_doc_change_time is not None
@@ -253,7 +272,8 @@ class DocumentClassifier:
logger.debug(
f"{docs_queryset.count()} documents, {num_tags} tag(s), {num_correspondents} correspondent(s), "
f"{num_document_types} document type(s). {num_storage_paths} storage path(s)",
f"{num_document_types} document type(s), {num_storage_paths} storage path(s), "
f"{num_custom_fields} custom field(s)",
)
from sklearn.feature_extraction.text import CountVectorizer
@@ -345,6 +365,39 @@ class DocumentClassifier:
"There are no storage paths. Not training storage path classifier.",
)
if num_custom_fields > 0:
logger.debug("Training custom fields classifier...")
if num_custom_fields == 1:
# Special case where only one custom field has auto:
# Fallback to binary classification.
labels_custom_fields = [
label[0] if len(label) == 1 else -1
for label in labels_custom_fields
]
self.custom_fields_binarizer = LabelBinarizer()
labels_custom_fields_vectorized = (
self.custom_fields_binarizer.fit_transform(
labels_custom_fields,
).ravel()
)
else:
self.custom_fields_binarizer = MultiLabelBinarizer()
labels_custom_fields_vectorized = (
self.custom_fields_binarizer.fit_transform(labels_custom_fields)
)
self.custom_fields_classifier = MLPClassifier(tol=0.01)
self.custom_fields_classifier.fit(
data_vectorized,
labels_custom_fields_vectorized,
)
else:
self.custom_fields_classifier = None
logger.debug(
"There are no custom fields. Not training custom fields classifier.",
)
self.last_doc_change_time = latest_doc_change
self.last_auto_type_hash = hasher.digest()
@@ -472,3 +525,29 @@ class DocumentClassifier:
return None
else:
return None
def predict_custom_fields(self, content: str) -> dict:
"""
Custom fields are a bit different from the other classifiers, as we
need to predict the values for the fields, not just the field itself.
"""
# TODO: can this return the value?
from sklearn.utils.multiclass import type_of_target
if self.custom_fields_classifier:
X = self.data_vectorizer.transform([self.preprocess_content(content)])
y = self.custom_fields_classifier.predict(X)
custom_fields_ids = self.custom_fields_binarizer.inverse_transform(y)[0]
if type_of_target(y).startswith("multilabel"):
# the usual case when there are multiple custom fields.
return list(custom_fields_ids)
elif type_of_target(y) == "binary" and custom_fields_ids != -1:
# This is for when we have binary classification with only one
# custom field and the result is to assign this custom field.
return [custom_fields_ids]
else:
# Usually binary as well with -1 as the result, but we're
# going to catch everything else here as well.
return []
else:
return []
@@ -7,6 +7,7 @@ from documents.classifier import load_classifier
from documents.management.commands.mixins import ProgressBarMixin
from documents.models import Document
from documents.signals.handlers import set_correspondent
from documents.signals.handlers import set_custom_fields
from documents.signals.handlers import set_document_type
from documents.signals.handlers import set_storage_path
from documents.signals.handlers import set_tags
@@ -17,9 +18,9 @@ logger = logging.getLogger("paperless.management.retagger")
class Command(ProgressBarMixin, BaseCommand):
help = (
"Using the current classification model, assigns correspondents, tags "
"and document types to all documents, effectively allowing you to "
"back-tag all previously indexed documents with metadata created (or "
"modified) after their initial import."
"document types, storage paths and custom fields to all documents, effectively"
"allowing you to back-tag all previously indexed documents with metadata created "
"(or modified) after their initial import."
)
def add_arguments(self, parser):
@@ -27,6 +28,12 @@ class Command(ProgressBarMixin, BaseCommand):
parser.add_argument("-T", "--tags", default=False, action="store_true")
parser.add_argument("-t", "--document_type", default=False, action="store_true")
parser.add_argument("-s", "--storage_path", default=False, action="store_true")
parser.add_argument(
"-cf",
"--custom_fields",
default=False,
action="store_true",
)
parser.add_argument("-i", "--inbox-only", default=False, action="store_true")
parser.add_argument(
"--use-first",
@@ -134,3 +141,16 @@ class Command(ProgressBarMixin, BaseCommand):
stdout=self.stdout,
style_func=self.style,
)
if options["custom_fields"]:
set_custom_fields(
sender=None,
document=document,
classifier=classifier,
replace=options["overwrite"],
use_first=options["use_first"],
suggest=options["suggest"],
base_url=options["base_url"],
stdout=self.stdout,
style_func=self.style,
)
+44
View File
@@ -132,6 +132,50 @@ def match_storage_paths(document: Document, classifier: DocumentClassifier, user
)
def match_custom_fields(
document: Document,
classifier: DocumentClassifier,
user=None,
) -> dict:
"""
Custom fields work differently, we need the values for the match as well.
"""
# TODO: this needs to return values as well
predicted_custom_field_ids = (
classifier.predict_custom_fields(document.content) if classifier else []
)
fields = [instance.field for instance in document.custom_fields.all()]
matched_fields = {}
for field in fields:
if field.matching_algorithm == MatchingModel.MATCH_AUTO:
if field.pk in predicted_custom_field_ids:
matched_fields[field] = None
elif field.matching_algorithm == MatchingModel.MATCH_REGEX:
try:
match = re.search(
re.compile(field.matching_model.match),
document.content,
)
if match:
matched_fields[field] = match.group()
except re.error:
logger.error(
f"Error while processing regular expression {field.matching_model.match}",
)
return False
if match:
log_reason(
field.matching_model,
document,
f"the string {match.group()} matches the regular expression "
f"{field.matching_model.match}",
)
return matched_fields
def matches(matching_model: MatchingModel, document: Document):
search_kwargs = {}
@@ -0,0 +1,55 @@
# Generated by Django 5.1.6 on 2025-03-20 23:37
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1065_workflowaction_assign_custom_fields_values"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="customfield",
name="is_insensitive",
field=models.BooleanField(default=True, verbose_name="is insensitive"),
),
migrations.AddField(
model_name="customfield",
name="match",
field=models.CharField(blank=True, max_length=256, verbose_name="match"),
),
migrations.AddField(
model_name="customfield",
name="matching_algorithm",
field=models.PositiveIntegerField(
choices=[
(0, "None"),
(1, "Any word"),
(2, "All words"),
(3, "Exact match"),
(4, "Regular expression"),
(5, "Fuzzy word"),
(6, "Automatic"),
],
default=0,
verbose_name="matching algorithm",
),
),
migrations.AddField(
model_name="customfield",
name="owner",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
]
+7 -1
View File
@@ -719,7 +719,7 @@ class ShareLink(SoftDeleteModel):
return f"Share Link for {self.document.title}"
class CustomField(models.Model):
class CustomField(MatchingModel):
"""
Defines the name and type of a custom field
"""
@@ -760,6 +760,12 @@ class CustomField(models.Model):
),
)
matching_algorithm = models.PositiveIntegerField(
_("matching algorithm"),
choices=MatchingModel.MATCHING_ALGORITHMS,
default=MatchingModel.MATCH_NONE, # override with CustomField.FieldDataType.NONE
)
class Meta:
ordering = ("created",)
verbose_name = _("custom field")
+17 -3
View File
@@ -582,7 +582,7 @@ class StoragePathField(serializers.PrimaryKeyRelatedField):
return StoragePath.objects.all()
class CustomFieldSerializer(serializers.ModelSerializer):
class CustomFieldSerializer(MatchingModelSerializer, serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
context = kwargs.get("context")
self.api_version = int(
@@ -597,8 +597,6 @@ class CustomFieldSerializer(serializers.ModelSerializer):
read_only=False,
)
document_count = serializers.IntegerField(read_only=True)
class Meta:
model = CustomField
fields = [
@@ -607,6 +605,9 @@ class CustomFieldSerializer(serializers.ModelSerializer):
"data_type",
"extra_data",
"document_count",
"match",
"matching_algorithm",
"is_insensitive",
]
def validate(self, attrs):
@@ -669,6 +670,19 @@ class CustomFieldSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(
{"error": "extra_data.default_currency must be a 3-character string"},
)
if (
"matching_algorithm" in attrs
and attrs["matching_algorithm"] != CustomField.MATCH_REGEX
and "data_type" in attrs
and attrs["data_type"]
not in [
CustomField.FieldDataType.SELECT,
CustomField.FieldDataType.BOOL,
]
):
raise serializers.ValidationError(
{"error": "Only discrete data types support matching"},
)
return super().validate(attrs)
def to_internal_value(self, data):
+71
View File
@@ -318,6 +318,77 @@ def set_storage_path(
document.save(update_fields=("storage_path",))
def set_custom_fields(
document: Document,
logging_group=None,
classifier: DocumentClassifier | None = None,
base_url=None,
stdout=None,
style_func=None,
*,
replace=False,
suggest=False,
**kwargs,
):
if replace:
CustomFieldInstance.objects.filter(document=document).exclude(
Q(field__match="") & ~Q(field__matching_algorithm=CustomField.MATCH_AUTO),
).delete()
current_fields = set([instance.field for instance in document.custom_fields.all()])
matched_fields_w_values: dict = matching.match_custom_fields(document, classifier)
matched_fields = matched_fields_w_values.keys()
relevant_fields = set(matched_fields) - current_fields
if suggest:
extra_fields = current_fields - set(matched_fields)
extra_fields = [
f for f in extra_fields if f.matching_algorithm == MatchingModel.MATCH_AUTO
]
if not relevant_fields and not extra_fields:
return
doc_str = style_func.SUCCESS(str(document))
if base_url:
stdout.write(doc_str)
stdout.write(f"{base_url}/documents/{document.pk}")
else:
stdout.write(doc_str + style_func.SUCCESS(f" [{document.pk}]"))
if relevant_fields:
stdout.write(
"Suggest custom fields: "
+ ", ".join([f.name for f in relevant_fields]),
)
if extra_fields:
stdout.write(
"Extra custom fields: " + ", ".join([f.name for f in extra_fields]),
)
else:
if not relevant_fields:
return
message = 'Assigning custom fields "{}" to "{}"'
logger.info(
message.format(document, ", ".join([f.name for f in relevant_fields])),
extra={"group": logging_group},
)
for field in relevant_fields:
args = {
"field": field,
"document": document,
}
if field.pk in matched_fields_w_values:
value_field_name = CustomFieldInstance.get_value_field_name(
data_type=field.data_type,
)
args[value_field_name] = matched_fields_w_values[field.pk]
CustomFieldInstance.objects.create(
**args,
)
# see empty_trash in documents/tasks.py for signal handling
def cleanup_document_deletion(sender, instance, **kwargs):
with FileLock(settings.MEDIA_LOCK):
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More