Merge branch 'dev' into feature-doc-detail-fixes

This commit is contained in:
shamoon 2024-01-13 13:20:07 -08:00
commit 3db7ab4675
50 changed files with 1607 additions and 743 deletions

View File

@ -4,9 +4,9 @@ Paperless provides a wide range of customizations. Depending on how you
run paperless, these settings have to be defined in different places. run paperless, these settings have to be defined in different places.
Certain configuration options may be set via the UI. This currently includes Certain configuration options may be set via the UI. This currently includes
common [OCR](#ocr) related settings. If set, these will take preference over the common [OCR](#ocr) related settings and some frontend settings. If set, these will take
settings via environment variables. If not set, the environment setting or applicable preference over the settings via environment variables. If not set, the environment setting
default will be utilized instead. or applicable default will be utilized instead.
- If you run paperless on docker, `paperless.conf` is not used. - If you run paperless on docker, `paperless.conf` is not used.
Rather, configure paperless by copying necessary options to Rather, configure paperless by copying necessary options to
@ -1329,7 +1329,15 @@ started by the container.
You can read more about this in the [advanced documentation](advanced_usage.md#celery-monitoring). You can read more about this in the [advanced documentation](advanced_usage.md#celery-monitoring).
## Update Checking {#update-checking} ## Frontend Settings
#### [`PAPERLESS_APP_TITLE=<bool>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
: If set, overrides the default name "Paperless-ngx"
#### [`PAPERLESS_APP_LOGO=<path>`](#PAPERLESS_APP_LOGO) {#PAPERLESS_APP_LOGO}
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK} #### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}

View File

@ -439,7 +439,7 @@
<source>Discard</source> <source>Discard</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">48</context> <context context-type="linenumber">49</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@ -450,7 +450,7 @@
<source>Save</source> <source>Save</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">51</context> <context context-type="linenumber">52</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
@ -513,28 +513,42 @@
<source>Error retrieving config</source> <source>Error retrieving config</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">79</context> <context context-type="linenumber">81</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1172622527269118932" datatype="html"> <trans-unit id="1172622527269118932" datatype="html">
<source>Invalid JSON</source> <source>Invalid JSON</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">105</context> <context context-type="linenumber">107</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5103146006962696736" datatype="html"> <trans-unit id="5103146006962696736" datatype="html">
<source>Configuration updated</source> <source>Configuration updated</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">148</context> <context context-type="linenumber">151</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1664963291286452273" datatype="html"> <trans-unit id="1664963291286452273" datatype="html">
<source>An error occurred updating configuration</source> <source>An error occurred updating configuration</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">153</context> <context context-type="linenumber">156</context>
</context-group>
</trans-unit>
<trans-unit id="2653081282186526824" datatype="html">
<source>File successfully updated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">178</context>
</context-group>
</trans-unit>
<trans-unit id="5902783625859504265" datatype="html">
<source>An error occurred uploading file</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">183</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4804785061014590286" datatype="html"> <trans-unit id="4804785061014590286" datatype="html">
@ -545,11 +559,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">309</context> <context context-type="linenumber">319</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">314</context> <context context-type="linenumber">324</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8838884664569764142" datatype="html"> <trans-unit id="8838884664569764142" datatype="html">
@ -646,15 +660,15 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">59</context> <context context-type="linenumber">69</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">267</context> <context context-type="linenumber">277</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">271</context> <context context-type="linenumber">281</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1685061484835793745" datatype="html"> <trans-unit id="1685061484835793745" datatype="html">
@ -1123,7 +1137,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">116</context> <context context-type="linenumber">126</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1595668988802980095" datatype="html"> <trans-unit id="1595668988802980095" datatype="html">
@ -1517,7 +1531,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">117</context> <context context-type="linenumber">121</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5260584511980773458" datatype="html"> <trans-unit id="5260584511980773458" datatype="html">
@ -1535,7 +1549,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">296</context> <context context-type="linenumber">306</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="103921551219467537" datatype="html"> <trans-unit id="103921551219467537" datatype="html">
@ -1722,11 +1736,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">285</context> <context context-type="linenumber">295</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">289</context> <context context-type="linenumber">299</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4555457172864212828" datatype="html"> <trans-unit id="4555457172864212828" datatype="html">
@ -1917,7 +1931,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">704</context> <context context-type="linenumber">701</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@ -1956,7 +1970,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">706</context> <context context-type="linenumber">703</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@ -2035,66 +2049,65 @@
<context context-type="linenumber">180</context> <context context-type="linenumber">180</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2173456130768795374" datatype="html"> <trans-unit id="7931334600001636863" datatype="html">
<source>Paperless-ngx</source> <source>by Paperless-ngx</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">15</context> <context context-type="linenumber">20</context>
</context-group> </context-group>
<note priority="1" from="description">app title</note>
</trans-unit> </trans-unit>
<trans-unit id="7100953725264790651" datatype="html"> <trans-unit id="7100953725264790651" datatype="html">
<source>Search documents</source> <source>Search documents</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">23</context> <context context-type="linenumber">33</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2448391510242468907" datatype="html"> <trans-unit id="2448391510242468907" datatype="html">
<source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source> <source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">47</context> <context context-type="linenumber">57</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2127032578120864096" datatype="html"> <trans-unit id="2127032578120864096" datatype="html">
<source>My Profile</source> <source>My Profile</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">53</context> <context context-type="linenumber">63</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3797778920049399855" datatype="html"> <trans-unit id="3797778920049399855" datatype="html">
<source>Logout</source> <source>Logout</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">64</context> <context context-type="linenumber">74</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4895326106573044490" datatype="html"> <trans-unit id="4895326106573044490" datatype="html">
<source>Documentation</source> <source>Documentation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">71</context> <context context-type="linenumber">81</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">319</context> <context context-type="linenumber">329</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">324</context> <context context-type="linenumber">334</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6570363013146073520" datatype="html"> <trans-unit id="6570363013146073520" datatype="html">
<source>Dashboard</source> <source>Dashboard</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">96</context> <context context-type="linenumber">106</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">100</context> <context context-type="linenumber">110</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context> <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
@ -2105,11 +2118,11 @@
<source>Documents</source> <source>Documents</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">105</context> <context context-type="linenumber">115</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">109</context> <context context-type="linenumber">119</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
@ -2136,36 +2149,36 @@
<source>Open documents</source> <source>Open documents</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">150</context> <context context-type="linenumber">160</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5687256342387781369" datatype="html"> <trans-unit id="5687256342387781369" datatype="html">
<source>Close all</source> <source>Close all</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">174</context> <context context-type="linenumber">184</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">178</context> <context context-type="linenumber">188</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3897348120591552265" datatype="html"> <trans-unit id="3897348120591552265" datatype="html">
<source>Manage</source> <source>Manage</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">186</context> <context context-type="linenumber">196</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7437910965833684826" datatype="html"> <trans-unit id="7437910965833684826" datatype="html">
<source>Correspondents</source> <source>Correspondents</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">192</context> <context context-type="linenumber">202</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">196</context> <context context-type="linenumber">206</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
@ -2176,11 +2189,11 @@
<source>Tags</source> <source>Tags</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">201</context> <context context-type="linenumber">211</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">206</context> <context context-type="linenumber">216</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context> <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
@ -2207,11 +2220,11 @@
<source>Document Types</source> <source>Document Types</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">212</context> <context context-type="linenumber">222</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">216</context> <context context-type="linenumber">226</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
@ -2222,11 +2235,11 @@
<source>Storage Paths</source> <source>Storage Paths</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">221</context> <context context-type="linenumber">231</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">225</context> <context context-type="linenumber">235</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
@ -2237,11 +2250,11 @@
<source>Custom Fields</source> <source>Custom Fields</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">230</context> <context context-type="linenumber">240</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">234</context> <context context-type="linenumber">244</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
@ -2256,11 +2269,11 @@
<source>Workflows</source> <source>Workflows</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">241</context> <context context-type="linenumber">251</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">245</context> <context context-type="linenumber">255</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context> <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
@ -2271,99 +2284,99 @@
<source>Mail</source> <source>Mail</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">250</context> <context context-type="linenumber">260</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">255</context> <context context-type="linenumber">265</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7844706011418789951" datatype="html"> <trans-unit id="7844706011418789951" datatype="html">
<source>Administration</source> <source>Administration</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">261</context> <context context-type="linenumber">271</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3008420115644088420" datatype="html"> <trans-unit id="3008420115644088420" datatype="html">
<source>Configuration</source> <source>Configuration</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">276</context> <context context-type="linenumber">286</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">280</context> <context context-type="linenumber">290</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6626289114556551491" datatype="html"> <trans-unit id="6626289114556551491" datatype="html">
<source>File Tasks<x id="START_BLOCK_IF" equiv-text="@if (tasksService.failedFileTasks.length &gt; 0) {"/><x id="START_TAG_SPAN_1" ctype="x-span_1" equiv-text="&lt;span&gt;"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-danger ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> <source>File Tasks<x id="START_BLOCK_IF" equiv-text="@if (tasksService.failedFileTasks.length &gt; 0) {"/><x id="START_TAG_SPAN_1" ctype="x-span_1" equiv-text="&lt;span&gt;"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-danger ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">303,305</context> <context context-type="linenumber">313,315</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1534029177398918729" datatype="html"> <trans-unit id="1534029177398918729" datatype="html">
<source>GitHub</source> <source>GitHub</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">331</context> <context context-type="linenumber">341</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4112664765954374539" datatype="html"> <trans-unit id="4112664765954374539" datatype="html">
<source>is available.</source> <source>is available.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">340,341</context> <context context-type="linenumber">350,351</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1175891574282637937" datatype="html"> <trans-unit id="1175891574282637937" datatype="html">
<source>Click to view.</source> <source>Click to view.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">341</context> <context context-type="linenumber">351</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9811291095862612" datatype="html"> <trans-unit id="9811291095862612" datatype="html">
<source>Paperless-ngx can automatically check for updates</source> <source>Paperless-ngx can automatically check for updates</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">345</context> <context context-type="linenumber">355</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="894819944961861800" datatype="html"> <trans-unit id="894819944961861800" datatype="html">
<source> How does this work? </source> <source> How does this work? </source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">352,354</context> <context context-type="linenumber">362,364</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="509090351011426949" datatype="html"> <trans-unit id="509090351011426949" datatype="html">
<source>Update available</source> <source>Update available</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">368</context> <context context-type="linenumber">378</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1542489069631984294" datatype="html"> <trans-unit id="1542489069631984294" datatype="html">
<source>Sidebar views updated</source> <source>Sidebar views updated</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">259</context> <context context-type="linenumber">263</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3547923076537026828" datatype="html"> <trans-unit id="3547923076537026828" datatype="html">
<source>Error updating sidebar views</source> <source>Error updating sidebar views</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">262</context> <context context-type="linenumber">266</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2526035785704676448" datatype="html"> <trans-unit id="2526035785704676448" datatype="html">
<source>An error occurred while saving update checking settings.</source> <source>An error occurred while saving update checking settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">283</context> <context context-type="linenumber">287</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8700121026680200191" datatype="html"> <trans-unit id="8700121026680200191" datatype="html">
@ -2381,13 +2394,6 @@
<context context-type="linenumber">63</context> <context context-type="linenumber">63</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5000042972069710005" datatype="html">
<source><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;d-inline-block&quot; style=&quot;padding-bottom: 1px;&quot; &gt;"/>Cancel<x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
</trans-unit>
<trans-unit id="1234709746630139322" datatype="html"> <trans-unit id="1234709746630139322" datatype="html">
<source>Confirmation</source> <source>Confirmation</source>
<context-group purpose="location"> <context-group purpose="location">
@ -2422,6 +2428,73 @@
<context context-type="linenumber">439</context> <context context-type="linenumber">439</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2159130950882492111" datatype="html">
<source>Cancel</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context>
<context context-type="linenumber">44</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
<context context-type="linenumber">24</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context>
<context context-type="linenumber">15</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
<context context-type="linenumber">26</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html</context>
<context context-type="linenumber">36</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
<context context-type="linenumber">48</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">25</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
<context context-type="linenumber">35</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">170</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
<context context-type="linenumber">22</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">57</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/select-dialog/select-dialog.component.html</context>
<context context-type="linenumber">12</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">6</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="3972154626835212608" datatype="html"> <trans-unit id="3972154626835212608" datatype="html">
<source>Create New Field</source> <source>Create New Field</source>
<context-group purpose="location"> <context-group purpose="location">
@ -2586,69 +2659,6 @@
<context context-type="linenumber">194</context> <context context-type="linenumber">194</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2159130950882492111" datatype="html">
<source>Cancel</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
<context context-type="linenumber">24</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context>
<context context-type="linenumber">15</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
<context context-type="linenumber">26</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html</context>
<context context-type="linenumber">36</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
<context context-type="linenumber">48</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">25</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
<context context-type="linenumber">35</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">170</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
<context context-type="linenumber">22</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">57</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/select-dialog/select-dialog.component.html</context>
<context context-type="linenumber">12</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">6</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="6457471243969293847" datatype="html"> <trans-unit id="6457471243969293847" datatype="html">
<source>Create new correspondent</source> <source>Create new correspondent</source>
<context-group purpose="location"> <context-group purpose="location">
@ -3696,6 +3706,14 @@
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context> <context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
<context context-type="linenumber">11</context> <context context-type="linenumber">11</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/file/file.component.html</context>
<context context-type="linenumber">11</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/file/file.component.html</context>
<context context-type="linenumber">25</context>
</context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/number/number.component.html</context> <context context-type="sourcefile">src/app/components/common/input/number/number.component.html</context>
<context context-type="linenumber">11</context> <context context-type="linenumber">11</context>
@ -3757,6 +3775,13 @@
<context context-type="linenumber">44</context> <context context-type="linenumber">44</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6932865105766151309" datatype="html">
<source>Upload</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/file/file.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="5554528553553249088" datatype="html"> <trans-unit id="5554528553553249088" datatype="html">
<source>Show password</source> <source>Show password</source>
<context-group purpose="location"> <context-group purpose="location">
@ -4188,32 +4213,32 @@
<context context-type="linenumber">44</context> <context context-type="linenumber">44</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1865646076514070962" datatype="html"> <trans-unit id="6581372518205328477" datatype="html">
<source>Hello <x id="PH" equiv-text="this.settingsService.displayName"/>, welcome to Paperless-ngx</source> <source>Hello <x id="PH" equiv-text="this.settingsService.displayName"/>, welcome to <x id="PH_1" equiv-text="appTitle"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context> <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
<context context-type="linenumber">38</context> <context context-type="linenumber">41</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5334686081082652461" datatype="html"> <trans-unit id="2901300640157872718" datatype="html">
<source>Welcome to Paperless-ngx</source> <source>Welcome to <x id="PH" equiv-text="appTitle"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context> <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
<context context-type="linenumber">40</context> <context context-type="linenumber">43</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1325877348738783391" datatype="html"> <trans-unit id="1325877348738783391" datatype="html">
<source>Dashboard updated</source> <source>Dashboard updated</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context> <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
<context context-type="linenumber">71</context> <context context-type="linenumber">74</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3214475953924351473" datatype="html"> <trans-unit id="3214475953924351473" datatype="html">
<source>Error updating dashboard</source> <source>Error updating dashboard</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context> <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
<context context-type="linenumber">74</context> <context context-type="linenumber">77</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2946624699882754313" datatype="html"> <trans-unit id="2946624699882754313" datatype="html">
@ -4727,7 +4752,7 @@
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7206723502037428235" datatype="html"> <trans-unit id="7206723502037428235" datatype="html">
<source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> <source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="}&lt;/a&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">286,289</context> <context context-type="linenumber">286,289</context>
@ -4762,50 +4787,78 @@
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source> <source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">284,286</context> <context context-type="linenumber">276,278</context>
</context-group>
</trans-unit>
<trans-unit id="3200733026060976258" datatype="html">
<source>Document changes detected</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">298</context>
</context-group>
</trans-unit>
<trans-unit id="2887155916749964" datatype="html">
<source>The version of this document in your browser session appears older than the existing version.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">299</context>
</context-group>
</trans-unit>
<trans-unit id="237142428785956348" datatype="html">
<source>Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">300</context>
</context-group>
</trans-unit>
<trans-unit id="8720977247725652816" datatype="html">
<source>Ok</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">301</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5758784066858623886" datatype="html"> <trans-unit id="5758784066858623886" datatype="html">
<source>Error retrieving metadata</source> <source>Error retrieving metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">436</context> <context context-type="linenumber">437</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3456881259945295697" datatype="html"> <trans-unit id="3456881259945295697" datatype="html">
<source>Error retrieving suggestions.</source> <source>Error retrieving suggestions.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">461</context> <context context-type="linenumber">458</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8348337312757497317" datatype="html"> <trans-unit id="8348337312757497317" datatype="html">
<source>Document saved successfully.</source> <source>Document saved successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">579</context> <context context-type="linenumber">576</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">588</context> <context context-type="linenumber">585</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="448882439049417053" datatype="html"> <trans-unit id="448882439049417053" datatype="html">
<source>Error saving document</source> <source>Error saving document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">592</context> <context context-type="linenumber">589</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">633</context> <context context-type="linenumber">630</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9021887951960049161" datatype="html"> <trans-unit id="9021887951960049161" datatype="html">
<source>Confirm delete</source> <source>Confirm delete</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">659</context> <context context-type="linenumber">656</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
@ -4816,35 +4869,35 @@
<source>Do you really want to delete document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;?</source> <source>Do you really want to delete document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">660</context> <context context-type="linenumber">657</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6691075929777935948" datatype="html"> <trans-unit id="6691075929777935948" datatype="html">
<source>The files for this document will be deleted permanently. This operation cannot be undone.</source> <source>The files for this document will be deleted permanently. This operation cannot be undone.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">661</context> <context context-type="linenumber">658</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="719892092227206532" datatype="html"> <trans-unit id="719892092227206532" datatype="html">
<source>Delete document</source> <source>Delete document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">663</context> <context context-type="linenumber">660</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7295637485862454066" datatype="html"> <trans-unit id="7295637485862454066" datatype="html">
<source>Error deleting document</source> <source>Error deleting document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">682</context> <context context-type="linenumber">679</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7362691899087997122" datatype="html"> <trans-unit id="7362691899087997122" datatype="html">
<source>Redo OCR confirm</source> <source>Redo OCR confirm</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">702</context> <context context-type="linenumber">699</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@ -4855,28 +4908,28 @@
<source>This operation will permanently redo OCR for this document.</source> <source>This operation will permanently redo OCR for this document.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">703</context> <context context-type="linenumber">700</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5729001209753056399" datatype="html"> <trans-unit id="5729001209753056399" datatype="html">
<source>Redo OCR operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source> <source>Redo OCR operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">714</context> <context context-type="linenumber">711</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4409560272830824468" datatype="html"> <trans-unit id="4409560272830824468" datatype="html">
<source>Error executing operation</source> <source>Error executing operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">725</context> <context context-type="linenumber">722</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4458954481601077369" datatype="html"> <trans-unit id="4458954481601077369" datatype="html">
<source>Page Fit</source> <source>Page Fit</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">794</context> <context context-type="linenumber">791</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6857598786757174736" datatype="html"> <trans-unit id="6857598786757174736" datatype="html">
@ -6443,102 +6496,123 @@
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="432834967329800065" datatype="html">
<source>General Settings</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">50</context>
</context-group>
</trans-unit>
<trans-unit id="2762851116637676072" datatype="html"> <trans-unit id="2762851116637676072" datatype="html">
<source>OCR Settings</source> <source>OCR Settings</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">49</context> <context context-type="linenumber">51</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1313137480169642057" datatype="html"> <trans-unit id="1313137480169642057" datatype="html">
<source>Output Type</source> <source>Output Type</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">73</context> <context context-type="linenumber">75</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2826581353496868063" datatype="html"> <trans-unit id="2826581353496868063" datatype="html">
<source>Language</source> <source>Language</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">81</context> <context context-type="linenumber">83</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3817498941817715969" datatype="html"> <trans-unit id="3817498941817715969" datatype="html">
<source>Pages</source> <source>Pages</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">88</context> <context context-type="linenumber">90</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1713271461473302108" datatype="html"> <trans-unit id="1713271461473302108" datatype="html">
<source>Mode</source> <source>Mode</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">95</context> <context context-type="linenumber">97</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6114528299376689399" datatype="html"> <trans-unit id="6114528299376689399" datatype="html">
<source>Skip Archive File</source> <source>Skip Archive File</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">103</context> <context context-type="linenumber">105</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1115402553541327390" datatype="html"> <trans-unit id="1115402553541327390" datatype="html">
<source>Image DPI</source> <source>Image DPI</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">111</context> <context context-type="linenumber">113</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6352596107300820129" datatype="html"> <trans-unit id="6352596107300820129" datatype="html">
<source>Clean</source> <source>Clean</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">118</context> <context context-type="linenumber">120</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="725308589819024010" datatype="html"> <trans-unit id="725308589819024010" datatype="html">
<source>Deskew</source> <source>Deskew</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">126</context> <context context-type="linenumber">128</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6256076128297775802" datatype="html"> <trans-unit id="6256076128297775802" datatype="html">
<source>Rotate Pages</source> <source>Rotate Pages</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">133</context> <context context-type="linenumber">135</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8527188778859256947" datatype="html"> <trans-unit id="8527188778859256947" datatype="html">
<source>Rotate Pages Threshold</source> <source>Rotate Pages Threshold</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">140</context> <context context-type="linenumber">142</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3762131309176747817" datatype="html"> <trans-unit id="3762131309176747817" datatype="html">
<source>Max Image Pixels</source> <source>Max Image Pixels</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">147</context> <context context-type="linenumber">149</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7846583355792281769" datatype="html"> <trans-unit id="7846583355792281769" datatype="html">
<source>Color Conversion Strategy</source> <source>Color Conversion Strategy</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">154</context> <context context-type="linenumber">156</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4696480417479207939" datatype="html"> <trans-unit id="4696480417479207939" datatype="html">
<source>OCR Arguments</source> <source>OCR Arguments</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">162</context> <context context-type="linenumber">164</context>
</context-group>
</trans-unit>
<trans-unit id="7106327322456204362" datatype="html">
<source>Application Logo</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">171</context>
</context-group>
</trans-unit>
<trans-unit id="2684743776608068095" datatype="html">
<source>Application Title</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">178</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5948496158474272829" datatype="html"> <trans-unit id="5948496158474272829" datatype="html">

View File

@ -110,6 +110,7 @@ import { DocumentLinkComponent } from './components/common/input/document-link/d
import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component' import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component'
import { SwitchComponent } from './components/common/input/switch/switch.component' import { SwitchComponent } from './components/common/input/switch/switch.component'
import { ConfigComponent } from './components/admin/config/config.component' import { ConfigComponent } from './components/admin/config/config.component'
import { FileComponent } from './components/common/input/file/file.component'
import localeAf from '@angular/common/locales/af' import localeAf from '@angular/common/locales/af'
import localeAr from '@angular/common/locales/ar' import localeAr from '@angular/common/locales/ar'
@ -267,6 +268,7 @@ function initializeApp(settings: SettingsService) {
PreviewPopupComponent, PreviewPopupComponent,
SwitchComponent, SwitchComponent,
ConfigComponent, ConfigComponent,
FileComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

View File

@ -30,6 +30,7 @@
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> } @case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> } @case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> } @case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
} }
</div> </div>
</div> </div>

View File

@ -15,12 +15,15 @@ import { SwitchComponent } from '../../common/input/switch/switch.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { SelectComponent } from '../../common/input/select/select.component' import { SelectComponent } from '../../common/input/select/select.component'
import { FileComponent } from '../../common/input/file/file.component'
import { SettingsService } from 'src/app/services/settings.service'
describe('ConfigComponent', () => { describe('ConfigComponent', () => {
let component: ConfigComponent let component: ConfigComponent
let fixture: ComponentFixture<ConfigComponent> let fixture: ComponentFixture<ConfigComponent>
let configService: ConfigService let configService: ConfigService
let toastService: ToastService let toastService: ToastService
let settingService: SettingsService
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@ -30,6 +33,7 @@ describe('ConfigComponent', () => {
SelectComponent, SelectComponent,
NumberComponent, NumberComponent,
SwitchComponent, SwitchComponent,
FileComponent,
PageHeaderComponent, PageHeaderComponent,
], ],
imports: [ imports: [
@ -44,6 +48,7 @@ describe('ConfigComponent', () => {
configService = TestBed.inject(ConfigService) configService = TestBed.inject(ConfigService)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
settingService = TestBed.inject(SettingsService)
fixture = TestBed.createComponent(ConfigComponent) fixture = TestBed.createComponent(ConfigComponent)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
@ -100,4 +105,39 @@ describe('ConfigComponent', () => {
component.configForm.patchValue({ user_args: '{ "foo": "bar" }' }) component.configForm.patchValue({ user_args: '{ "foo": "bar" }' })
expect(component.errors).toEqual({ user_args: null }) expect(component.errors).toEqual({ user_args: null })
}) })
it('should upload file, show error if necessary', () => {
const uploadSpy = jest.spyOn(configService, 'uploadFile')
const errorSpy = jest.spyOn(toastService, 'showError')
uploadSpy.mockReturnValueOnce(
throwError(() => new Error('Error uploading file'))
)
component.uploadFile(new File([], 'test.png'), 'app_logo')
expect(uploadSpy).toHaveBeenCalled()
expect(errorSpy).toHaveBeenCalled()
uploadSpy.mockReturnValueOnce(
of({ app_logo: 'https://example.com/logo/test.png' } as any)
)
component.uploadFile(new File([], 'test.png'), 'app_logo')
expect(component.initialConfig).toEqual({
app_logo: 'https://example.com/logo/test.png',
})
})
it('should refresh ui settings after save or upload', () => {
const saveSpy = jest.spyOn(configService, 'saveConfig')
const initSpy = jest.spyOn(settingService, 'initializeSettings')
saveSpy.mockReturnValueOnce(
of({ output_type: OutputTypeConfig.PDF_A } as any)
)
component.saveConfig()
expect(initSpy).toHaveBeenCalled()
const uploadSpy = jest.spyOn(configService, 'uploadFile')
uploadSpy.mockReturnValueOnce(
of({ app_logo: 'https://example.com/logo/test.png' } as any)
)
component.uploadFile(new File([], 'test.png'), 'app_logo')
expect(initSpy).toHaveBeenCalled()
})
}) })

View File

@ -19,6 +19,7 @@ import { ConfigService } from 'src/app/services/config.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms' import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
import { SettingsService } from 'src/app/services/settings.service'
@Component({ @Component({
selector: 'pngx-config', selector: 'pngx-config',
@ -55,7 +56,8 @@ export class ConfigComponent
constructor( constructor(
private configService: ConfigService, private configService: ConfigService,
private toastService: ToastService private toastService: ToastService,
private settingsService: SettingsService
) { ) {
super() super()
this.configForm.addControl('id', new FormControl()) this.configForm.addControl('id', new FormControl())
@ -145,6 +147,7 @@ export class ConfigComponent
this.loading = false this.loading = false
this.initialize(config) this.initialize(config)
this.store.next(config) this.store.next(config)
this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`Configuration updated`) this.toastService.showInfo($localize`Configuration updated`)
}, },
error: (e) => { error: (e) => {
@ -160,4 +163,27 @@ export class ConfigComponent
public discardChanges() { public discardChanges() {
this.configForm.reset(this.initialConfig) this.configForm.reset(this.initialConfig)
} }
public uploadFile(file: File, key: string) {
this.loading = true
this.configService
.uploadFile(file, this.configForm.value['id'], key)
.pipe(takeUntil(this.unsubscribeNotifier), first())
.subscribe({
next: (config) => {
this.loading = false
this.initialize(config)
this.store.next(config)
this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`File successfully updated`)
},
error: (e) => {
this.loading = false
this.toastService.showError(
$localize`An error occurred uploading file`,
e
)
},
})
}
} }

View File

@ -4,15 +4,25 @@
(click)="isMenuCollapsed = !isMenuCollapsed"> (click)="isMenuCollapsed = !isMenuCollapsed">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<a class="navbar-brand col-auto col-md-3 col-lg-2 me-0 px-3 py-3 order-sm-0" <a class="navbar-brand d-flex col-auto col-md-3 col-lg-2 me-0 px-3 py-3 order-sm-0"
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-auto col-md-3 col-lg-2'" routerLink="/dashboard" [ngClass]="{ 'slim': slimSidebarEnabled, 'd-flex col-auto col-md-3 col-lg-2' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
routerLink="/dashboard"
tourAnchor="tour.intro"> tourAnchor="tour.intro">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" class="me-2" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" class="me-2" fill="currentColor">
<path <path
d="M194.7,0C164.22,70.94,17.64,79.74,64.55,194.06c.58,1.47-10.85,17-18.47,29.9-1.76-6.45-3.81-13.48-3.52-14.07,38.11-45.14-27.26-70.65-30.78-107.58C-4.64,131.62-10.5,182.92,39,212.53c.3,0,2.64,11.14,3.81,16.71a58.55,58.55,0,0,0-2.93,6.45c-1.17,2.93,7.62,2.64,7.62,3.22.88-.29,21.7-36.93,22.28-37.23C187.67,174.72,208.48,68.6,194.7,0ZM134.61,74.75C79.5,124,70.12,160.64,71.88,178.53,53.41,134.85,107.64,86.77,134.61,74.75ZM28.2,145.11c10.55,9.67,28.14,39.28,13.19,56.57C44.91,193.77,46.08,175.89,28.2,145.11Z" d="M194.7,0C164.22,70.94,17.64,79.74,64.55,194.06c.58,1.47-10.85,17-18.47,29.9-1.76-6.45-3.81-13.48-3.52-14.07,38.11-45.14-27.26-70.65-30.78-107.58C-4.64,131.62-10.5,182.92,39,212.53c.3,0,2.64,11.14,3.81,16.71a58.55,58.55,0,0,0-2.93,6.45c-1.17,2.93,7.62,2.64,7.62,3.22.88-.29,21.7-36.93,22.28-37.23C187.67,174.72,208.48,68.6,194.7,0ZM134.61,74.75C79.5,124,70.12,160.64,71.88,178.53,53.41,134.85,107.64,86.77,134.61,74.75ZM28.2,145.11c10.55,9.67,28.14,39.28,13.19,56.57C44.91,193.77,46.08,175.89,28.2,145.11Z"
transform="translate(0 0)" /> transform="translate(0 0)" />
</svg> </svg>
<span class="ms-2" [class.visually-hidden]="slimSidebarEnabled" i18n="app title">Paperless-ngx</span> <div class="ms-2 d-inline-block" [class.visually-hidden]="slimSidebarEnabled">
@if (customAppTitle?.length) {
<div class="d-flex flex-column align-items-start">
<span class="title">{{customAppTitle}}</span>
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
</div>
} @else {
Paperless-ngx
}
</div>
</a> </a>
<div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1" <div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">

View File

@ -217,9 +217,16 @@ main {
*/ */
.navbar-brand { .navbar-brand {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
font-size: 1rem; font-size: 1rem;
.flex-column {
padding: 0.15rem 0;
}
.byline {
font-size: 0.5rem;
letter-spacing: 0.1rem;
}
} }
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {

View File

@ -102,6 +102,10 @@ export class AppFrameComponent
}, 200) // slightly longer than css animation for slim sidebar }, 200) // slightly longer than css animation for slim sidebar
} }
get customAppTitle(): string {
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
}
get slimSidebarEnabled(): boolean { get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR) return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
} }

View File

@ -12,8 +12,8 @@
} }
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled" i18n> <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;" >Cancel</span> <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button> </button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled"> <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
<span> <span>

View File

@ -37,6 +37,12 @@ export class ConfirmDialogComponent {
@Input() @Input()
alternativeBtnCaption alternativeBtnCaption
@Input()
cancelBtnClass = 'btn-outline-secondary'
@Input()
cancelBtnCaption = $localize`Cancel`
@Input() @Input()
buttonsEnabled = true buttonsEnabled = true

View File

@ -0,0 +1,37 @@
<div class="mb-3" [class.pb-3]="error">
<div class="row">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
@if (title) {
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
}
@if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg>&nbsp;<ng-container i18n>Remove</ng-container>
</button>
}
</div>
<div class="input-group" [class.col-md-9]="horizontal" [class.is-invalid]="error">
<input #fileInput type="file" class="form-control" [id]="inputId" (change)="onFile($event)" [disabled]="disabled">
<button class="btn btn-outline-primary py-0" type="button" (click)="uploadClicked()" [disabled]="disabled || !file" i18n>Upload</button>
</div>
@if (filename) {
<div class="form-text d-flex align-items-center">
<span class="text-muted">{{filename}}</span>
<button type="button" class="btn btn-link btn-sm text-danger ms-2" (click)="clear()">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg><small class="ms-1" i18n>Remove</small>
</button>
</div>
}
<input #inputField type="hidden" class="form-control small" [(ngModel)]="value" [disabled]="true">
@if (hint) {
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
}
<div class="invalid-feedback position-absolute top-100">
{{error}}
</div>
</div>
</div>

View File

@ -0,0 +1,41 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FileComponent } from './file.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
describe('FileComponent', () => {
let component: FileComponent
let fixture: ComponentFixture<FileComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [FileComponent],
imports: [FormsModule, ReactiveFormsModule, HttpClientTestingModule],
}).compileComponents()
fixture = TestBed.createComponent(FileComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should update file on change', () => {
const event = { target: { files: [new File([], 'test.png')] } }
component.onFile(event as any)
expect(component.file.name).toEqual('test.png')
})
it('should get filename', () => {
component.value = 'https://example.com:8000/logo/filename.svg'
expect(component.filename).toEqual('filename.svg')
})
it('should fire upload event', () => {
let firedFile
component.file = new File([], 'test.png')
component.upload.subscribe((file) => (firedFile = file))
component.uploadClicked()
expect(firedFile.name).toEqual('test.png')
expect(component.file).toBeUndefined()
})
})

View File

@ -0,0 +1,53 @@
import {
Component,
ElementRef,
EventEmitter,
Output,
ViewChild,
forwardRef,
} from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { AbstractInputComponent } from '../abstract-input'
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => FileComponent),
multi: true,
},
],
selector: 'pngx-input-file',
templateUrl: './file.component.html',
styleUrl: './file.component.scss',
})
export class FileComponent extends AbstractInputComponent<string> {
@Output()
upload = new EventEmitter<File>()
public file: File
@ViewChild('fileInput') fileInput: ElementRef
get filename(): string {
return this.value
? this.value.substring(this.value.lastIndexOf('/') + 1)
: null
}
onFile(event: Event) {
this.file = (event.target as HTMLInputElement).files[0]
}
uploadClicked() {
this.upload.emit(this.file)
this.clear()
}
clear() {
this.file = undefined
this.fileInput.nativeElement.value = null
this.writeValue(null)
this.onChange(null)
}
}

View File

@ -1,3 +1,6 @@
@if (customLogo) {
<img src="{{customLogo}}" height="100%" width="100%" [attr.style]="'height:'+height" />
} @else {
<svg [class]="getClasses()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" [attr.style]="'height:'+height"> <svg [class]="getClasses()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" [attr.style]="'height:'+height">
<path class="leaf" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z" transform="translate(0)" style="fill:#17541f"/> <path class="leaf" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z" transform="translate(0)" style="fill:#17541f"/>
<g class="text" style="fill:#000"> <g class="text" style="fill:#000">
@ -16,3 +19,4 @@
<path d="M757.6,293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5,39.2-21.1,76.4-37.6,111.3c-9.9,20.8-21.1,40.6-33.6,59.4v207.2h88.9 V521.5h72c25.2,0,47.8-5.4,67.8-16.2s35.7-25.6,47.1-44.2c11.4-18.7,17.1-39.1,17.1-61.3c0.1-22.7-5.6-43.3-17-61.9 C793.3,319.2,777.6,304.5,757.6,293.7z M716.6,434.3c-9.3,8.9-21.6,13.3-36.7,13.3l-62.2,0.4v-92.5l62.2-0.4 c15.1,0,27.3,4.4,36.7,13.3c9.4,8.9,14,19.9,14,32.9C730.6,414.5,726,425.4,716.6,434.3z" transform="translate(0)"/> <path d="M757.6,293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5,39.2-21.1,76.4-37.6,111.3c-9.9,20.8-21.1,40.6-33.6,59.4v207.2h88.9 V521.5h72c25.2,0,47.8-5.4,67.8-16.2s35.7-25.6,47.1-44.2c11.4-18.7,17.1-39.1,17.1-61.3c0.1-22.7-5.6-43.3-17-61.9 C793.3,319.2,777.6,304.5,757.6,293.7z M716.6,434.3c-9.3,8.9-21.6,13.3-36.7,13.3l-62.2,0.4v-92.5l62.2-0.4 c15.1,0,27.3,4.4,36.7,13.3c9.4,8.9,14,19.9,14,32.9C730.6,414.5,726,425.4,716.6,434.3z" transform="translate(0)"/>
</g> </g>
</svg> </svg>
}

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@ -2,15 +2,21 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { LogoComponent } from './logo.component' import { LogoComponent } from './logo.component'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { SettingsService } from 'src/app/services/settings.service'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
describe('LogoComponent', () => { describe('LogoComponent', () => {
let component: LogoComponent let component: LogoComponent
let fixture: ComponentFixture<LogoComponent> let fixture: ComponentFixture<LogoComponent>
let settingsService: SettingsService
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [LogoComponent], declarations: [LogoComponent],
imports: [HttpClientTestingModule],
}) })
settingsService = TestBed.inject(SettingsService)
fixture = TestBed.createComponent(LogoComponent) fixture = TestBed.createComponent(LogoComponent)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
@ -33,4 +39,9 @@ describe('LogoComponent', () => {
'height:10em' 'height:10em'
) )
}) })
it('should support getting custom logo', () => {
settingsService.set(SETTINGS_KEYS.APP_LOGO, '/logo/test.png')
expect(component.customLogo).toEqual('http://localhost:8000/logo/test.png')
})
}) })

View File

@ -1,4 +1,7 @@
import { Component, Input } from '@angular/core' import { Component, Input } from '@angular/core'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from 'src/app/services/settings.service'
import { environment } from 'src/environments/environment'
@Component({ @Component({
selector: 'pngx-logo', selector: 'pngx-logo',
@ -12,6 +15,17 @@ export class LogoComponent {
@Input() @Input()
height = '6em' height = '6em'
get customLogo(): string {
return this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
? environment.apiBaseUrl.replace(
/\/api\/$/,
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
)
: null
}
constructor(private settingsService: SettingsService) {}
getClasses() { getClasses() {
return ['logo'].concat(this.extra_classes).join(' ') return ['logo'].concat(this.extra_classes).join(' ')
} }

View File

@ -5,13 +5,13 @@ import { ComponentWithPermissions } from '../with-permissions/with-permissions.c
import { TourService } from 'ngx-ui-tour-ng-bootstrap' import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { import {
CdkDragDrop, CdkDragDrop,
CdkDragEnd, CdkDragEnd,
CdkDragStart, CdkDragStart,
moveItemInArray, moveItemInArray,
} from '@angular/cdk/drag-drop' } from '@angular/cdk/drag-drop'
import { environment } from 'src/environments/environment'
@Component({ @Component({
selector: 'pngx-dashboard', selector: 'pngx-dashboard',
@ -35,9 +35,9 @@ export class DashboardComponent extends ComponentWithPermissions {
get subtitle() { get subtitle() {
if (this.settingsService.displayName) { if (this.settingsService.displayName) {
return $localize`Hello ${this.settingsService.displayName}, welcome to Paperless-ngx` return $localize`Hello ${this.settingsService.displayName}, welcome to ${environment.appTitle}`
} else { } else {
return $localize`Welcome to Paperless-ngx` return $localize`Welcome to ${environment.appTitle}`
} }
} }

View File

@ -79,8 +79,9 @@ const doc: Document = {
storage_path: 31, storage_path: 31,
tags: [41, 42, 43], tags: [41, 42, 43],
content: 'text content', content: 'text content',
added: new Date(), added: new Date('May 4, 2014 03:24:00'),
created: new Date(), created: new Date('May 4, 2014 03:24:00'),
modified: new Date('May 4, 2014 03:24:00'),
archive_serial_number: null, archive_serial_number: null,
original_file_name: 'file.pdf', original_file_name: 'file.pdf',
owner: null, owner: null,
@ -966,6 +967,26 @@ describe('DocumentDetailComponent', () => {
expect(errorSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled()
}) })
it('should warn when open document does not match doc retrieved from backend on init', () => {
const modalSpy = jest.spyOn(modalService, 'open')
const openDoc = Object.assign({}, doc)
// simulate a document being modified elsewhere and db updated
doc.modified = new Date()
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
count: customFields.length,
all: customFields.map((f) => f.id),
results: customFields,
})
)
fixture.detectChanges() // calls ngOnInit
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent, {
backdrop: 'static',
})
})
function initNormally() { function initNormally() {
jest jest
.spyOn(activatedRoute, 'paramMap', 'get') .spyOn(activatedRoute, 'paramMap', 'get')

View File

@ -297,7 +297,23 @@ export class DocumentDetailComponent
const openDocument = this.openDocumentService.getOpenDocument( const openDocument = this.openDocumentService.getOpenDocument(
this.documentId this.documentId
) )
if (openDocument) { if (openDocument) {
if (
new Date(doc.modified) > new Date(openDocument.modified) &&
!this.modalService.hasOpenModals()
) {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Document changes detected`
modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
modal.componentInstance.cancelBtnCaption = $localize`Ok`
modal.componentInstance.cancelBtnClass = 'btn-primary'
modal.componentInstance.btnClass = 'visually-hidden'
}
if (this.documentForm.dirty) { if (this.documentForm.dirty) {
Object.assign(openDocument, this.documentForm.value) Object.assign(openDocument, this.documentForm.value)
openDocument['owner'] = openDocument['owner'] =

View File

@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NotFoundComponent } from './not-found.component' import { NotFoundComponent } from './not-found.component'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { LogoComponent } from '../common/logo/logo.component' import { LogoComponent } from '../common/logo/logo.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
describe('NotFoundComponent', () => { describe('NotFoundComponent', () => {
let component: NotFoundComponent let component: NotFoundComponent
@ -10,6 +11,7 @@ describe('NotFoundComponent', () => {
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [NotFoundComponent, LogoComponent], declarations: [NotFoundComponent, LogoComponent],
imports: [HttpClientTestingModule],
}).compileComponents() }).compileComponents()
fixture = TestBed.createComponent(NotFoundComponent) fixture = TestBed.createComponent(NotFoundComponent)

View File

@ -43,9 +43,11 @@ export enum ConfigOptionType {
Select = 'select', Select = 'select',
Boolean = 'boolean', Boolean = 'boolean',
JSON = 'json', JSON = 'json',
File = 'file',
} }
export const ConfigCategory = { export const ConfigCategory = {
General: $localize`General Settings`,
OCR: $localize`OCR Settings`, OCR: $localize`OCR Settings`,
} }
@ -164,6 +166,20 @@ export const PaperlessConfigOptions: ConfigOption[] = [
config_key: 'PAPERLESS_OCR_USER_ARGS', config_key: 'PAPERLESS_OCR_USER_ARGS',
category: ConfigCategory.OCR, category: ConfigCategory.OCR,
}, },
{
key: 'app_logo',
title: $localize`Application Logo`,
type: ConfigOptionType.File,
config_key: 'PAPERLESS_APP_LOGO',
category: ConfigCategory.General,
},
{
key: 'app_title',
title: $localize`Application Title`,
type: ConfigOptionType.String,
config_key: 'PAPERLESS_APP_TITLE',
category: ConfigCategory.General,
},
] ]
export interface PaperlessConfig extends ObjectWithId { export interface PaperlessConfig extends ObjectWithId {
@ -180,4 +196,6 @@ export interface PaperlessConfig extends ObjectWithId {
max_image_pixels: number max_image_pixels: number
color_conversion_strategy: ColorConvertConfig color_conversion_strategy: ColorConvertConfig
user_args: object user_args: object
app_logo: string
app_title: string
} }

View File

@ -14,6 +14,8 @@ export interface UiSetting {
export const SETTINGS_KEYS = { export const SETTINGS_KEYS = {
LANGUAGE: 'language', LANGUAGE: 'language',
APP_LOGO: 'app_logo',
APP_TITLE: 'app_title',
// maintain old general-settings: for backwards compatibility // maintain old general-settings: for backwards compatibility
BULK_EDIT_CONFIRMATION_DIALOGS: BULK_EDIT_CONFIRMATION_DIALOGS:
'general-settings:bulk-edit:confirmation-dialogs', 'general-settings:bulk-edit:confirmation-dialogs',
@ -194,4 +196,14 @@ export const SETTINGS: UiSetting[] = [
type: 'array', type: 'array',
default: [], default: [],
}, },
{
key: SETTINGS_KEYS.APP_LOGO,
type: 'string',
default: '',
},
{
key: SETTINGS_KEYS.APP_TITLE,
type: 'string',
default: '',
},
] ]

View File

@ -39,4 +39,26 @@ describe('ConfigService', () => {
) )
expect(req.request.method).toEqual('PATCH') expect(req.request.method).toEqual('PATCH')
}) })
it('should support upload file with form data', () => {
service.uploadFile(new File([], 'test.png'), 1, 'app_logo').subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}config/1/`
)
expect(req.request.method).toEqual('PATCH')
expect(req.request.body).not.toBeNull()
})
it('should not pass string app_logo', () => {
service
.saveConfig({
id: 1,
app_logo: '/logo/foobar.png',
} as PaperlessConfig)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}config/1/`
)
expect(req.request.body).toEqual({ id: 1 })
})
}) })

View File

@ -20,8 +20,22 @@ export class ConfigService {
} }
saveConfig(config: PaperlessConfig): Observable<PaperlessConfig> { saveConfig(config: PaperlessConfig): Observable<PaperlessConfig> {
// dont pass string
if (typeof config.app_logo === 'string') delete config.app_logo
return this.http return this.http
.patch<PaperlessConfig>(`${this.baseUrl}${config.id}/`, config) .patch<PaperlessConfig>(`${this.baseUrl}${config.id}/`, config)
.pipe(first()) .pipe(first())
} }
uploadFile(
file: File,
configID: number,
configKey: string
): Observable<PaperlessConfig> {
let formData = new FormData()
formData.append(configKey, file, file.name)
return this.http
.patch<PaperlessConfig>(`${this.baseUrl}${configID}/`, formData)
.pipe(first())
}
} }

View File

@ -301,4 +301,16 @@ describe('SettingsService', () => {
.expectOne(`${environment.apiBaseUrl}ui_settings/`) .expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings) .flush(ui_settings)
}) })
it('should update environment app title if set', () => {
const settings = Object.assign({}, ui_settings)
settings.settings['app_title'] = 'FooBar'
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
)
req.flush(settings)
expect(environment.appTitle).toEqual('FooBar')
// post for migrate
httpTestingController.expectOne(`${environment.apiBaseUrl}ui_settings/`)
})
}) })

View File

@ -270,6 +270,9 @@ export class SettingsService {
first(), first(),
tap((uisettings) => { tap((uisettings) => {
Object.assign(this.settings, uisettings.settings) Object.assign(this.settings, uisettings.settings)
if (this.get(SETTINGS_KEYS.APP_TITLE)?.length) {
environment.appTitle = this.get(SETTINGS_KEYS.APP_TITLE)
}
this.maybeMigrateSettings() this.maybeMigrateSettings()
// to update lang cookie // to update lang cookie
if (this.settings['language']?.length) if (this.settings['language']?.length)

View File

@ -11,6 +11,9 @@ $grid-breakpoints: (
xxxl: 2400px xxxl: 2400px
); );
$form-file-button-bg: var(--bs-body-bg);
$form-file-button-hover-bg: var(--pngx-bg-alt);
@import "node_modules/bootstrap/scss/bootstrap"; @import "node_modules/bootstrap/scss/bootstrap";
@import "theme"; @import "theme";
@import "~@ng-select/ng-select/themes/default.theme.css"; @import "~@ng-select/ng-select/themes/default.theme.css";

View File

@ -3,7 +3,6 @@ import re
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Final
from typing import Optional from typing import Optional
from django.conf import settings from django.conf import settings
@ -15,8 +14,9 @@ from PIL import Image
from documents.converters import convert_from_tiff_to_pdf from documents.converters import convert_from_tiff_to_pdf
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides from documents.plugins.base import ConsumeTaskPlugin
from documents.data_models import DocumentSource from documents.plugins.base import StopConsumeTaskError
from documents.plugins.helpers import ProgressStatusOptions
from documents.utils import copy_basic_file_stats from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats from documents.utils import copy_file_with_basic_stats
@ -26,7 +26,7 @@ logger = logging.getLogger("paperless.barcodes")
@dataclass(frozen=True) @dataclass(frozen=True)
class Barcode: class Barcode:
""" """
Holds the information about a single barcode and its location Holds the information about a single barcode and its location in a document
""" """
page: int page: int
@ -49,77 +49,111 @@ class Barcode:
return self.value.startswith(settings.CONSUMER_ASN_BARCODE_PREFIX) return self.value.startswith(settings.CONSUMER_ASN_BARCODE_PREFIX)
class BarcodeReader: class BarcodePlugin(ConsumeTaskPlugin):
def __init__(self, filepath: Path, mime_type: str) -> None: NAME: str = "BarcodePlugin"
self.file: Final[Path] = filepath
self.mime: Final[str] = mime_type
self.pdf_file: Path = self.file
self.barcodes: list[Barcode] = []
self._tiff_conversion_done = False
self.temp_dir: Optional[tempfile.TemporaryDirectory] = None
@property
def able_to_run(self) -> bool:
"""
Able to run if:
- ASN from barcode detection is enabled or
- Barcode support is enabled and the mime type is supported
"""
if settings.CONSUMER_BARCODE_TIFF_SUPPORT: if settings.CONSUMER_BARCODE_TIFF_SUPPORT:
self.SUPPORTED_FILE_MIMES = {"application/pdf", "image/tiff"} supported_mimes = {"application/pdf", "image/tiff"}
else: else:
self.SUPPORTED_FILE_MIMES = {"application/pdf"} supported_mimes = {"application/pdf"}
def __enter__(self): return (
if self.supported_mime_type: settings.CONSUMER_ENABLE_ASN_BARCODE or settings.CONSUMER_ENABLE_BARCODES
self.temp_dir = tempfile.TemporaryDirectory(prefix="paperless-barcodes") ) and self.input_doc.mime_type in supported_mimes
return self
def __exit__(self, exc_type, exc_val, exc_tb): def setup(self):
if self.temp_dir is not None: self.temp_dir = tempfile.TemporaryDirectory(
self.temp_dir.cleanup() dir=self.base_tmp_dir,
self.temp_dir = None prefix="barcode",
)
self.pdf_file = self.input_doc.original_file
self._tiff_conversion_done = False
self.barcodes: list[Barcode] = []
@property def run(self) -> Optional[str]:
def supported_mime_type(self) -> bool: # Maybe do the conversion of TIFF to PDF
""" self.convert_from_tiff_to_pdf()
Return True if the given mime type is supported for barcodes, false otherwise
"""
return self.mime in self.SUPPORTED_FILE_MIMES
@property # Locate any barcodes in the files
def asn(self) -> Optional[int]:
"""
Search the parsed barcodes for any ASNs.
The first barcode that starts with CONSUMER_ASN_BARCODE_PREFIX
is considered the ASN to be used.
Returns the detected ASN (or None)
"""
asn = None
if not self.supported_mime_type:
return None
# Ensure the barcodes have been read
self.detect() self.detect()
# get the first barcode that starts with CONSUMER_ASN_BARCODE_PREFIX # Update/overwrite an ASN if possible
asn_text = next( located_asn = self.asn
(x.value for x in self.barcodes if x.is_asn), if located_asn is not None:
None, logger.info(f"Found ASN in barcode: {located_asn}")
self.metadata.asn = located_asn
separator_pages = self.get_separation_pages()
if not separator_pages:
return "No pages to split on!"
# We have pages to split against
# Note this does NOT use the base_temp_dir, as that will be removed
tmp_dir = Path(
tempfile.mkdtemp(
dir=settings.SCRATCH_DIR,
prefix="paperless-barcode-split-",
),
).resolve()
from documents import tasks
# Create the split document tasks
for new_document in self.separate_pages(separator_pages):
copy_file_with_basic_stats(new_document, tmp_dir / new_document.name)
task = tasks.consume_file.delay(
ConsumableDocument(
# Same source, for templates
source=self.input_doc.source,
mailrule_id=self.input_doc.mailrule_id,
# Can't use same folder or the consume might grab it again
original_file=(tmp_dir / new_document.name).resolve(),
),
# All the same metadata
self.metadata,
) )
logger.info(f"Created new task {task.id} for {new_document.name}")
if asn_text: # This file is now two or more files
logger.debug(f"Found ASN Barcode: {asn_text}") self.input_doc.original_file.unlink()
# remove the prefix and remove whitespace
asn_text = asn_text[len(settings.CONSUMER_ASN_BARCODE_PREFIX) :].strip()
# remove non-numeric parts of the remaining string msg = "Barcode splitting complete!"
asn_text = re.sub(r"\D", "", asn_text)
# now, try parsing the ASN number # Update the progress to complete
try: self.status_mgr.send_progress(ProgressStatusOptions.SUCCESS, msg, 100, 100)
asn = int(asn_text)
except ValueError as e:
logger.warning(f"Failed to parse ASN number because: {e}")
return asn # Request the consume task stops
raise StopConsumeTaskError(msg)
def cleanup(self) -> None:
self.temp_dir.cleanup()
def convert_from_tiff_to_pdf(self):
"""
May convert a TIFF image into a PDF, if the input is a TIFF and
the TIFF has not been made into a PDF
"""
# Nothing to do, pdf_file is already assigned correctly
if self.input_doc.mime_type != "image/tiff" or self._tiff_conversion_done:
return
self.pdf_file = convert_from_tiff_to_pdf(
self.input_doc.original_file,
Path(self.temp_dir.name),
)
self._tiff_conversion_done = True
@staticmethod @staticmethod
def read_barcodes_zxing(image: Image) -> list[str]: def read_barcodes_zxing(image: Image.Image) -> list[str]:
barcodes = [] barcodes = []
import zxingcpp import zxingcpp
@ -135,7 +169,7 @@ class BarcodeReader:
return barcodes return barcodes
@staticmethod @staticmethod
def read_barcodes_pyzbar(image: Image) -> list[str]: def read_barcodes_pyzbar(image: Image.Image) -> list[str]:
barcodes = [] barcodes = []
from pyzbar import pyzbar from pyzbar import pyzbar
@ -154,18 +188,6 @@ class BarcodeReader:
return barcodes return barcodes
def convert_from_tiff_to_pdf(self):
"""
May convert a TIFF image into a PDF, if the input is a TIFF and
the TIFF has not been made into a PDF
"""
# Nothing to do, pdf_file is already assigned correctly
if self.mime != "image/tiff" or self._tiff_conversion_done:
return
self._tiff_conversion_done = True
self.pdf_file = convert_from_tiff_to_pdf(self.file, Path(self.temp_dir.name))
def detect(self) -> None: def detect(self) -> None:
""" """
Scan all pages of the PDF as images, updating barcodes and the pages Scan all pages of the PDF as images, updating barcodes and the pages
@ -218,10 +240,45 @@ class BarcodeReader:
# This file is really borked, allow the consumption to continue # This file is really borked, allow the consumption to continue
# but it may fail further on # but it may fail further on
except Exception as e: # pragma: no cover except Exception as e: # pragma: no cover
logger.exception( logger.warning(
f"Exception during barcode scanning: {e}", f"Exception during barcode scanning: {e}",
) )
@property
def asn(self) -> Optional[int]:
"""
Search the parsed barcodes for any ASNs.
The first barcode that starts with CONSUMER_ASN_BARCODE_PREFIX
is considered the ASN to be used.
Returns the detected ASN (or None)
"""
asn = None
# Ensure the barcodes have been read
self.detect()
# get the first barcode that starts with CONSUMER_ASN_BARCODE_PREFIX
asn_text = next(
(x.value for x in self.barcodes if x.is_asn),
None,
)
if asn_text:
logger.debug(f"Found ASN Barcode: {asn_text}")
# remove the prefix and remove whitespace
asn_text = asn_text[len(settings.CONSUMER_ASN_BARCODE_PREFIX) :].strip()
# remove non-numeric parts of the remaining string
asn_text = re.sub(r"\D", "", asn_text)
# now, try parsing the ASN number
try:
asn = int(asn_text)
except ValueError as e:
logger.warning(f"Failed to parse ASN number because: {e}")
return asn
def get_separation_pages(self) -> dict[int, bool]: def get_separation_pages(self) -> dict[int, bool]:
""" """
Search the parsed barcodes for separators and returns a dict of page Search the parsed barcodes for separators and returns a dict of page
@ -251,7 +308,7 @@ class BarcodeReader:
""" """
document_paths = [] document_paths = []
fname = self.file.stem fname = self.input_doc.original_file.stem
with Pdf.open(self.pdf_file) as input_pdf: with Pdf.open(self.pdf_file) as input_pdf:
# Start with an empty document # Start with an empty document
current_document: list[Page] = [] current_document: list[Page] = []
@ -292,58 +349,8 @@ class BarcodeReader:
with open(savepath, "wb") as out: with open(savepath, "wb") as out:
dst.save(out) dst.save(out)
copy_basic_file_stats(self.file, savepath) copy_basic_file_stats(self.input_doc.original_file, savepath)
document_paths.append(savepath) document_paths.append(savepath)
return document_paths return document_paths
def separate(
self,
source: DocumentSource,
overrides: DocumentMetadataOverrides,
) -> bool:
"""
Separates the document, based on barcodes and configuration, creating new
documents as required in the appropriate location.
Returns True if a split happened, False otherwise
"""
# Do nothing
if not self.supported_mime_type:
logger.warning(f"Unsupported file format for barcode reader: {self.mime}")
return False
# Does nothing unless needed
self.convert_from_tiff_to_pdf()
# Actually read the codes, if any
self.detect()
separator_pages = self.get_separation_pages()
# Also do nothing
if not separator_pages:
logger.warning("No pages to split on!")
return False
tmp_dir = Path(tempfile.mkdtemp(prefix="paperless-barcode-split-")).resolve()
from documents import tasks
# Create the split document tasks
for new_document in self.separate_pages(separator_pages):
copy_file_with_basic_stats(new_document, tmp_dir / new_document.name)
tasks.consume_file.delay(
ConsumableDocument(
# Same source, for templates
source=source,
# Can't use same folder or the consume might grab it again
original_file=(tmp_dir / new_document.name).resolve(),
),
# All the same metadata
overrides,
)
logger.info("Barcode splitting complete!")
return True

View File

@ -21,7 +21,6 @@ from filelock import FileLock
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from documents.classifier import load_classifier from documents.classifier import load_classifier
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentMetadataOverrides
from documents.file_handling import create_source_path_directory from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_unique_filename from documents.file_handling import generate_unique_filename
@ -42,12 +41,83 @@ from documents.parsers import ParseError
from documents.parsers import get_parser_class_for_mime_type from documents.parsers import get_parser_class_for_mime_type
from documents.parsers import parse_date from documents.parsers import parse_date
from documents.permissions import set_permissions_for_object from documents.permissions import set_permissions_for_object
from documents.plugins.base import AlwaysRunPluginMixin
from documents.plugins.base import ConsumeTaskPlugin
from documents.plugins.base import NoCleanupPluginMixin
from documents.plugins.base import NoSetupPluginMixin
from documents.signals import document_consumption_finished from documents.signals import document_consumption_finished
from documents.signals import document_consumption_started from documents.signals import document_consumption_started
from documents.utils import copy_basic_file_stats from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats from documents.utils import copy_file_with_basic_stats
class WorkflowTriggerPlugin(
NoCleanupPluginMixin,
NoSetupPluginMixin,
AlwaysRunPluginMixin,
ConsumeTaskPlugin,
):
NAME: str = "WorkflowTriggerPlugin"
def run(self) -> Optional[str]:
"""
Get overrides from matching workflows
"""
overrides = DocumentMetadataOverrides()
for workflow in Workflow.objects.filter(enabled=True).order_by("order"):
template_overrides = DocumentMetadataOverrides()
if document_matches_workflow(
self.input_doc,
workflow,
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
):
for action in workflow.actions.all():
if action.assign_title is not None:
template_overrides.title = action.assign_title
if action.assign_tags is not None:
template_overrides.tag_ids = [
tag.pk for tag in action.assign_tags.all()
]
if action.assign_correspondent is not None:
template_overrides.correspondent_id = (
action.assign_correspondent.pk
)
if action.assign_document_type is not None:
template_overrides.document_type_id = (
action.assign_document_type.pk
)
if action.assign_storage_path is not None:
template_overrides.storage_path_id = (
action.assign_storage_path.pk
)
if action.assign_owner is not None:
template_overrides.owner_id = action.assign_owner.pk
if action.assign_view_users is not None:
template_overrides.view_users = [
user.pk for user in action.assign_view_users.all()
]
if action.assign_view_groups is not None:
template_overrides.view_groups = [
group.pk for group in action.assign_view_groups.all()
]
if action.assign_change_users is not None:
template_overrides.change_users = [
user.pk for user in action.assign_change_users.all()
]
if action.assign_change_groups is not None:
template_overrides.change_groups = [
group.pk for group in action.assign_change_groups.all()
]
if action.assign_custom_fields is not None:
template_overrides.custom_field_ids = [
field.pk for field in action.assign_custom_fields.all()
]
overrides.update(template_overrides)
self.metadata.update(overrides)
class ConsumerError(Exception): class ConsumerError(Exception):
pass pass
@ -602,70 +672,6 @@ class Consumer(LoggingMixin):
return document return document
def get_workflow_overrides(
self,
input_doc: ConsumableDocument,
) -> DocumentMetadataOverrides:
"""
Get overrides from matching workflows
"""
overrides = DocumentMetadataOverrides()
for workflow in Workflow.objects.filter(enabled=True).order_by("order"):
template_overrides = DocumentMetadataOverrides()
if document_matches_workflow(
input_doc,
workflow,
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
):
for action in workflow.actions.all():
self.log.info(
f"Applying overrides in {action} from {workflow}",
)
if action.assign_title is not None:
template_overrides.title = action.assign_title
if action.assign_tags is not None:
template_overrides.tag_ids = [
tag.pk for tag in action.assign_tags.all()
]
if action.assign_correspondent is not None:
template_overrides.correspondent_id = (
action.assign_correspondent.pk
)
if action.assign_document_type is not None:
template_overrides.document_type_id = (
action.assign_document_type.pk
)
if action.assign_storage_path is not None:
template_overrides.storage_path_id = (
action.assign_storage_path.pk
)
if action.assign_owner is not None:
template_overrides.owner_id = action.assign_owner.pk
if action.assign_view_users is not None:
template_overrides.view_users = [
user.pk for user in action.assign_view_users.all()
]
if action.assign_view_groups is not None:
template_overrides.view_groups = [
group.pk for group in action.assign_view_groups.all()
]
if action.assign_change_users is not None:
template_overrides.change_users = [
user.pk for user in action.assign_change_users.all()
]
if action.assign_change_groups is not None:
template_overrides.change_groups = [
group.pk for group in action.assign_change_groups.all()
]
if action.assign_custom_fields is not None:
template_overrides.custom_field_ids = [
field.pk for field in action.assign_custom_fields.all()
]
overrides.update(template_overrides)
return overrides
def _parse_title_placeholders(self, title: str) -> str: def _parse_title_placeholders(self, title: str) -> str:
local_added = timezone.localtime(timezone.now()) local_added = timezone.localtime(timezone.now())

View File

@ -3,24 +3,41 @@ import logging
import os import os
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Final
from typing import Optional
from django.conf import settings from django.conf import settings
from pikepdf import Pdf from pikepdf import Pdf
from documents.consumer import ConsumerError from documents.consumer import ConsumerError
from documents.converters import convert_from_tiff_to_pdf from documents.converters import convert_from_tiff_to_pdf
from documents.data_models import ConsumableDocument from documents.plugins.base import ConsumeTaskPlugin
from documents.plugins.base import NoCleanupPluginMixin
from documents.plugins.base import NoSetupPluginMixin
from documents.plugins.base import StopConsumeTaskError
logger = logging.getLogger("paperless.double_sided") logger = logging.getLogger("paperless.double_sided")
# Hardcoded for now, could be made a configurable setting if needed # Hardcoded for now, could be made a configurable setting if needed
TIMEOUT_MINUTES = 30 TIMEOUT_MINUTES: Final[int] = 30
TIMEOUT_SECONDS: Final[int] = TIMEOUT_MINUTES * 60
# Used by test cases # Used by test cases
STAGING_FILE_NAME = "double-sided-staging.pdf" STAGING_FILE_NAME = "double-sided-staging.pdf"
def collate(input_doc: ConsumableDocument) -> str: class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin):
NAME: str = "CollatePlugin"
@property
def able_to_run(self) -> bool:
return (
settings.CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED
and settings.CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME
in self.input_doc.original_file.parts
)
def run(self) -> Optional[str]:
""" """
Tries to collate pages from 2 single sided scans of a double sided Tries to collate pages from 2 single sided scans of a double sided
document. document.
@ -39,33 +56,32 @@ def collate(input_doc: ConsumableDocument) -> str:
in case of failure. in case of failure.
""" """
# Make sure scratch dir exists, Consumer might not have run yet if self.input_doc.mime_type == "application/pdf":
settings.SCRATCH_DIR.mkdir(exist_ok=True) pdf_file = self.input_doc.original_file
if input_doc.mime_type == "application/pdf":
pdf_file = input_doc.original_file
elif ( elif (
input_doc.mime_type == "image/tiff" self.input_doc.mime_type == "image/tiff"
and settings.CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT and settings.CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT
): ):
pdf_file = convert_from_tiff_to_pdf( pdf_file = convert_from_tiff_to_pdf(
input_doc.original_file, self.input_doc.original_file,
settings.SCRATCH_DIR, self.base_tmp_dir,
) )
input_doc.original_file.unlink() self.input_doc.original_file.unlink()
else: else:
raise ConsumerError("Unsupported file type for collation of double-sided scans") raise ConsumerError(
"Unsupported file type for collation of double-sided scans",
)
staging = settings.SCRATCH_DIR / STAGING_FILE_NAME staging: Path = settings.SCRATCH_DIR / STAGING_FILE_NAME
valid_staging_exists = False valid_staging_exists = False
if staging.exists(): if staging.exists():
stats = os.stat(str(staging)) stats = staging.stat()
# if the file is older than the timeout, we don't consider # if the file is older than the timeout, we don't consider
# it valid # it valid
if dt.datetime.now().timestamp() - stats.st_mtime > TIMEOUT_MINUTES * 60: if (dt.datetime.now().timestamp() - stats.st_mtime) > TIMEOUT_SECONDS:
logger.warning("Outdated double sided staging file exists, deleting it") logger.warning("Outdated double sided staging file exists, deleting it")
os.unlink(str(staging)) staging.unlink()
else: else:
valid_staging_exists = True valid_staging_exists = True
@ -88,23 +104,24 @@ def collate(input_doc: ConsumableDocument) -> str:
# Merged file has the same path, but without the # Merged file has the same path, but without the
# double-sided subdir. Therefore, it is also in the # double-sided subdir. Therefore, it is also in the
# consumption dir and will be picked up for processing # consumption dir and will be picked up for processing
old_file = input_doc.original_file old_file = self.input_doc.original_file
new_file = Path( new_file = Path(
*( *(
part part
for part in old_file.with_name( for part in old_file.with_name(
f"{old_file.stem}-collated.pdf", f"{old_file.stem}-collated.pdf",
).parts ).parts
if part != settings.CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME if part
!= settings.CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME
), ),
) )
# If the user didn't create the subdirs yet, do it for them # If the user didn't create the subdirs yet, do it for them
new_file.parent.mkdir(parents=True, exist_ok=True) new_file.parent.mkdir(parents=True, exist_ok=True)
pdf1.save(new_file) pdf1.save(new_file)
logger.info("Collated documents into new file %s", new_file) logger.info("Collated documents into new file %s", new_file)
return ( raise StopConsumeTaskError(
"Success. Even numbered pages of double sided scan collated " "Success. Even numbered pages of double sided scan collated "
"with odd pages" "with odd pages",
) )
finally: finally:
# Delete staging and recently uploaded file no matter what. # Delete staging and recently uploaded file no matter what.
@ -118,12 +135,13 @@ def collate(input_doc: ConsumableDocument) -> str:
shutil.move(pdf_file, staging) shutil.move(pdf_file, staging)
# update access to modification time so we know if the file # update access to modification time so we know if the file
# is outdated when another file gets uploaded # is outdated when another file gets uploaded
os.utime(staging, (dt.datetime.now().timestamp(),) * 2) timestamp = dt.datetime.now().timestamp()
os.utime(staging, (timestamp, timestamp))
logger.info( logger.info(
"Got scan with odd numbered pages of double-sided scan, moved it to %s", "Got scan with odd numbered pages of double-sided scan, moved it to %s",
staging, staging,
) )
return ( raise StopConsumeTaskError(
"Received odd numbered pages of double sided scan, waiting up to " "Received odd numbered pages of double sided scan, waiting up to "
f"{TIMEOUT_MINUTES} minutes for even numbered pages" f"{TIMEOUT_MINUTES} minutes for even numbered pages",
) )

View File

View File

@ -0,0 +1,131 @@
import abc
from pathlib import Path
from typing import Final
from typing import Optional
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.plugins.helpers import ProgressManager
class StopConsumeTaskError(Exception):
"""
A plugin setup or run may raise this to exit the asynchronous consume task.
Most likely, this means it has created one or more new tasks to execute instead,
such as when a barcode has been used to create new documents
"""
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
class ConsumeTaskPlugin(abc.ABC):
"""
Defines the interface for a plugin for the document consume task
Meanings as per RFC2119 (https://datatracker.ietf.org/doc/html/rfc2119)
Plugin Implementation
The plugin SHALL implement property able_to_run and methods setup, run and cleanup.
The plugin property able_to_run SHALL return True if the plugin is able to run, given the conditions, settings and document information.
The plugin property able_to_run MAY be hardcoded to return True.
The plugin setup SHOULD perform any resource creation or additional initialization needed to run the document.
The plugin setup MAY be a non-operation.
The plugin cleanup SHOULD perform resource cleanup, including in the event of an error.
The plugin cleanup MAY be a non-operation.
The plugin run SHALL perform any operations against the document or system state required for the plugin.
The plugin run MAY update the document metadata.
The plugin run MAY return an informational message.
The plugin run MAY raise StopConsumeTaskError to cease any further operations against the document.
Plugin Manager Implementation
The plugin manager SHALL provide the plugin with the input document, document metadata, progress manager and a created temporary directory.
The plugin manager SHALL execute the plugin setup, run and cleanup, in that order IF the plugin property able_to_run is True.
The plugin manager SHOULD log the return message of executing a plugin's run.
The plugin manager SHALL always execute the plugin cleanup, IF the plugin property able_to_run is True.
The plugin manager SHALL cease calling plugins and exit the task IF a plugin raises StopConsumeTaskError.
The plugin manager SHOULD return the StopConsumeTaskError message IF a plugin raises StopConsumeTaskError.
"""
NAME: str = "ConsumeTaskPlugin"
def __init__(
self,
input_doc: ConsumableDocument,
metadata: DocumentMetadataOverrides,
status_mgr: ProgressManager,
base_tmp_dir: Path,
task_id: str,
) -> None:
super().__init__()
self.input_doc = input_doc
self.metadata = metadata
self.base_tmp_dir: Final = base_tmp_dir
self.status_mgr = status_mgr
self.task_id: Final = task_id
@abc.abstractproperty
def able_to_run(self) -> bool:
"""
Return True if the conditions are met for the plugin to run, False otherwise
If False, setup(), run() and cleanup() will not be called
"""
@abc.abstractmethod
def setup(self) -> None:
"""
Allows the plugin to perform any additional setup it may need, such as creating
a temporary directory, copying a file somewhere, etc.
Executed before run()
In general, this should be the "light" work, not the bulk of processing
"""
@abc.abstractmethod
def run(self) -> Optional[str]:
"""
The bulk of plugin processing, this does whatever action the plugin is for.
Executed after setup() and before cleanup()
"""
@abc.abstractmethod
def cleanup(self) -> None:
"""
Allows the plugin to execute any cleanup it may require
Executed after run(), even in the case of error
"""
class AlwaysRunPluginMixin(ConsumeTaskPlugin):
"""
A plugin which is always able to run
"""
@property
def able_to_run(self) -> bool:
return True
class NoSetupPluginMixin(ConsumeTaskPlugin):
"""
A plugin which requires no setup
"""
def setup(self) -> None:
pass
class NoCleanupPluginMixin(ConsumeTaskPlugin):
"""
A plugin which needs to clean up no files
"""
def cleanup(self) -> None:
pass

View File

@ -0,0 +1,82 @@
import enum
from typing import TYPE_CHECKING
from typing import Optional
from typing import Union
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from channels_redis.pubsub import RedisPubSubChannelLayer
class ProgressStatusOptions(str, enum.Enum):
STARTED = "STARTED"
WORKING = "WORKING"
SUCCESS = "SUCCESS"
FAILED = "FAILED"
class ProgressManager:
"""
Handles sending of progress information via the channel layer, with proper management
of the open/close of the layer to ensure messages go out and everything is cleaned up
"""
def __init__(self, filename: str, task_id: Optional[str] = None) -> None:
self.filename = filename
self._channel: Optional[RedisPubSubChannelLayer] = None
self.task_id = task_id
def __enter__(self):
self.open()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def open(self) -> None:
"""
If not already opened, gets the default channel layer
opened and ready to send messages
"""
if self._channel is None:
self._channel = get_channel_layer()
def close(self) -> None:
"""
If it was opened, flushes the channel layer
"""
if self._channel is not None:
async_to_sync(self._channel.flush)
self._channel = None
def send_progress(
self,
status: ProgressStatusOptions,
message: str,
current_progress: int,
max_progress: int,
extra_args: Optional[dict[str, Union[str, int]]] = None,
) -> None:
# Ensure the layer is open
self.open()
# Just for IDEs
if TYPE_CHECKING:
assert self._channel is not None
payload = {
"type": "status_update",
"data": {
"filename": self.filename,
"task_id": self.task_id,
"current_progress": current_progress,
"max_progress": max_progress,
"status": status,
"message": message,
},
}
if extra_args is not None:
payload["data"].update(extra_args)
# Construct and send the update
async_to_sync(self._channel.group_send)("status_updates", payload)

View File

@ -13,6 +13,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.validators import DecimalValidator from django.core.validators import DecimalValidator
from django.core.validators import MaxLengthValidator from django.core.validators import MaxLengthValidator
from django.core.validators import integer_validator from django.core.validators import integer_validator
from django.utils import timezone
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -580,6 +581,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
custom_field_instances_to_update, custom_field_instances_to_update,
["value_document_ids"], ["value_document_ids"],
) )
Document.objects.filter(id__in=target_doc_ids).update(modified=timezone.now())
@staticmethod @staticmethod
def remove_doclink( def remove_doclink(
@ -600,6 +602,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
): ):
target_doc_field_instance.value.remove(document.id) target_doc_field_instance.value.remove(document.id)
target_doc_field_instance.save() target_doc_field_instance.save()
Document.objects.filter(id=target_doc_id).update(modified=timezone.now())
class Meta: class Meta:
model = CustomFieldInstance model = CustomFieldInstance

View File

@ -2,30 +2,30 @@ import hashlib
import logging import logging
import shutil import shutil
import uuid import uuid
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Optional from typing import Optional
import tqdm import tqdm
from asgiref.sync import async_to_sync
from celery import Task from celery import Task
from celery import shared_task from celery import shared_task
from channels.layers import get_channel_layer
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models.signals import post_save from django.db.models.signals import post_save
from filelock import FileLock from filelock import FileLock
from redis.exceptions import ConnectionError
from whoosh.writing import AsyncWriter from whoosh.writing import AsyncWriter
from documents import index from documents import index
from documents import sanity_checker from documents import sanity_checker
from documents.barcodes import BarcodeReader from documents.barcodes import BarcodePlugin
from documents.classifier import DocumentClassifier from documents.classifier import DocumentClassifier
from documents.classifier import load_classifier from documents.classifier import load_classifier
from documents.consumer import Consumer from documents.consumer import Consumer
from documents.consumer import ConsumerError from documents.consumer import ConsumerError
from documents.consumer import WorkflowTriggerPlugin
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentMetadataOverrides
from documents.double_sided import collate from documents.double_sided import CollatePlugin
from documents.file_handling import create_source_path_directory from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_unique_filename from documents.file_handling import generate_unique_filename
from documents.models import Correspondent from documents.models import Correspondent
@ -35,6 +35,10 @@ from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.parsers import DocumentParser from documents.parsers import DocumentParser
from documents.parsers import get_parser_class_for_mime_type from documents.parsers import get_parser_class_for_mime_type
from documents.plugins.base import ConsumeTaskPlugin
from documents.plugins.base import ProgressManager
from documents.plugins.base import StopConsumeTaskError
from documents.plugins.helpers import ProgressStatusOptions
from documents.sanity_checker import SanityCheckFailedException from documents.sanity_checker import SanityCheckFailedException
from documents.signals import document_updated from documents.signals import document_updated
@ -102,70 +106,60 @@ def consume_file(
input_doc: ConsumableDocument, input_doc: ConsumableDocument,
overrides: Optional[DocumentMetadataOverrides] = None, overrides: Optional[DocumentMetadataOverrides] = None,
): ):
def send_progress(status="SUCCESS", message="finished"):
payload = {
"filename": overrides.filename or input_doc.original_file.name,
"task_id": None,
"current_progress": 100,
"max_progress": 100,
"status": status,
"message": message,
}
try:
async_to_sync(get_channel_layer().group_send)(
"status_updates",
{"type": "status_update", "data": payload},
)
except ConnectionError as e:
logger.warning(f"ConnectionError on status send: {e!s}")
# Default no overrides # Default no overrides
if overrides is None: if overrides is None:
overrides = DocumentMetadataOverrides() overrides = DocumentMetadataOverrides()
# Handle collation of double-sided documents scanned in two parts plugins: list[type[ConsumeTaskPlugin]] = [
if settings.CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED and ( CollatePlugin,
settings.CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME BarcodePlugin,
in input_doc.original_file.parts WorkflowTriggerPlugin,
): ]
try:
msg = collate(input_doc)
send_progress(message=msg)
return msg
except ConsumerError as e:
send_progress(status="FAILURE", message=e.args[0])
raise e
# read all barcodes in the current document with ProgressManager(
if settings.CONSUMER_ENABLE_BARCODES or settings.CONSUMER_ENABLE_ASN_BARCODE: overrides.filename or input_doc.original_file.name,
with BarcodeReader(input_doc.original_file, input_doc.mime_type) as reader: self.request.id,
if settings.CONSUMER_ENABLE_BARCODES and reader.separate( ) as status_mgr, TemporaryDirectory(dir=settings.SCRATCH_DIR) as tmp_dir:
input_doc.source, tmp_dir = Path(tmp_dir)
for plugin_class in plugins:
plugin_name = plugin_class.NAME
plugin = plugin_class(
input_doc,
overrides, overrides,
): status_mgr,
# notify the sender, otherwise the progress bar tmp_dir,
# in the UI stays stuck self.request.id,
send_progress()
# consuming stops here, since the original document with
# the barcodes has been split and will be consumed separately
input_doc.original_file.unlink()
return "File successfully split"
# try reading the ASN from barcode
if (
settings.CONSUMER_ENABLE_ASN_BARCODE
and (located_asn := reader.asn) is not None
):
# Note this will take precedence over an API provided ASN
# But it's from a physical barcode, so that's good
overrides.asn = located_asn
logger.info(f"Found ASN in barcode: {overrides.asn}")
template_overrides = Consumer().get_workflow_overrides(
input_doc=input_doc,
) )
overrides.update(template_overrides) if not plugin.able_to_run:
logger.debug(f"Skipping plugin {plugin_name}")
continue
try:
logger.debug(f"Executing plugin {plugin_name}")
plugin.setup()
msg = plugin.run()
if msg is not None:
logger.info(f"{plugin_name} completed with: {msg}")
else:
logger.info(f"{plugin_name} completed with no message")
overrides = plugin.metadata
except StopConsumeTaskError as e:
logger.info(f"{plugin_name} requested task exit: {e.message}")
return e.message
except Exception as e:
logger.exception(f"{plugin_name} failed: {e}")
status_mgr.send_progress(ProgressStatusOptions.FAILED, f"{e}", 100, 100)
raise
finally:
plugin.cleanup()
# continue with consumption if no barcode was found # continue with consumption if no barcode was found
document = Consumer().try_consume_file( document = Consumer().try_consume_file(

View File

@ -1,4 +1,5 @@
import json import json
import os
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import status from rest_framework import status
@ -49,10 +50,34 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
"rotate_pages_threshold": None, "rotate_pages_threshold": None,
"max_image_pixels": None, "max_image_pixels": None,
"color_conversion_strategy": None, "color_conversion_strategy": None,
"app_title": None,
"app_logo": None,
}, },
), ),
) )
def test_api_get_ui_settings_with_config(self):
"""
GIVEN:
- Existing config with app_title, app_logo specified
WHEN:
- API to retrieve uisettings is called
THEN:
- app_title and app_logo are included
"""
config = ApplicationConfiguration.objects.first()
config.app_title = "Fancy New Title"
config.app_logo = "/logo/example.jpg"
config.save()
response = self.client.get("/api/ui_settings/", format="json")
self.assertDictContainsSubset(
{
"app_title": config.app_title,
"app_logo": config.app_logo,
},
response.data["settings"],
)
def test_api_update_config(self): def test_api_update_config(self):
""" """
GIVEN: GIVEN:
@ -100,3 +125,37 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
config = ApplicationConfiguration.objects.first() config = ApplicationConfiguration.objects.first()
self.assertEqual(config.user_args, None) self.assertEqual(config.user_args, None)
self.assertEqual(config.language, None) self.assertEqual(config.language, None)
def test_api_replace_app_logo(self):
"""
GIVEN:
- Existing config with app_logo specified
WHEN:
- API to replace app_logo is called
THEN:
- old app_logo file is deleted
"""
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.jpg"),
"rb",
) as f:
self.client.patch(
f"{self.ENDPOINT}1/",
{
"app_logo": f,
},
)
config = ApplicationConfiguration.objects.first()
old_logo = config.app_logo
self.assertTrue(os.path.exists(old_logo.path))
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.png"),
"rb",
) as f:
self.client.patch(
f"{self.ENDPOINT}1/",
{
"app_logo": f,
},
)
self.assertFalse(os.path.exists(old_logo.path))

View File

@ -35,6 +35,8 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
self.assertDictEqual( self.assertDictEqual(
response.data["settings"], response.data["settings"],
{ {
"app_title": None,
"app_logo": None,
"update_checking": { "update_checking": {
"backend_setting": "default", "backend_setting": "default",
}, },

View File

@ -1,4 +1,7 @@
import shutil import shutil
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
from unittest import mock from unittest import mock
import pytest import pytest
@ -7,14 +10,13 @@ from django.test import TestCase
from django.test import override_settings from django.test import override_settings
from documents import tasks from documents import tasks
from documents.barcodes import BarcodeReader from documents.barcodes import BarcodePlugin
from documents.consumer import ConsumerError
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.models import Document
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import DocumentConsumeDelayMixin from documents.tests.utils import DocumentConsumeDelayMixin
from documents.tests.utils import DummyProgressManager
from documents.tests.utils import FileSystemAssertsMixin from documents.tests.utils import FileSystemAssertsMixin
from documents.tests.utils import SampleDirMixin from documents.tests.utils import SampleDirMixin
@ -26,8 +28,29 @@ except ImportError:
HAS_ZXING_LIB = False HAS_ZXING_LIB = False
class GetReaderPluginMixin:
@contextmanager
def get_reader(self, filepath: Path) -> Generator[BarcodePlugin, None, None]:
reader = BarcodePlugin(
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
DocumentMetadataOverrides(),
DummyProgressManager(filepath.name, None),
self.dirs.scratch_dir,
"task-id",
)
reader.setup()
yield reader
reader.cleanup()
@override_settings(CONSUMER_BARCODE_SCANNER="PYZBAR") @override_settings(CONSUMER_BARCODE_SCANNER="PYZBAR")
class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, TestCase): class TestBarcode(
DirectoriesMixin,
FileSystemAssertsMixin,
SampleDirMixin,
GetReaderPluginMixin,
TestCase,
):
def test_scan_file_for_separating_barcodes(self): def test_scan_file_for_separating_barcodes(self):
""" """
GIVEN: GIVEN:
@ -39,7 +62,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t.pdf" test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -60,7 +83,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.tiff" test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.tiff"
with BarcodeReader(test_file, "image/tiff") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -80,7 +103,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle-alpha.tiff" test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle-alpha.tiff"
with BarcodeReader(test_file, "image/tiff") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -97,7 +120,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
- No pages to split on - No pages to split on
""" """
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -115,7 +138,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.pdf" test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -133,7 +156,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "several-patcht-codes.pdf" test_file = self.BARCODE_SAMPLE_DIR / "several-patcht-codes.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -158,7 +181,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
]: ]:
test_file = self.BARCODE_SAMPLE_DIR / test_file test_file = self.BARCODE_SAMPLE_DIR / test_file
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -177,7 +200,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle-unreadable.pdf" test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle-unreadable.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -195,7 +218,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "barcode-fax-image.pdf" test_file = self.BARCODE_SAMPLE_DIR / "barcode-fax-image.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -214,7 +237,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-qr.pdf" test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-qr.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -234,7 +257,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-custom.pdf" test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-custom.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -255,7 +278,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "barcode-qr-custom.pdf" test_file = self.BARCODE_SAMPLE_DIR / "barcode-qr-custom.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -276,7 +299,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "barcode-128-custom.pdf" test_file = self.BARCODE_SAMPLE_DIR / "barcode-128-custom.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -296,7 +319,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-custom.pdf" test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-custom.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -315,7 +338,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "many-qr-codes.pdf" test_file = self.BARCODE_SAMPLE_DIR / "many-qr-codes.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -334,7 +357,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.SAMPLE_DIR / "password-is-test.pdf" test_file = self.SAMPLE_DIR / "password-is-test.pdf"
with self.assertLogs("paperless.barcodes", level="WARNING") as cm: with self.assertLogs("paperless.barcodes", level="WARNING") as cm:
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
warning = cm.output[0] warning = cm.output[0]
expected_str = "WARNING:paperless.barcodes:File is likely password protected, not checking for barcodes" expected_str = "WARNING:paperless.barcodes:File is likely password protected, not checking for barcodes"
@ -356,7 +379,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.pdf" test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
documents = reader.separate_pages({1: False}) documents = reader.separate_pages({1: False})
self.assertEqual(reader.pdf_file, test_file) self.assertEqual(reader.pdf_file, test_file)
@ -373,7 +396,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-double.pdf" test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-double.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
documents = reader.separate_pages({1: False, 2: False}) documents = reader.separate_pages({1: False, 2: False})
self.assertEqual(len(documents), 2) self.assertEqual(len(documents), 2)
@ -385,32 +408,18 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
WHEN: WHEN:
- No separation pages are provided - No separation pages are provided
THEN: THEN:
- No new documents are produced - Nothing happens
- A warning is logged
""" """
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
with self.assertLogs("paperless.barcodes", level="WARNING") as cm: with self.get_reader(test_file) as reader:
with BarcodeReader(test_file, "application/pdf") as reader: self.assertEqual("No pages to split on!", reader.run())
self.assertFalse(
reader.separate(
DocumentSource.ApiUpload,
DocumentMetadataOverrides(),
),
)
self.assertEqual(
cm.output,
[
"WARNING:paperless.barcodes:No pages to split on!",
],
)
@override_settings( @override_settings(
CONSUMER_ENABLE_BARCODES=True, CONSUMER_ENABLE_BARCODES=True,
CONSUMER_BARCODE_TIFF_SUPPORT=True, CONSUMER_BARCODE_TIFF_SUPPORT=True,
) )
@mock.patch("documents.consumer.Consumer.try_consume_file") def test_consume_barcode_unsupported_jpg_file(self):
def test_consume_barcode_unsupported_jpg_file(self, m):
""" """
GIVEN: GIVEN:
- JPEG image as input - JPEG image as input
@ -422,35 +431,8 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.SAMPLE_DIR / "simple.jpg" test_file = self.SAMPLE_DIR / "simple.jpg"
dst = settings.SCRATCH_DIR / "simple.jpg" with self.get_reader(test_file) as reader:
shutil.copy(test_file, dst) self.assertFalse(reader.able_to_run)
with self.assertLogs("paperless.barcodes", level="WARNING") as cm:
self.assertIn(
"Success",
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=dst,
),
None,
),
)
self.assertListEqual(
cm.output,
[
"WARNING:paperless.barcodes:Unsupported file format for barcode reader: image/jpeg",
],
)
m.assert_called_once()
args, kwargs = m.call_args
self.assertIsNone(kwargs["override_filename"])
self.assertIsNone(kwargs["override_title"])
self.assertIsNone(kwargs["override_correspondent_id"])
self.assertIsNone(kwargs["override_document_type_id"])
self.assertIsNone(kwargs["override_tag_ids"])
@override_settings( @override_settings(
CONSUMER_ENABLE_BARCODES=True, CONSUMER_ENABLE_BARCODES=True,
@ -467,7 +449,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "split-by-asn-2.pdf" test_file = self.BARCODE_SAMPLE_DIR / "split-by-asn-2.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -504,7 +486,7 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, Test
""" """
test_file = self.BARCODE_SAMPLE_DIR / "split-by-asn-1.pdf" test_file = self.BARCODE_SAMPLE_DIR / "split-by-asn-1.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@ -550,7 +532,7 @@ class TestBarcodeNewConsume(
overrides = DocumentMetadataOverrides(tag_ids=[1, 2, 9]) overrides = DocumentMetadataOverrides(tag_ids=[1, 2, 9])
with mock.patch("documents.tasks.async_to_sync") as progress_mocker: with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
self.assertEqual( self.assertEqual(
tasks.consume_file( tasks.consume_file(
ConsumableDocument( ConsumableDocument(
@ -559,10 +541,8 @@ class TestBarcodeNewConsume(
), ),
overrides, overrides,
), ),
"File successfully split", "Barcode splitting complete!",
) )
# We let the consumer know progress is done
progress_mocker.assert_called_once()
# 2 new document consume tasks created # 2 new document consume tasks created
self.assertEqual(self.consume_file_mock.call_count, 2) self.assertEqual(self.consume_file_mock.call_count, 2)
@ -580,7 +560,20 @@ class TestBarcodeNewConsume(
self.assertEqual(overrides, new_doc_overrides) self.assertEqual(overrides, new_doc_overrides)
class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, TestCase): class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, TestCase):
@contextmanager
def get_reader(self, filepath: Path) -> BarcodePlugin:
reader = BarcodePlugin(
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
DocumentMetadataOverrides(),
DummyProgressManager(filepath.name, None),
self.dirs.scratch_dir,
"task-id",
)
reader.setup()
yield reader
reader.cleanup()
@override_settings(CONSUMER_ASN_BARCODE_PREFIX="CUSTOM-PREFIX-") @override_settings(CONSUMER_ASN_BARCODE_PREFIX="CUSTOM-PREFIX-")
def test_scan_file_for_asn_custom_prefix(self): def test_scan_file_for_asn_custom_prefix(self):
""" """
@ -594,7 +587,7 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, TestCase):
- The ASN integer value is correct - The ASN integer value is correct
""" """
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-custom-prefix.pdf" test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-custom-prefix.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
asn = reader.asn asn = reader.asn
self.assertEqual(reader.pdf_file, test_file) self.assertEqual(reader.pdf_file, test_file)
@ -613,7 +606,7 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, TestCase):
""" """
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-123.pdf" test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-123.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
asn = reader.asn asn = reader.asn
self.assertEqual(reader.pdf_file, test_file) self.assertEqual(reader.pdf_file, test_file)
@ -630,55 +623,12 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, TestCase):
""" """
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t.pdf" test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
asn = reader.asn asn = reader.asn
self.assertEqual(reader.pdf_file, test_file) self.assertEqual(reader.pdf_file, test_file)
self.assertEqual(asn, None) self.assertEqual(asn, None)
@override_settings(CONSUMER_ENABLE_ASN_BARCODE=True)
def test_scan_file_for_asn_already_exists(self):
"""
GIVEN:
- PDF with an ASN barcode
- ASN value already exists
WHEN:
- File is scanned for barcodes
THEN:
- ASN is retrieved from the document
- Consumption fails
"""
Document.objects.create(
title="WOW",
content="the content",
archive_serial_number=123,
checksum="456",
mime_type="application/pdf",
)
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-123.pdf"
dst = settings.SCRATCH_DIR / "barcode-39-asn-123.pdf"
shutil.copy(test_file, dst)
with mock.patch("documents.consumer.Consumer._send_progress"):
with self.assertRaises(ConsumerError) as cm, self.assertLogs(
"paperless.consumer",
level="ERROR",
) as logs_cm:
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=dst,
),
None,
)
self.assertIn("Not consuming barcode-39-asn-123.pdf", str(cm.exception))
error_str = logs_cm.output[0]
expected_str = "ERROR:paperless.consumer:Not consuming barcode-39-asn-123.pdf: Given ASN already exists!"
self.assertEqual(expected_str, error_str)
def test_scan_file_for_asn_barcode_invalid(self): def test_scan_file_for_asn_barcode_invalid(self):
""" """
GIVEN: GIVEN:
@ -692,7 +642,7 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, TestCase):
""" """
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-invalid.pdf" test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-invalid.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
asn = reader.asn asn = reader.asn
self.assertEqual(reader.pdf_file, test_file) self.assertEqual(reader.pdf_file, test_file)
@ -718,7 +668,9 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, TestCase):
dst = settings.SCRATCH_DIR / "barcode-39-asn-123.pdf" dst = settings.SCRATCH_DIR / "barcode-39-asn-123.pdf"
shutil.copy(test_file, dst) shutil.copy(test_file, dst)
with mock.patch("documents.consumer.Consumer.try_consume_file") as mocked_call: with mock.patch(
"documents.consumer.Consumer.try_consume_file",
) as mocked_consumer:
tasks.consume_file( tasks.consume_file(
ConsumableDocument( ConsumableDocument(
source=DocumentSource.ConsumeFolder, source=DocumentSource.ConsumeFolder,
@ -726,40 +678,11 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, TestCase):
), ),
None, None,
) )
mocked_consumer.assert_called_once()
args, kwargs = mocked_call.call_args args, kwargs = mocked_consumer.call_args
self.assertEqual(kwargs["override_asn"], 123) self.assertEqual(kwargs["override_asn"], 123)
@override_settings(CONSUMER_ENABLE_ASN_BARCODE=True)
def test_asn_too_large(self):
"""
GIVEN:
- ASN from barcode enabled
- Barcode contains too large an ASN value
WHEN:
- ASN from barcode checked for correctness
THEN:
- Exception is raised regarding size limits
"""
src = self.BARCODE_SAMPLE_DIR / "barcode-128-asn-too-large.pdf"
dst = self.dirs.scratch_dir / "barcode-128-asn-too-large.pdf"
shutil.copy(src, dst)
input_doc = ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=dst,
)
with mock.patch("documents.consumer.Consumer._send_progress"):
self.assertRaisesMessage(
ConsumerError,
"Given ASN 4294967296 is out of range [0, 4,294,967,295]",
tasks.consume_file,
input_doc,
)
@override_settings(CONSUMER_BARCODE_SCANNER="PYZBAR") @override_settings(CONSUMER_BARCODE_SCANNER="PYZBAR")
def test_scan_file_for_qrcode_without_upscale(self): def test_scan_file_for_qrcode_without_upscale(self):
""" """
@ -774,7 +697,7 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, TestCase):
test_file = self.BARCODE_SAMPLE_DIR / "barcode-qr-asn-000123-upscale-dpi.pdf" test_file = self.BARCODE_SAMPLE_DIR / "barcode-qr-asn-000123-upscale-dpi.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
self.assertEqual(len(reader.barcodes), 0) self.assertEqual(len(reader.barcodes), 0)
@ -796,7 +719,7 @@ class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, TestCase):
test_file = self.BARCODE_SAMPLE_DIR / "barcode-qr-asn-000123-upscale-dpi.pdf" test_file = self.BARCODE_SAMPLE_DIR / "barcode-qr-asn-000123-upscale-dpi.pdf"
with BarcodeReader(test_file, "application/pdf") as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
self.assertEqual(len(reader.barcodes), 1) self.assertEqual(len(reader.barcodes), 1)
self.assertEqual(reader.asn, 123) self.assertEqual(reader.asn, 123)

View File

@ -17,6 +17,7 @@ from documents.data_models import DocumentSource
from documents.double_sided import STAGING_FILE_NAME from documents.double_sided import STAGING_FILE_NAME
from documents.double_sided import TIMEOUT_MINUTES from documents.double_sided import TIMEOUT_MINUTES
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import DummyProgressManager
from documents.tests.utils import FileSystemAssertsMixin from documents.tests.utils import FileSystemAssertsMixin
@ -42,9 +43,10 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
dst = self.dirs.double_sided_dir / dstname dst = self.dirs.double_sided_dir / dstname
dst.parent.mkdir(parents=True, exist_ok=True) dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(src, dst) shutil.copy(src, dst)
with mock.patch("documents.tasks.async_to_sync"), mock.patch( with mock.patch(
"documents.consumer.async_to_sync", "documents.tasks.ProgressManager",
): DummyProgressManager,
), mock.patch("documents.consumer.async_to_sync"):
msg = tasks.consume_file( msg = tasks.consume_file(
ConsumableDocument( ConsumableDocument(
source=DocumentSource.ConsumeFolder, source=DocumentSource.ConsumeFolder,
@ -211,7 +213,7 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
""" """
msg = self.consume_file("simple.pdf", Path("..") / "simple.pdf") msg = self.consume_file("simple.pdf", Path("..") / "simple.pdf")
self.assertIsNotFile(self.staging_file) self.assertIsNotFile(self.staging_file)
self.assertRegex(msg, "Success. New document .* created") self.assertRegex(msg, r"Success. New document id \d+ created")
def test_subdirectory_upload(self): def test_subdirectory_upload(self):
""" """
@ -250,4 +252,4 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
""" """
msg = self.consume_file("simple.pdf") msg = self.consume_file("simple.pdf")
self.assertIsNotFile(self.staging_file) self.assertIsNotFile(self.staging_file)
self.assertRegex(msg, "Success. New document .* created") self.assertRegex(msg, r"Success. New document id \d+ created")

View File

@ -24,6 +24,7 @@ from documents.models import WorkflowAction
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.signals import document_consumption_finished from documents.signals import document_consumption_finished
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import DummyProgressManager
from documents.tests.utils import FileSystemAssertsMixin from documents.tests.utils import FileSystemAssertsMixin
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
@ -126,7 +127,7 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"): with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
with self.assertLogs("paperless.matching", level="INFO") as cm: with self.assertLogs("paperless.matching", level="INFO") as cm:
tasks.consume_file( tasks.consume_file(
ConsumableDocument( ConsumableDocument(
@ -203,7 +204,7 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
w.save() w.save()
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"): with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
with self.assertLogs("paperless.matching", level="INFO") as cm: with self.assertLogs("paperless.matching", level="INFO") as cm:
tasks.consume_file( tasks.consume_file(
ConsumableDocument( ConsumableDocument(
@ -294,7 +295,7 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"): with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
with self.assertLogs("paperless.matching", level="INFO") as cm: with self.assertLogs("paperless.matching", level="INFO") as cm:
tasks.consume_file( tasks.consume_file(
ConsumableDocument( ConsumableDocument(
@ -356,7 +357,7 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"): with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
with self.assertLogs("paperless.matching", level="DEBUG") as cm: with self.assertLogs("paperless.matching", level="DEBUG") as cm:
tasks.consume_file( tasks.consume_file(
ConsumableDocument( ConsumableDocument(
@ -407,7 +408,7 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"): with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
with self.assertLogs("paperless.matching", level="DEBUG") as cm: with self.assertLogs("paperless.matching", level="DEBUG") as cm:
tasks.consume_file( tasks.consume_file(
ConsumableDocument( ConsumableDocument(
@ -468,7 +469,7 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"): with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
with self.assertLogs("paperless.matching", level="DEBUG") as cm: with self.assertLogs("paperless.matching", level="DEBUG") as cm:
tasks.consume_file( tasks.consume_file(
ConsumableDocument( ConsumableDocument(
@ -529,7 +530,7 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"): with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
with self.assertLogs("paperless.matching", level="DEBUG") as cm: with self.assertLogs("paperless.matching", level="DEBUG") as cm:
tasks.consume_file( tasks.consume_file(
ConsumableDocument( ConsumableDocument(
@ -591,7 +592,7 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"): with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
with self.assertLogs("paperless.matching", level="DEBUG") as cm: with self.assertLogs("paperless.matching", level="DEBUG") as cm:
tasks.consume_file( tasks.consume_file(
ConsumableDocument( ConsumableDocument(
@ -686,7 +687,7 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase):
test_file = self.SAMPLE_DIR / "simple.pdf" test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"): with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
with self.assertLogs("paperless.matching", level="INFO") as cm: with self.assertLogs("paperless.matching", level="INFO") as cm:
tasks.consume_file( tasks.consume_file(
ConsumableDocument( ConsumableDocument(

View File

@ -9,6 +9,7 @@ from os import PathLike
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from typing import Callable from typing import Callable
from typing import Optional
from typing import Union from typing import Union
from unittest import mock from unittest import mock
@ -23,6 +24,7 @@ from django.test import override_settings
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentMetadataOverrides
from documents.parsers import ParseError from documents.parsers import ParseError
from documents.plugins.helpers import ProgressStatusOptions
def setup_directories(): def setup_directories():
@ -146,6 +148,11 @@ def util_call_with_backoff(
class DirectoriesMixin: class DirectoriesMixin:
"""
Creates and overrides settings for all folders and paths, then ensures
they are cleaned up on exit
"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.dirs = None self.dirs = None
@ -160,6 +167,10 @@ class DirectoriesMixin:
class FileSystemAssertsMixin: class FileSystemAssertsMixin:
"""
Utilities for checks various state information of the file system
"""
def assertIsFile(self, path: Union[PathLike, str]): def assertIsFile(self, path: Union[PathLike, str]):
self.assertTrue(Path(path).resolve().is_file(), f"File does not exist: {path}") self.assertTrue(Path(path).resolve().is_file(), f"File does not exist: {path}")
@ -188,6 +199,11 @@ class FileSystemAssertsMixin:
class ConsumerProgressMixin: class ConsumerProgressMixin:
"""
Mocks the Consumer _send_progress, preventing attempts to connect to Redis
and allowing access to its calls for verification
"""
def setUp(self) -> None: def setUp(self) -> None:
self.send_progress_patcher = mock.patch( self.send_progress_patcher = mock.patch(
"documents.consumer.Consumer._send_progress", "documents.consumer.Consumer._send_progress",
@ -310,3 +326,59 @@ class SampleDirMixin:
SAMPLE_DIR = Path(__file__).parent / "samples" SAMPLE_DIR = Path(__file__).parent / "samples"
BARCODE_SAMPLE_DIR = SAMPLE_DIR / "barcodes" BARCODE_SAMPLE_DIR = SAMPLE_DIR / "barcodes"
class DummyProgressManager:
"""
A dummy handler for progress management that doesn't actually try to
connect to Redis. Payloads are stored for test assertions if needed.
Use it with
mock.patch("documents.tasks.ProgressManager", DummyProgressManager)
"""
def __init__(self, filename: str, task_id: Optional[str] = None) -> None:
self.filename = filename
self.task_id = task_id
print("hello world")
self.payloads = []
def __enter__(self):
self.open()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def open(self) -> None:
pass
def close(self) -> None:
pass
def send_progress(
self,
status: ProgressStatusOptions,
message: str,
current_progress: int,
max_progress: int,
extra_args: Optional[dict[str, Union[str, int]]] = None,
) -> None:
# Ensure the layer is open
self.open()
payload = {
"type": "status_update",
"data": {
"filename": self.filename,
"task_id": self.task_id,
"current_progress": current_progress,
"max_progress": max_progress,
"status": status,
"message": message,
},
}
if extra_args is not None:
payload["data"].update(extra_args)
self.payloads.append(payload)

View File

@ -120,6 +120,7 @@ from documents.serialisers import WorkflowTriggerSerializer
from documents.signals import document_updated from documents.signals import document_updated
from documents.tasks import consume_file from documents.tasks import consume_file
from paperless import version from paperless import version
from paperless.config import GeneralConfig
from paperless.db import GnuPG from paperless.db import GnuPG
from paperless.views import StandardPagination from paperless.views import StandardPagination
@ -1164,6 +1165,16 @@ class UiSettingsView(GenericAPIView):
ui_settings["update_checking"] = { ui_settings["update_checking"] = {
"backend_setting": settings.ENABLE_UPDATE_CHECK, "backend_setting": settings.ENABLE_UPDATE_CHECK,
} }
general_config = GeneralConfig()
ui_settings["app_title"] = settings.APP_TITLE
if general_config.app_title is not None and len(general_config.app_title) > 0:
ui_settings["app_title"] = general_config.app_title
ui_settings["app_logo"] = settings.APP_LOGO
if general_config.app_logo is not None and len(general_config.app_logo) > 0:
ui_settings["app_logo"] = general_config.app_logo
user_resp = { user_resp = {
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,

View File

@ -8,13 +8,11 @@ from paperless.models import ApplicationConfiguration
@dataclasses.dataclass @dataclasses.dataclass
class OutputTypeConfig: class BaseConfig:
""" """
Almost all parsers care about the chosen PDF output format Almost all parsers care about the chosen PDF output format
""" """
output_type: str = dataclasses.field(init=False)
@staticmethod @staticmethod
def _get_config_instance() -> ApplicationConfiguration: def _get_config_instance() -> ApplicationConfiguration:
app_config = ApplicationConfiguration.objects.all().first() app_config = ApplicationConfiguration.objects.all().first()
@ -24,6 +22,15 @@ class OutputTypeConfig:
app_config = ApplicationConfiguration.objects.all().first() app_config = ApplicationConfiguration.objects.all().first()
return app_config return app_config
@dataclasses.dataclass
class OutputTypeConfig(BaseConfig):
"""
Almost all parsers care about the chosen PDF output format
"""
output_type: str = dataclasses.field(init=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
app_config = self._get_config_instance() app_config = self._get_config_instance()
@ -86,3 +93,19 @@ class OcrConfig(OutputTypeConfig):
user_args = {} user_args = {}
self.user_args = user_args self.user_args = user_args
@dataclasses.dataclass
class GeneralConfig(BaseConfig):
"""
General application settings that require global scope
"""
app_title: str = dataclasses.field(init=False)
app_logo: str = dataclasses.field(init=False)
def __post_init__(self) -> None:
app_config = self._get_config_instance()
self.app_title = app_config.app_title or None
self.app_logo = app_config.app_logo.url if app_config.app_logo else None

View File

@ -0,0 +1,33 @@
# Generated by Django 4.2.9 on 2024-01-12 05:33
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("paperless", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="applicationconfiguration",
name="app_logo",
field=models.FileField(
blank=True,
null=True,
upload_to="",
verbose_name="Application logo",
),
),
migrations.AddField(
model_name="applicationconfiguration",
name="app_title",
field=models.CharField(
blank=True,
max_length=48,
null=True,
verbose_name="Application title",
),
),
]

View File

@ -1,3 +1,4 @@
from django.core.validators import FileExtensionValidator
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -166,6 +167,23 @@ class ApplicationConfiguration(AbstractSingletonModel):
null=True, null=True,
) )
app_title = models.CharField(
verbose_name=_("Application title"),
null=True,
blank=True,
max_length=48,
)
app_logo = models.FileField(
verbose_name=_("Application logo"),
null=True,
blank=True,
validators=[
FileExtensionValidator(allowed_extensions=["jpg", "png", "gif", "svg"]),
],
upload_to="logo/",
)
class Meta: class Meta:
verbose_name = _("paperless application settings") verbose_name = _("paperless application settings")

View File

@ -132,6 +132,11 @@ class ApplicationConfigurationSerializer(serializers.ModelSerializer):
data["language"] = None data["language"] = None
return super().run_validation(data) return super().run_validation(data)
def update(self, instance, validated_data):
if instance.app_logo and "app_logo" in validated_data:
instance.app_logo.delete()
return super().update(instance, validated_data)
class Meta: class Meta:
model = ApplicationConfiguration model = ApplicationConfiguration
fields = "__all__" fields = "__all__"

View File

@ -367,6 +367,7 @@ STORAGES = {
"staticfiles": { "staticfiles": {
"BACKEND": _static_backend, "BACKEND": _static_backend,
}, },
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
} }
_CELERY_REDIS_URL, _CHANNELS_REDIS_URL = _parse_redis_url( _CELERY_REDIS_URL, _CHANNELS_REDIS_URL = _parse_redis_url(
@ -999,6 +1000,9 @@ ENABLE_UPDATE_CHECK = os.getenv("PAPERLESS_ENABLE_UPDATE_CHECK", "default")
if ENABLE_UPDATE_CHECK != "default": if ENABLE_UPDATE_CHECK != "default":
ENABLE_UPDATE_CHECK = __get_boolean("PAPERLESS_ENABLE_UPDATE_CHECK") ENABLE_UPDATE_CHECK = __get_boolean("PAPERLESS_ENABLE_UPDATE_CHECK")
APP_TITLE = os.getenv("PAPERLESS_APP_TITLE", None)
APP_LOGO = os.getenv("PAPERLESS_APP_LOGO", None)
############################################################################### ###############################################################################
# Machine Learning # # Machine Learning #
############################################################################### ###############################################################################

View File

@ -1,3 +1,5 @@
import os
from django.conf import settings from django.conf import settings
from django.conf.urls import include from django.conf.urls import include
from django.contrib import admin from django.contrib import admin
@ -8,6 +10,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import RedirectView from django.views.generic import RedirectView
from django.views.static import serve
from rest_framework.authtoken import views from rest_framework.authtoken import views
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
@ -181,6 +184,12 @@ urlpatterns = [
url=settings.STATIC_URL + "frontend/en-US/assets/%(path)s", url=settings.STATIC_URL + "frontend/en-US/assets/%(path)s",
), ),
), ),
# App logo
re_path(
r"^logo(?P<path>.*)$",
serve,
kwargs={"document_root": os.path.join(settings.MEDIA_ROOT, "logo")},
),
# TODO: with localization, this is even worse! :/ # TODO: with localization, this is even worse! :/
# login, logout # login, logout
path("accounts/", include("django.contrib.auth.urls")), path("accounts/", include("django.contrib.auth.urls")),