mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Compare commits
	
		
			71 Commits
		
	
	
		
			main
			...
			feature-re
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					32bdf11f7f | ||
| 
						 | 
					3dc7cf3da1 | ||
| 
						 | 
					819f606335 | ||
| 
						 | 
					ad45e3f747 | ||
| 
						 | 
					74b10db028 | ||
| 
						 | 
					cffb9c34f0 | ||
| 
						 | 
					6f52614817 | ||
| 
						 | 
					a0d3527d20 | ||
| 
						 | 
					4e64ca7ca6 | ||
| 
						 | 
					e9511bd3da | ||
| 
						 | 
					8b9ca75a90 | ||
| 
						 | 
					9f0a4ac19d | ||
| 
						 | 
					8f969ecab5 | ||
| 
						 | 
					245e52a4eb | ||
| 
						 | 
					a8c75d95d8 | ||
| 
						 | 
					d6e2456baf | ||
| 
						 | 
					3b75d3271e | ||
| 
						 | 
					e88816d141 | ||
| 
						 | 
					e5bd4713ac | ||
| 
						 | 
					b9aced07fb | ||
| 
						 | 
					6b55740f56 | ||
| 
						 | 
					0627ca69f5 | ||
| 
						 | 
					f5525bbdff | ||
| 
						 | 
					a21a2a41a8 | ||
| 
						 | 
					cc73ed8b86 | ||
| 
						 | 
					0c706b2316 | ||
| 
						 | 
					85b7b6874d | ||
| 
						 | 
					56b26185fa | ||
| 
						 | 
					6537fade7b | ||
| 
						 | 
					9f8090816f | ||
| 
						 | 
					1de7c52478 | ||
| 
						 | 
					9aaaa6f069 | ||
| 
						 | 
					c3a20b7797 | ||
| 
						 | 
					476556379b | ||
| 
						 | 
					e5cafff043 | ||
| 
						 | 
					8e0d574e99 | ||
| 
						 | 
					8a5820328e | ||
| 
						 | 
					809d62a2f4 | ||
| 
						 | 
					0d87f94b9b | ||
| 
						 | 
					315b90f8e5 | ||
| 
						 | 
					47b2d2964b | ||
| 
						 | 
					e05639ae4e | ||
| 
						 | 
					f400a8cb2f | ||
| 
						 | 
					26abcf5612 | ||
| 
						 | 
					afde52430d | ||
| 
						 | 
					716f2da652 | ||
| 
						 | 
					c54073b7c2 | ||
| 
						 | 
					247e6f39dc | ||
| 
						 | 
					1e6dfc4481 | ||
| 
						 | 
					7cc0750066 | ||
| 
						 | 
					bd6585d3b4 | ||
| 
						 | 
					717e828a1d | ||
| 
						 | 
					07381d48e6 | ||
| 
						 | 
					dd0ffaf312 | ||
| 
						 | 
					264504affc | ||
| 
						 | 
					4feedf2add | ||
| 
						 | 
					2f76cf9831 | ||
| 
						 | 
					1002d37f6b | ||
| 
						 | 
					d260a94740 | ||
| 
						 | 
					88c69b83ea | ||
| 
						 | 
					2557ee2014 | ||
| 
						 | 
					3c75deed80 | ||
| 
						 | 
					d05343c927 | ||
| 
						 | 
					e7972b7eaf | ||
| 
						 | 
					75a091cc0d | ||
| 
						 | 
					dca74803fd | ||
| 
						 | 
					3cf3d868d0 | ||
| 
						 | 
					bf4fc6604a | ||
| 
						 | 
					e8c1eb86fa | ||
| 
						 | 
					c3dad3cf69 | ||
| 
						 | 
					811bd66088 | 
@@ -1794,3 +1794,23 @@ password. All of these options come from their similarly-named [Django settings]
 | 
			
		||||
#### [`PAPERLESS_EMAIL_USE_SSL=<bool>`](#PAPERLESS_EMAIL_USE_SSL) {#PAPERLESS_EMAIL_USE_SSL}
 | 
			
		||||
 | 
			
		||||
: Defaults to false.
 | 
			
		||||
 | 
			
		||||
## Remote OCR
 | 
			
		||||
 | 
			
		||||
#### [`PAPERLESS_REMOTE_OCR_ENGINE=<str>`](#PAPERLESS_REMOTE_OCR_ENGINE) {#PAPERLESS_REMOTE_OCR_ENGINE}
 | 
			
		||||
 | 
			
		||||
: The remote OCR engine to use. Currently only Azure AI is supported as "azureai".
 | 
			
		||||
 | 
			
		||||
    Defaults to None, which disables remote OCR.
 | 
			
		||||
 | 
			
		||||
#### [`PAPERLESS_REMOTE_OCR_API_KEY=<str>`](#PAPERLESS_REMOTE_OCR_API_KEY) {#PAPERLESS_REMOTE_OCR_API_KEY}
 | 
			
		||||
 | 
			
		||||
: The API key to use for the remote OCR engine.
 | 
			
		||||
 | 
			
		||||
    Defaults to None.
 | 
			
		||||
 | 
			
		||||
#### [`PAPERLESS_REMOTE_OCR_ENDPOINT=<str>`](#PAPERLESS_REMOTE_OCR_ENDPOINT) {#PAPERLESS_REMOTE_OCR_ENDPOINT}
 | 
			
		||||
 | 
			
		||||
: The endpoint to use for the remote OCR engine. This is required for Azure AI.
 | 
			
		||||
 | 
			
		||||
    Defaults to None.
 | 
			
		||||
 
 | 
			
		||||
@@ -25,9 +25,10 @@ physical documents into a searchable online archive so you can keep, well, _less
 | 
			
		||||
## Features
 | 
			
		||||
 | 
			
		||||
-   **Organize and index** your scanned documents with tags, correspondents, types, and more.
 | 
			
		||||
-   _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way.
 | 
			
		||||
-   _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so.
 | 
			
		||||
-   Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
 | 
			
		||||
-   Utilizes the open-source Tesseract engine to recognize more than 100 languages.
 | 
			
		||||
    -   Utilizes the open-source Tesseract engine to recognize more than 100 languages.
 | 
			
		||||
    -   _New!_ Supports remote OCR with Azure AI (opt-in).
 | 
			
		||||
-   Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
 | 
			
		||||
-   Uses machine-learning to automatically add tags, correspondents and document types to your documents.
 | 
			
		||||
-   Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
 | 
			
		||||
 
 | 
			
		||||
@@ -891,6 +891,21 @@ how regularly you intend to scan documents and use paperless.
 | 
			
		||||
    performed the task associated with the document, move it to the
 | 
			
		||||
    inbox.
 | 
			
		||||
 | 
			
		||||
## Remote OCR
 | 
			
		||||
 | 
			
		||||
!!! important
 | 
			
		||||
 | 
			
		||||
    This feature is disabled by default and will always remain strictly "opt-in".
 | 
			
		||||
 | 
			
		||||
Paperless-ngx supports performing OCR on documents using remote services. At the moment, this is limited to
 | 
			
		||||
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
 | 
			
		||||
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
 | 
			
		||||
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
 | 
			
		||||
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details.
 | 
			
		||||
 | 
			
		||||
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
 | 
			
		||||
or page limitations (e.g. with a free tier).
 | 
			
		||||
 | 
			
		||||
## Architecture
 | 
			
		||||
 | 
			
		||||
Paperless-ngx consists of the following components:
 | 
			
		||||
 
 | 
			
		||||
@@ -16,6 +16,7 @@ classifiers = [
 | 
			
		||||
# This will allow testing to not install a webserver, mysql, etc
 | 
			
		||||
 | 
			
		||||
dependencies = [
 | 
			
		||||
  "azure-ai-documentintelligence>=1.0.2",
 | 
			
		||||
  "babel>=2.17",
 | 
			
		||||
  "bleach~=6.2.0",
 | 
			
		||||
  "celery[redis]~=5.5.1",
 | 
			
		||||
@@ -252,6 +253,7 @@ testpaths = [
 | 
			
		||||
  "src/paperless_tesseract/tests/",
 | 
			
		||||
  "src/paperless_tika/tests",
 | 
			
		||||
  "src/paperless_text/tests/",
 | 
			
		||||
  "src/paperless_remote/tests/",
 | 
			
		||||
]
 | 
			
		||||
addopts = [
 | 
			
		||||
  "--pythonwarnings=all",
 | 
			
		||||
 
 | 
			
		||||
@@ -297,11 +297,11 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">82</context>
 | 
			
		||||
          <context context-type="linenumber">84</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">84</context>
 | 
			
		||||
          <context context-type="linenumber">86</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
 | 
			
		||||
@@ -316,11 +316,11 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">89</context>
 | 
			
		||||
          <context context-type="linenumber">91</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">91</context>
 | 
			
		||||
          <context context-type="linenumber">93</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
 | 
			
		||||
@@ -363,11 +363,11 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">253</context>
 | 
			
		||||
          <context context-type="linenumber">255</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">255</context>
 | 
			
		||||
          <context context-type="linenumber">257</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2501522447884928778" datatype="html">
 | 
			
		||||
@@ -658,11 +658,11 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">288</context>
 | 
			
		||||
          <context context-type="linenumber">290</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">291</context>
 | 
			
		||||
          <context context-type="linenumber">293</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2272120016352772836" datatype="html">
 | 
			
		||||
@@ -672,11 +672,33 @@
 | 
			
		||||
          <context context-type="linenumber">4</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="8461842260159597706" datatype="html">
 | 
			
		||||
        <source>Show</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">8</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">37</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">52</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5724363929304709833" datatype="html">
 | 
			
		||||
        <source>lines</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">17</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="8838884664569764142" datatype="html">
 | 
			
		||||
        <source>Auto refresh</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">8</context>
 | 
			
		||||
          <context context-type="linenumber">21</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
 | 
			
		||||
@@ -687,11 +709,11 @@
 | 
			
		||||
        <source>Loading...</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">24</context>
 | 
			
		||||
          <context context-type="linenumber">38</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">36</context>
 | 
			
		||||
          <context context-type="linenumber">53</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
 | 
			
		||||
@@ -1003,11 +1025,11 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">213</context>
 | 
			
		||||
          <context context-type="linenumber">215</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">215</context>
 | 
			
		||||
          <context context-type="linenumber">217</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
 | 
			
		||||
@@ -1572,7 +1594,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">167</context>
 | 
			
		||||
          <context context-type="linenumber">180</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2991443309752293110" datatype="html">
 | 
			
		||||
@@ -1583,11 +1605,11 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">276</context>
 | 
			
		||||
          <context context-type="linenumber">278</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">278</context>
 | 
			
		||||
          <context context-type="linenumber">280</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="103921551219467537" datatype="html">
 | 
			
		||||
@@ -1999,11 +2021,11 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">236</context>
 | 
			
		||||
          <context context-type="linenumber">238</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">239</context>
 | 
			
		||||
          <context context-type="linenumber">241</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="3818027200170621545" datatype="html">
 | 
			
		||||
@@ -2368,11 +2390,11 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">267</context>
 | 
			
		||||
          <context context-type="linenumber">269</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">269</context>
 | 
			
		||||
          <context context-type="linenumber">271</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4569276013106377105" datatype="html">
 | 
			
		||||
@@ -2709,58 +2731,58 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">297</context>
 | 
			
		||||
          <context context-type="linenumber">299</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">300</context>
 | 
			
		||||
          <context context-type="linenumber">302</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="472206565520537964" datatype="html">
 | 
			
		||||
        <source>Saved views</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">99</context>
 | 
			
		||||
          <context context-type="linenumber">101</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">104</context>
 | 
			
		||||
          <context context-type="linenumber">106</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6988090220128974198" datatype="html">
 | 
			
		||||
        <source>Open documents</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">139</context>
 | 
			
		||||
          <context context-type="linenumber">141</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5687256342387781369" datatype="html">
 | 
			
		||||
        <source>Close all</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">159</context>
 | 
			
		||||
          <context context-type="linenumber">161</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">161</context>
 | 
			
		||||
          <context context-type="linenumber">163</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="3897348120591552265" datatype="html">
 | 
			
		||||
        <source>Manage</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">170</context>
 | 
			
		||||
          <context context-type="linenumber">172</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="7437910965833684826" datatype="html">
 | 
			
		||||
        <source>Correspondents</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">176</context>
 | 
			
		||||
          <context context-type="linenumber">178</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">178</context>
 | 
			
		||||
          <context context-type="linenumber">180</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
 | 
			
		||||
@@ -2771,11 +2793,11 @@
 | 
			
		||||
        <source>Tags</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">183</context>
 | 
			
		||||
          <context context-type="linenumber">185</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">186</context>
 | 
			
		||||
          <context context-type="linenumber">188</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
 | 
			
		||||
@@ -2806,11 +2828,11 @@
 | 
			
		||||
        <source>Document Types</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">192</context>
 | 
			
		||||
          <context context-type="linenumber">194</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">194</context>
 | 
			
		||||
          <context context-type="linenumber">196</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
 | 
			
		||||
@@ -2821,11 +2843,11 @@
 | 
			
		||||
        <source>Storage Paths</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">199</context>
 | 
			
		||||
          <context context-type="linenumber">201</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">201</context>
 | 
			
		||||
          <context context-type="linenumber">203</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
 | 
			
		||||
@@ -2836,11 +2858,11 @@
 | 
			
		||||
        <source>Custom Fields</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">206</context>
 | 
			
		||||
          <context context-type="linenumber">208</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">208</context>
 | 
			
		||||
          <context context-type="linenumber">210</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
 | 
			
		||||
@@ -2855,11 +2877,11 @@
 | 
			
		||||
        <source>Workflows</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">222</context>
 | 
			
		||||
          <context context-type="linenumber">224</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">224</context>
 | 
			
		||||
          <context context-type="linenumber">226</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
 | 
			
		||||
@@ -2870,92 +2892,92 @@
 | 
			
		||||
        <source>Mail</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">229</context>
 | 
			
		||||
          <context context-type="linenumber">231</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">232</context>
 | 
			
		||||
          <context context-type="linenumber">234</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="7844706011418789951" datatype="html">
 | 
			
		||||
        <source>Administration</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">247</context>
 | 
			
		||||
          <context context-type="linenumber">249</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="3008420115644088420" datatype="html">
 | 
			
		||||
        <source>Configuration</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">260</context>
 | 
			
		||||
          <context context-type="linenumber">262</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">262</context>
 | 
			
		||||
          <context context-type="linenumber">264</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="1534029177398918729" datatype="html">
 | 
			
		||||
        <source>GitHub</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">307</context>
 | 
			
		||||
          <context context-type="linenumber">309</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4112664765954374539" datatype="html">
 | 
			
		||||
        <source>is available.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">316,317</context>
 | 
			
		||||
          <context context-type="linenumber">318,319</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="1175891574282637937" datatype="html">
 | 
			
		||||
        <source>Click to view.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">317</context>
 | 
			
		||||
          <context context-type="linenumber">319</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="9811291095862612" datatype="html">
 | 
			
		||||
        <source>Paperless-ngx can automatically check for updates</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">321</context>
 | 
			
		||||
          <context context-type="linenumber">323</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="894819944961861800" datatype="html">
 | 
			
		||||
        <source> How does this work? </source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">328,330</context>
 | 
			
		||||
          <context context-type="linenumber">330,332</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="509090351011426949" datatype="html">
 | 
			
		||||
        <source>Update available</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">341</context>
 | 
			
		||||
          <context context-type="linenumber">343</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="1542489069631984294" datatype="html">
 | 
			
		||||
        <source>Sidebar views updated</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">251</context>
 | 
			
		||||
          <context context-type="linenumber">264</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="3547923076537026828" datatype="html">
 | 
			
		||||
        <source>Error updating sidebar views</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">254</context>
 | 
			
		||||
          <context context-type="linenumber">267</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2526035785704676448" datatype="html">
 | 
			
		||||
        <source>An error occurred while saving update checking settings.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">275</context>
 | 
			
		||||
          <context context-type="linenumber">288</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4580988005648117665" datatype="html">
 | 
			
		||||
@@ -7259,25 +7281,25 @@
 | 
			
		||||
        <source>Print failed.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">1455</context>
 | 
			
		||||
          <context context-type="linenumber">1460</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6457245677384603573" datatype="html">
 | 
			
		||||
        <source>Error loading document for printing.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">1463</context>
 | 
			
		||||
          <context context-type="linenumber">1472</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6085793215710522488" datatype="html">
 | 
			
		||||
        <source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">1528</context>
 | 
			
		||||
          <context context-type="linenumber">1537</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">1532</context>
 | 
			
		||||
          <context context-type="linenumber">1541</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4958946940233632319" datatype="html">
 | 
			
		||||
@@ -7881,17 +7903,6 @@
 | 
			
		||||
          <context context-type="linenumber">45</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="8461842260159597706" datatype="html">
 | 
			
		||||
        <source>Show</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">37</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">52</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5146398958364876914" datatype="html">
 | 
			
		||||
        <source>Sort</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
 
 | 
			
		||||
@@ -3,9 +3,23 @@
 | 
			
		||||
  i18n-title
 | 
			
		||||
  info="Review the log files for the application and for email checking."
 | 
			
		||||
  i18n-info>
 | 
			
		||||
  <div class="form-check form-switch">
 | 
			
		||||
    <input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
 | 
			
		||||
    <label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
 | 
			
		||||
  <div class="input-group input-group-sm align-items-center">
 | 
			
		||||
    <div class="input-group input-group-sm me-3">
 | 
			
		||||
      <span class="input-group-text text-muted" i18n>Show</span>
 | 
			
		||||
      <input
 | 
			
		||||
        class="form-control"
 | 
			
		||||
        type="number"
 | 
			
		||||
        min="100"
 | 
			
		||||
        step="100"
 | 
			
		||||
        [(ngModel)]="limit"
 | 
			
		||||
        (ngModelChange)="onLimitChange($event)"
 | 
			
		||||
        style="width: 100px;">
 | 
			
		||||
      <span class="input-group-text text-muted" i18n>lines</span>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="form-check form-switch mt-1">
 | 
			
		||||
      <input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
 | 
			
		||||
      <label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</pngx-page-header>
 | 
			
		||||
 | 
			
		||||
@@ -29,14 +43,19 @@
 | 
			
		||||
 | 
			
		||||
<div [ngbNavOutlet]="nav" class="mt-2"></div>
 | 
			
		||||
 | 
			
		||||
<div class="bg-dark p-3 text-light font-monospace log-container" #logContainer>
 | 
			
		||||
<cdk-virtual-scroll-viewport
 | 
			
		||||
  itemSize="20"
 | 
			
		||||
  class="bg-dark p-3 text-light font-monospace log-container"
 | 
			
		||||
  #logContainer>
 | 
			
		||||
  @if (loading && logFiles.length) {
 | 
			
		||||
    <div>
 | 
			
		||||
      <div class="spinner-border spinner-border-sm me-2" role="status"></div>
 | 
			
		||||
      <ng-container i18n>Loading...</ng-container>
 | 
			
		||||
    </div>
 | 
			
		||||
  }
 | 
			
		||||
  @for (log of logs; track $index) {
 | 
			
		||||
    <p class="m-0 p-0 log-entry-{{getLogLevel(log)}}">{{log}}</p>
 | 
			
		||||
  }
 | 
			
		||||
</div>
 | 
			
		||||
  <p *cdkVirtualFor="let log of logs"
 | 
			
		||||
     class="m-0 p-0"
 | 
			
		||||
     [ngClass]="'log-entry-' + log.level">
 | 
			
		||||
    {{log.message}}
 | 
			
		||||
  </p>
 | 
			
		||||
</cdk-virtual-scroll-viewport>
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@
 | 
			
		||||
.log-container {
 | 
			
		||||
  overflow-y: scroll;
 | 
			
		||||
  height: calc(100vh - 200px);
 | 
			
		||||
  top: 70px;
 | 
			
		||||
  top: 0;
 | 
			
		||||
 | 
			
		||||
  p {
 | 
			
		||||
    white-space: pre-wrap;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,8 @@
 | 
			
		||||
import {
 | 
			
		||||
  CdkVirtualScrollViewport,
 | 
			
		||||
  ScrollingModule,
 | 
			
		||||
} from '@angular/cdk/scrolling'
 | 
			
		||||
import { CommonModule } from '@angular/common'
 | 
			
		||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
 | 
			
		||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
 | 
			
		||||
@@ -38,6 +43,9 @@ describe('LogsComponent', () => {
 | 
			
		||||
        NgxBootstrapIconsModule.pick(allIcons),
 | 
			
		||||
        LogsComponent,
 | 
			
		||||
        PageHeaderComponent,
 | 
			
		||||
        CommonModule,
 | 
			
		||||
        CdkVirtualScrollViewport,
 | 
			
		||||
        ScrollingModule,
 | 
			
		||||
      ],
 | 
			
		||||
      providers: [
 | 
			
		||||
        provideHttpClient(withInterceptorsFromDi()),
 | 
			
		||||
@@ -54,13 +62,12 @@ describe('LogsComponent', () => {
 | 
			
		||||
    fixture = TestBed.createComponent(LogsComponent)
 | 
			
		||||
    component = fixture.componentInstance
 | 
			
		||||
    reloadSpy = jest.spyOn(component, 'reloadLogs')
 | 
			
		||||
    window.HTMLElement.prototype.scroll = function () {} // mock scroll
 | 
			
		||||
    jest.useFakeTimers()
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should display logs with first log initially', () => {
 | 
			
		||||
    expect(logSpy).toHaveBeenCalledWith('paperless')
 | 
			
		||||
    expect(logSpy).toHaveBeenCalledWith('paperless', 5000)
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    expect(fixture.debugElement.nativeElement.textContent).toContain(
 | 
			
		||||
      paperless_logs[0]
 | 
			
		||||
@@ -71,7 +78,7 @@ describe('LogsComponent', () => {
 | 
			
		||||
    fixture.debugElement
 | 
			
		||||
      .queryAll(By.directive(NgbNavLink))[1]
 | 
			
		||||
      .nativeElement.dispatchEvent(new MouseEvent('click'))
 | 
			
		||||
    expect(logSpy).toHaveBeenCalledWith('mail')
 | 
			
		||||
    expect(logSpy).toHaveBeenCalledWith('mail', 5000)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should handle error with no logs', () => {
 | 
			
		||||
@@ -83,6 +90,10 @@ describe('LogsComponent', () => {
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should auto refresh, allow toggle', () => {
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(CdkVirtualScrollViewport.prototype, 'scrollToIndex')
 | 
			
		||||
      .mockImplementation(() => undefined)
 | 
			
		||||
 | 
			
		||||
    jest.advanceTimersByTime(6000)
 | 
			
		||||
    expect(reloadSpy).toHaveBeenCalledTimes(2)
 | 
			
		||||
 | 
			
		||||
@@ -90,4 +101,13 @@ describe('LogsComponent', () => {
 | 
			
		||||
    jest.advanceTimersByTime(6000)
 | 
			
		||||
    expect(reloadSpy).toHaveBeenCalledTimes(2)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should debounce limit changes before reloading logs', () => {
 | 
			
		||||
    const initialCalls = reloadSpy.mock.calls.length
 | 
			
		||||
    component.onLimitChange(6000)
 | 
			
		||||
    jest.advanceTimersByTime(299)
 | 
			
		||||
    expect(reloadSpy).toHaveBeenCalledTimes(initialCalls)
 | 
			
		||||
    jest.advanceTimersByTime(1)
 | 
			
		||||
    expect(reloadSpy).toHaveBeenCalledTimes(initialCalls + 1)
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,11 @@
 | 
			
		||||
import {
 | 
			
		||||
  CdkVirtualScrollViewport,
 | 
			
		||||
  ScrollingModule,
 | 
			
		||||
} from '@angular/cdk/scrolling'
 | 
			
		||||
import { CommonModule } from '@angular/common'
 | 
			
		||||
import {
 | 
			
		||||
  ChangeDetectorRef,
 | 
			
		||||
  Component,
 | 
			
		||||
  ElementRef,
 | 
			
		||||
  OnDestroy,
 | 
			
		||||
  OnInit,
 | 
			
		||||
  ViewChild,
 | 
			
		||||
@@ -9,7 +13,7 @@ import {
 | 
			
		||||
} from '@angular/core'
 | 
			
		||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 | 
			
		||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { filter, takeUntil, timer } from 'rxjs'
 | 
			
		||||
import { Subject, debounceTime, filter, takeUntil, timer } from 'rxjs'
 | 
			
		||||
import { LogService } from 'src/app/services/rest/log.service'
 | 
			
		||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
 | 
			
		||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
 | 
			
		||||
@@ -21,8 +25,11 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
 | 
			
		||||
  imports: [
 | 
			
		||||
    PageHeaderComponent,
 | 
			
		||||
    NgbNavModule,
 | 
			
		||||
    CommonModule,
 | 
			
		||||
    FormsModule,
 | 
			
		||||
    ReactiveFormsModule,
 | 
			
		||||
    CdkVirtualScrollViewport,
 | 
			
		||||
    ScrollingModule,
 | 
			
		||||
  ],
 | 
			
		||||
})
 | 
			
		||||
export class LogsComponent
 | 
			
		||||
@@ -32,7 +39,7 @@ export class LogsComponent
 | 
			
		||||
  private logService = inject(LogService)
 | 
			
		||||
  private changedetectorRef = inject(ChangeDetectorRef)
 | 
			
		||||
 | 
			
		||||
  public logs: string[] = []
 | 
			
		||||
  public logs: Array<{ message: string; level: number }> = []
 | 
			
		||||
 | 
			
		||||
  public logFiles: string[] = []
 | 
			
		||||
 | 
			
		||||
@@ -40,9 +47,17 @@ export class LogsComponent
 | 
			
		||||
 | 
			
		||||
  public autoRefreshEnabled: boolean = true
 | 
			
		||||
 | 
			
		||||
  @ViewChild('logContainer') logContainer: ElementRef
 | 
			
		||||
  public limit: number = 5000
 | 
			
		||||
 | 
			
		||||
  private readonly limitChange$ = new Subject<number>()
 | 
			
		||||
 | 
			
		||||
  @ViewChild('logContainer') logContainer: CdkVirtualScrollViewport
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.limitChange$
 | 
			
		||||
      .pipe(debounceTime(300), takeUntil(this.unsubscribeNotifier))
 | 
			
		||||
      .subscribe(() => this.reloadLogs())
 | 
			
		||||
 | 
			
		||||
    this.logService
 | 
			
		||||
      .list()
 | 
			
		||||
      .pipe(takeUntil(this.unsubscribeNotifier))
 | 
			
		||||
@@ -68,16 +83,33 @@ export class LogsComponent
 | 
			
		||||
    super.ngOnDestroy()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onLimitChange(limit: number): void {
 | 
			
		||||
    this.limitChange$.next(limit)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  reloadLogs() {
 | 
			
		||||
    this.loading = true
 | 
			
		||||
    this.logService
 | 
			
		||||
      .get(this.activeLog)
 | 
			
		||||
      .get(this.activeLog, this.limit)
 | 
			
		||||
      .pipe(takeUntil(this.unsubscribeNotifier))
 | 
			
		||||
      .subscribe({
 | 
			
		||||
        next: (result) => {
 | 
			
		||||
          this.logs = result
 | 
			
		||||
          this.loading = false
 | 
			
		||||
          this.scrollToBottom()
 | 
			
		||||
          const parsed = this.parseLogsWithLevel(result)
 | 
			
		||||
          const hasChanges =
 | 
			
		||||
            parsed.length !== this.logs.length ||
 | 
			
		||||
            parsed.some((log, idx) => {
 | 
			
		||||
              const current = this.logs[idx]
 | 
			
		||||
              return (
 | 
			
		||||
                !current ||
 | 
			
		||||
                current.message !== log.message ||
 | 
			
		||||
                current.level !== log.level
 | 
			
		||||
              )
 | 
			
		||||
            })
 | 
			
		||||
          if (hasChanges) {
 | 
			
		||||
            this.logs = parsed
 | 
			
		||||
            this.scrollToBottom()
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        error: () => {
 | 
			
		||||
          this.logs = []
 | 
			
		||||
@@ -100,12 +132,19 @@ export class LogsComponent
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseLogsWithLevel(
 | 
			
		||||
    logs: string[]
 | 
			
		||||
  ): Array<{ message: string; level: number }> {
 | 
			
		||||
    return logs.map((log) => ({
 | 
			
		||||
      message: log,
 | 
			
		||||
      level: this.getLogLevel(log),
 | 
			
		||||
    }))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  scrollToBottom(): void {
 | 
			
		||||
    this.changedetectorRef.detectChanges()
 | 
			
		||||
    this.logContainer?.nativeElement.scroll({
 | 
			
		||||
      top: this.logContainer.nativeElement.scrollHeight,
 | 
			
		||||
      left: 0,
 | 
			
		||||
      behavior: 'auto',
 | 
			
		||||
    })
 | 
			
		||||
    if (this.logContainer) {
 | 
			
		||||
      this.logContainer.scrollToIndex(this.logs.length - 1)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -68,13 +68,15 @@
 | 
			
		||||
    <nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
 | 
			
		||||
      [ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating"
 | 
			
		||||
      [ngbCollapse]="isMenuCollapsed">
 | 
			
		||||
      <button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
 | 
			
		||||
        @if (slimSidebarEnabled) {
 | 
			
		||||
          <i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
 | 
			
		||||
        } @else {
 | 
			
		||||
          <i-bs width="0.9em" height="0.9em" name="chevron-double-left"></i-bs>
 | 
			
		||||
        }
 | 
			
		||||
      </button>
 | 
			
		||||
      @if (canSaveSettings) {
 | 
			
		||||
        <button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
 | 
			
		||||
          @if (slimSidebarEnabled) {
 | 
			
		||||
            <i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
 | 
			
		||||
          } @else {
 | 
			
		||||
            <i-bs width="0.9em" height="0.9em" name="chevron-double-left"></i-bs>
 | 
			
		||||
          }
 | 
			
		||||
        </button>
 | 
			
		||||
      }
 | 
			
		||||
      <div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
 | 
			
		||||
        <ul class="nav flex-column">
 | 
			
		||||
          <li class="nav-item app-link">
 | 
			
		||||
 
 | 
			
		||||
@@ -152,6 +152,19 @@ export class AppFrameComponent
 | 
			
		||||
    return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get canSaveSettings(): boolean {
 | 
			
		||||
    return (
 | 
			
		||||
      this.permissionsService.currentUserCan(
 | 
			
		||||
        PermissionAction.Change,
 | 
			
		||||
        PermissionType.UISettings
 | 
			
		||||
      ) &&
 | 
			
		||||
      this.permissionsService.currentUserCan(
 | 
			
		||||
        PermissionAction.Add,
 | 
			
		||||
        PermissionType.UISettings
 | 
			
		||||
      )
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get slimSidebarEnabled(): boolean {
 | 
			
		||||
    return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1489,6 +1489,8 @@ describe('DocumentDetailComponent', () => {
 | 
			
		||||
      mockContentWindow.onafterprint(new Event('afterprint'))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    tick(500)
 | 
			
		||||
 | 
			
		||||
    expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
 | 
			
		||||
    expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
 | 
			
		||||
 | 
			
		||||
@@ -1512,65 +1514,97 @@ describe('DocumentDetailComponent', () => {
 | 
			
		||||
    )
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should show error toast if printing throws inside iframe', fakeAsync(() => {
 | 
			
		||||
    initNormally()
 | 
			
		||||
  const iframePrintErrorCases: Array<{
 | 
			
		||||
    description: string
 | 
			
		||||
    thrownError: Error
 | 
			
		||||
    expectToast: boolean
 | 
			
		||||
  }> = [
 | 
			
		||||
    {
 | 
			
		||||
      description: 'should show error toast if printing throws inside iframe',
 | 
			
		||||
      thrownError: new Error('focus failed'),
 | 
			
		||||
      expectToast: true,
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      description:
 | 
			
		||||
        'should suppress toast if cross-origin afterprint error occurs',
 | 
			
		||||
      thrownError: new DOMException(
 | 
			
		||||
        'Accessing onafterprint triggered a cross-origin violation',
 | 
			
		||||
        'SecurityError'
 | 
			
		||||
      ),
 | 
			
		||||
      expectToast: false,
 | 
			
		||||
    },
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
    const appendChildSpy = jest
 | 
			
		||||
      .spyOn(document.body, 'appendChild')
 | 
			
		||||
      .mockImplementation((node: Node) => node)
 | 
			
		||||
    const removeChildSpy = jest
 | 
			
		||||
      .spyOn(document.body, 'removeChild')
 | 
			
		||||
      .mockImplementation((node: Node) => node)
 | 
			
		||||
    const createObjectURLSpy = jest
 | 
			
		||||
      .spyOn(URL, 'createObjectURL')
 | 
			
		||||
      .mockReturnValue('blob:mock-url')
 | 
			
		||||
    const revokeObjectURLSpy = jest
 | 
			
		||||
      .spyOn(URL, 'revokeObjectURL')
 | 
			
		||||
      .mockImplementation(() => {})
 | 
			
		||||
  iframePrintErrorCases.forEach(({ description, thrownError, expectToast }) => {
 | 
			
		||||
    it(
 | 
			
		||||
      description,
 | 
			
		||||
      fakeAsync(() => {
 | 
			
		||||
        initNormally()
 | 
			
		||||
 | 
			
		||||
    const toastSpy = jest.spyOn(toastService, 'showError')
 | 
			
		||||
        const appendChildSpy = jest
 | 
			
		||||
          .spyOn(document.body, 'appendChild')
 | 
			
		||||
          .mockImplementation((node: Node) => node)
 | 
			
		||||
        const removeChildSpy = jest
 | 
			
		||||
          .spyOn(document.body, 'removeChild')
 | 
			
		||||
          .mockImplementation((node: Node) => node)
 | 
			
		||||
        const createObjectURLSpy = jest
 | 
			
		||||
          .spyOn(URL, 'createObjectURL')
 | 
			
		||||
          .mockReturnValue('blob:mock-url')
 | 
			
		||||
        const revokeObjectURLSpy = jest
 | 
			
		||||
          .spyOn(URL, 'revokeObjectURL')
 | 
			
		||||
          .mockImplementation(() => {})
 | 
			
		||||
 | 
			
		||||
    const mockContentWindow = {
 | 
			
		||||
      focus: jest.fn().mockImplementation(() => {
 | 
			
		||||
        throw new Error('focus failed')
 | 
			
		||||
      }),
 | 
			
		||||
      print: jest.fn(),
 | 
			
		||||
      onafterprint: null,
 | 
			
		||||
    }
 | 
			
		||||
        const toastSpy = jest.spyOn(toastService, 'showError')
 | 
			
		||||
 | 
			
		||||
    const mockIframe: any = {
 | 
			
		||||
      style: {},
 | 
			
		||||
      src: '',
 | 
			
		||||
      onload: null,
 | 
			
		||||
      contentWindow: mockContentWindow,
 | 
			
		||||
    }
 | 
			
		||||
        const mockContentWindow = {
 | 
			
		||||
          focus: jest.fn().mockImplementation(() => {
 | 
			
		||||
            throw thrownError
 | 
			
		||||
          }),
 | 
			
		||||
          print: jest.fn(),
 | 
			
		||||
          onafterprint: null,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    const createElementSpy = jest
 | 
			
		||||
      .spyOn(document, 'createElement')
 | 
			
		||||
      .mockReturnValue(mockIframe as any)
 | 
			
		||||
        const mockIframe: any = {
 | 
			
		||||
          style: {},
 | 
			
		||||
          src: '',
 | 
			
		||||
          onload: null,
 | 
			
		||||
          contentWindow: mockContentWindow,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    const blob = new Blob(['test'], { type: 'application/pdf' })
 | 
			
		||||
    component.printDocument()
 | 
			
		||||
        const createElementSpy = jest
 | 
			
		||||
          .spyOn(document, 'createElement')
 | 
			
		||||
          .mockReturnValue(mockIframe as any)
 | 
			
		||||
 | 
			
		||||
    const req = httpTestingController.expectOne(
 | 
			
		||||
      `${environment.apiBaseUrl}documents/${doc.id}/download/`
 | 
			
		||||
        const blob = new Blob(['test'], { type: 'application/pdf' })
 | 
			
		||||
        component.printDocument()
 | 
			
		||||
 | 
			
		||||
        const req = httpTestingController.expectOne(
 | 
			
		||||
          `${environment.apiBaseUrl}documents/${doc.id}/download/`
 | 
			
		||||
        )
 | 
			
		||||
        req.flush(blob)
 | 
			
		||||
 | 
			
		||||
        tick()
 | 
			
		||||
 | 
			
		||||
        if (mockIframe.onload) {
 | 
			
		||||
          mockIframe.onload(new Event('load'))
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        tick(200)
 | 
			
		||||
 | 
			
		||||
        if (expectToast) {
 | 
			
		||||
          expect(toastSpy).toHaveBeenCalled()
 | 
			
		||||
        } else {
 | 
			
		||||
          expect(toastSpy).not.toHaveBeenCalled()
 | 
			
		||||
        }
 | 
			
		||||
        expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
 | 
			
		||||
        expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
 | 
			
		||||
 | 
			
		||||
        createElementSpy.mockRestore()
 | 
			
		||||
        appendChildSpy.mockRestore()
 | 
			
		||||
        removeChildSpy.mockRestore()
 | 
			
		||||
        createObjectURLSpy.mockRestore()
 | 
			
		||||
        revokeObjectURLSpy.mockRestore()
 | 
			
		||||
      })
 | 
			
		||||
    )
 | 
			
		||||
    req.flush(blob)
 | 
			
		||||
 | 
			
		||||
    tick()
 | 
			
		||||
 | 
			
		||||
    if (mockIframe.onload) {
 | 
			
		||||
      mockIframe.onload(new Event('load'))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    expect(toastSpy).toHaveBeenCalled()
 | 
			
		||||
    expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
 | 
			
		||||
    expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
 | 
			
		||||
 | 
			
		||||
    createElementSpy.mockRestore()
 | 
			
		||||
    appendChildSpy.mockRestore()
 | 
			
		||||
    removeChildSpy.mockRestore()
 | 
			
		||||
    createObjectURLSpy.mockRestore()
 | 
			
		||||
    revokeObjectURLSpy.mockRestore()
 | 
			
		||||
  }))
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -21,7 +21,7 @@ import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
 | 
			
		||||
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
 | 
			
		||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { DeviceDetectorService } from 'ngx-device-detector'
 | 
			
		||||
import { BehaviorSubject, Observable, of, Subject } from 'rxjs'
 | 
			
		||||
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
 | 
			
		||||
import {
 | 
			
		||||
  catchError,
 | 
			
		||||
  debounceTime,
 | 
			
		||||
@@ -1452,9 +1452,18 @@ export class DocumentDetailComponent
 | 
			
		||||
                URL.revokeObjectURL(blobUrl)
 | 
			
		||||
              }
 | 
			
		||||
            } catch (err) {
 | 
			
		||||
              this.toastService.showError($localize`Print failed.`, err)
 | 
			
		||||
              document.body.removeChild(iframe)
 | 
			
		||||
              URL.revokeObjectURL(blobUrl)
 | 
			
		||||
              // FF throws cross-origin error on onafterprint
 | 
			
		||||
              const isCrossOriginAfterPrintError =
 | 
			
		||||
                err instanceof DOMException &&
 | 
			
		||||
                err.message.includes('onafterprint')
 | 
			
		||||
              if (!isCrossOriginAfterPrintError) {
 | 
			
		||||
                this.toastService.showError($localize`Print failed.`, err)
 | 
			
		||||
              }
 | 
			
		||||
              timer(100).subscribe(() => {
 | 
			
		||||
                // delay to avoid FF print failure
 | 
			
		||||
                document.body.removeChild(iframe)
 | 
			
		||||
                URL.revokeObjectURL(blobUrl)
 | 
			
		||||
              })
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
 
 | 
			
		||||
@@ -68,7 +68,7 @@
 | 
			
		||||
              </td>
 | 
			
		||||
              <td>
 | 
			
		||||
                <ng-template #errorPopover>
 | 
			
		||||
                  <pre class="small text-light">
 | 
			
		||||
                  <pre class="small">
 | 
			
		||||
                    {{ mail.error }}
 | 
			
		||||
                  </pre>
 | 
			
		||||
                </ng-template>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,7 @@
 | 
			
		||||
::ng-deep .popover {
 | 
			
		||||
    max-width: 350px;
 | 
			
		||||
    max-height: 600px;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
 | 
			
		||||
    pre {
 | 
			
		||||
        white-space: pre-wrap;
 | 
			
		||||
 
 | 
			
		||||
@@ -73,9 +73,14 @@ describe('TagListComponent', () => {
 | 
			
		||||
    )
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should filter out child tags if name filter is empty, otherwise show all', () => {
 | 
			
		||||
  it('should omit matching children from top level when their parent is present', () => {
 | 
			
		||||
    const tags = [
 | 
			
		||||
      { id: 1, name: 'Tag1', parent: null },
 | 
			
		||||
      {
 | 
			
		||||
        id: 1,
 | 
			
		||||
        name: 'Tag1',
 | 
			
		||||
        parent: null,
 | 
			
		||||
        children: [{ id: 2, name: 'Tag2', parent: 1 }],
 | 
			
		||||
      },
 | 
			
		||||
      { id: 2, name: 'Tag2', parent: 1 },
 | 
			
		||||
      { id: 3, name: 'Tag3', parent: null },
 | 
			
		||||
    ]
 | 
			
		||||
@@ -86,7 +91,13 @@ describe('TagListComponent', () => {
 | 
			
		||||
 | 
			
		||||
    component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
 | 
			
		||||
    const filteredWithName = component.filterData(tags as any)
 | 
			
		||||
    expect(filteredWithName.length).toBe(3)
 | 
			
		||||
    expect(filteredWithName.length).toBe(2)
 | 
			
		||||
    expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined()
 | 
			
		||||
    expect(
 | 
			
		||||
      filteredWithName
 | 
			
		||||
        .find((t) => t.id === 1)
 | 
			
		||||
        ?.children?.some((c) => c.id === 2)
 | 
			
		||||
    ).toBe(true)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should request only parent tags when no name filter is applied', () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -69,9 +69,13 @@ export class TagListComponent extends ManagementListComponent<Tag> {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  filterData(data: Tag[]) {
 | 
			
		||||
    return this.nameFilter?.length
 | 
			
		||||
      ? [...data]
 | 
			
		||||
      : data.filter((tag) => !tag.parent)
 | 
			
		||||
    if (!this.nameFilter?.length) {
 | 
			
		||||
      return data.filter((tag) => !tag.parent)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // When filtering by name, exclude children if their parent is also present
 | 
			
		||||
    const availableIds = new Set(data.map((tag) => tag.id))
 | 
			
		||||
    return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected override getSelectableIDs(tags: Tag[]): number[] {
 | 
			
		||||
 
 | 
			
		||||
@@ -49,4 +49,14 @@ describe('LogService', () => {
 | 
			
		||||
    )
 | 
			
		||||
    expect(req.request.method).toEqual('GET')
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should pass limit param on logs get when provided', () => {
 | 
			
		||||
    const id: string = 'mail'
 | 
			
		||||
    const limit: number = 100
 | 
			
		||||
    subscription = service.get(id, limit).subscribe()
 | 
			
		||||
    const req = httpTestingController.expectOne(
 | 
			
		||||
      `${environment.apiBaseUrl}${endpoint}/${id}/?limit=${limit}`
 | 
			
		||||
    )
 | 
			
		||||
    expect(req.request.method).toEqual('GET')
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { HttpClient } from '@angular/common/http'
 | 
			
		||||
import { HttpClient, HttpParams } from '@angular/common/http'
 | 
			
		||||
import { Injectable, inject } from '@angular/core'
 | 
			
		||||
import { Observable } from 'rxjs'
 | 
			
		||||
import { environment } from 'src/environments/environment'
 | 
			
		||||
@@ -13,7 +13,13 @@ export class LogService {
 | 
			
		||||
    return this.http.get<string[]>(`${environment.apiBaseUrl}logs/`)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get(id: string): Observable<string[]> {
 | 
			
		||||
    return this.http.get<string[]>(`${environment.apiBaseUrl}logs/${id}/`)
 | 
			
		||||
  get(id: string, limit?: number): Observable<string[]> {
 | 
			
		||||
    let params = new HttpParams()
 | 
			
		||||
    if (limit !== undefined) {
 | 
			
		||||
      params = params.set('limit', limit.toString())
 | 
			
		||||
    }
 | 
			
		||||
    return this.http.get<string[]>(`${environment.apiBaseUrl}logs/${id}/`, {
 | 
			
		||||
      params,
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1041,7 +1041,7 @@ class DocumentSerializer(
 | 
			
		||||
            request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        if api_version < 9:
 | 
			
		||||
        if api_version < 9 and "created" in self.fields:
 | 
			
		||||
            # provide created as a datetime for backwards compatibility
 | 
			
		||||
            from django.utils import timezone
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -172,6 +172,35 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
 | 
			
		||||
        results = response.data["results"]
 | 
			
		||||
        self.assertEqual(len(results[0]), 0)
 | 
			
		||||
 | 
			
		||||
    def test_document_fields_api_version_8_respects_created(self):
 | 
			
		||||
        Document.objects.create(
 | 
			
		||||
            title="legacy",
 | 
			
		||||
            checksum="123",
 | 
			
		||||
            mime_type="application/pdf",
 | 
			
		||||
            created=date(2024, 1, 15),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            "/api/documents/?fields=id",
 | 
			
		||||
            headers={"Accept": "application/json; version=8"},
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_200_OK)
 | 
			
		||||
        results = response.data["results"]
 | 
			
		||||
        self.assertIn("id", results[0])
 | 
			
		||||
        self.assertNotIn("created", results[0])
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            "/api/documents/?fields=id,created",
 | 
			
		||||
            headers={"Accept": "application/json; version=8"},
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_200_OK)
 | 
			
		||||
        results = response.data["results"]
 | 
			
		||||
        self.assertIn("id", results[0])
 | 
			
		||||
        self.assertIn("created", results[0])
 | 
			
		||||
        self.assertRegex(results[0]["created"], r"^2024-01-15T00:00:00.*$")
 | 
			
		||||
 | 
			
		||||
    def test_document_legacy_created_format(self):
 | 
			
		||||
        """
 | 
			
		||||
        GIVEN:
 | 
			
		||||
@@ -2250,6 +2279,23 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_200_OK)
 | 
			
		||||
        self.assertListEqual(response.data, ["test", "test2"])
 | 
			
		||||
 | 
			
		||||
    def test_get_log_with_limit(self):
 | 
			
		||||
        log_data = "test1\ntest2\ntest3\n"
 | 
			
		||||
        with (Path(settings.LOGGING_DIR) / "paperless.log").open("w") as f:
 | 
			
		||||
            f.write(log_data)
 | 
			
		||||
        response = self.client.get("/api/logs/paperless/", {"limit": 2})
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_200_OK)
 | 
			
		||||
        self.assertListEqual(response.data, ["test2", "test3"])
 | 
			
		||||
 | 
			
		||||
    def test_get_log_with_invalid_limit(self):
 | 
			
		||||
        log_data = "test1\ntest2\n"
 | 
			
		||||
        with (Path(settings.LOGGING_DIR) / "paperless.log").open("w") as f:
 | 
			
		||||
            f.write(log_data)
 | 
			
		||||
        response = self.client.get("/api/logs/paperless/", {"limit": "abc"})
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
        response = self.client.get("/api/logs/paperless/", {"limit": -5})
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
 | 
			
		||||
    def test_invalid_regex_other_algorithm(self):
 | 
			
		||||
        for endpoint in ["correspondents", "tags", "document_types"]:
 | 
			
		||||
            response = self.client.post(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from pytest_httpx import HTTPXMock
 | 
			
		||||
from rest_framework import status
 | 
			
		||||
from rest_framework.test import APIClient
 | 
			
		||||
@@ -8,6 +9,9 @@ from paperless import version
 | 
			
		||||
class TestApiRemoteVersion:
 | 
			
		||||
    ENDPOINT = "/api/remote_version/"
 | 
			
		||||
 | 
			
		||||
    def setup_method(self):
 | 
			
		||||
        cache.clear()
 | 
			
		||||
 | 
			
		||||
    def test_remote_version_enabled_no_update_prefix(
 | 
			
		||||
        self,
 | 
			
		||||
        rest_api_client: APIClient,
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ import re
 | 
			
		||||
import tempfile
 | 
			
		||||
import zipfile
 | 
			
		||||
from collections import defaultdict
 | 
			
		||||
from collections import deque
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from time import mktime
 | 
			
		||||
@@ -50,6 +51,7 @@ from django.utils.timezone import make_aware
 | 
			
		||||
from django.utils.translation import get_language
 | 
			
		||||
from django.views import View
 | 
			
		||||
from django.views.decorators.cache import cache_control
 | 
			
		||||
from django.views.decorators.cache import cache_page
 | 
			
		||||
from django.views.decorators.http import condition
 | 
			
		||||
from django.views.decorators.http import last_modified
 | 
			
		||||
from django.views.generic import TemplateView
 | 
			
		||||
@@ -69,6 +71,7 @@ from rest_framework import parsers
 | 
			
		||||
from rest_framework import serializers
 | 
			
		||||
from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.exceptions import NotFound
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.filters import OrderingFilter
 | 
			
		||||
from rest_framework.filters import SearchFilter
 | 
			
		||||
from rest_framework.generics import GenericAPIView
 | 
			
		||||
@@ -1362,6 +1365,13 @@ class UnifiedSearchViewSet(DocumentViewSet):
 | 
			
		||||
                type=OpenApiTypes.STR,
 | 
			
		||||
                location=OpenApiParameter.PATH,
 | 
			
		||||
            ),
 | 
			
		||||
            OpenApiParameter(
 | 
			
		||||
                name="limit",
 | 
			
		||||
                type=OpenApiTypes.INT,
 | 
			
		||||
                location=OpenApiParameter.QUERY,
 | 
			
		||||
                description="Return only the last N entries from the log file",
 | 
			
		||||
                required=False,
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
        responses={
 | 
			
		||||
            (200, "application/json"): serializers.ListSerializer(
 | 
			
		||||
@@ -1393,8 +1403,22 @@ class LogViewSet(ViewSet):
 | 
			
		||||
        if not log_file.is_file():
 | 
			
		||||
            raise Http404
 | 
			
		||||
 | 
			
		||||
        limit_param = request.query_params.get("limit")
 | 
			
		||||
        if limit_param is not None:
 | 
			
		||||
            try:
 | 
			
		||||
                limit = int(limit_param)
 | 
			
		||||
            except (TypeError, ValueError):
 | 
			
		||||
                raise ValidationError({"limit": "Must be a positive integer"})
 | 
			
		||||
            if limit < 1:
 | 
			
		||||
                raise ValidationError({"limit": "Must be a positive integer"})
 | 
			
		||||
        else:
 | 
			
		||||
            limit = None
 | 
			
		||||
 | 
			
		||||
        with log_file.open() as f:
 | 
			
		||||
            lines = [line.rstrip() for line in f.readlines()]
 | 
			
		||||
            if limit is None:
 | 
			
		||||
                lines = [line.rstrip() for line in f.readlines()]
 | 
			
		||||
            else:
 | 
			
		||||
                lines = [line.rstrip() for line in deque(f, maxlen=limit)]
 | 
			
		||||
 | 
			
		||||
        return Response(lines)
 | 
			
		||||
 | 
			
		||||
@@ -2402,6 +2426,7 @@ class UiSettingsView(GenericAPIView):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@method_decorator(cache_page(60 * 15), name="dispatch")
 | 
			
		||||
@extend_schema_view(
 | 
			
		||||
    get=extend_schema(
 | 
			
		||||
        description="Get the current version of the Paperless-NGX server",
 | 
			
		||||
 
 | 
			
		||||
@@ -322,6 +322,7 @@ INSTALLED_APPS = [
 | 
			
		||||
    "paperless_tesseract.apps.PaperlessTesseractConfig",
 | 
			
		||||
    "paperless_text.apps.PaperlessTextConfig",
 | 
			
		||||
    "paperless_mail.apps.PaperlessMailConfig",
 | 
			
		||||
    "paperless_remote.apps.PaperlessRemoteParserConfig",
 | 
			
		||||
    "django.contrib.admin",
 | 
			
		||||
    "rest_framework",
 | 
			
		||||
    "rest_framework.authtoken",
 | 
			
		||||
@@ -1401,3 +1402,10 @@ WEBHOOKS_ALLOW_INTERNAL_REQUESTS = __get_boolean(
 | 
			
		||||
    "PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS",
 | 
			
		||||
    "true",
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
###############################################################################
 | 
			
		||||
# Remote Parser                                                               #
 | 
			
		||||
###############################################################################
 | 
			
		||||
REMOTE_OCR_ENGINE = os.getenv("PAPERLESS_REMOTE_OCR_ENGINE")
 | 
			
		||||
REMOTE_OCR_API_KEY = os.getenv("PAPERLESS_REMOTE_OCR_API_KEY")
 | 
			
		||||
REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT")
 | 
			
		||||
 
 | 
			
		||||
@@ -53,6 +53,15 @@ class TestUrlCanary:
 | 
			
		||||
    Verify certain URLs are still available so testing is valid still
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    # Wikimedia rejects requests without a browser-like User-Agent header and returns 403.
 | 
			
		||||
    _WIKIMEDIA_HEADERS = {
 | 
			
		||||
        "User-Agent": (
 | 
			
		||||
            "Mozilla/5.0 (X11; Linux x86_64) "
 | 
			
		||||
            "AppleWebKit/537.36 (KHTML, like Gecko) "
 | 
			
		||||
            "Chrome/123.0.0.0 Safari/537.36"
 | 
			
		||||
        ),
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    def test_online_image_exception_on_not_available(self):
 | 
			
		||||
        """
 | 
			
		||||
        GIVEN:
 | 
			
		||||
@@ -70,6 +79,7 @@ class TestUrlCanary:
 | 
			
		||||
        with pytest.raises(httpx.HTTPStatusError) as exec_info:
 | 
			
		||||
            resp = httpx.get(
 | 
			
		||||
                "https://upload.wikimedia.org/wikipedia/en/f/f7/nonexistent.png",
 | 
			
		||||
                headers=self._WIKIMEDIA_HEADERS,
 | 
			
		||||
            )
 | 
			
		||||
            resp.raise_for_status()
 | 
			
		||||
 | 
			
		||||
@@ -90,7 +100,10 @@ class TestUrlCanary:
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        # Now check the URL used in samples/sample.html
 | 
			
		||||
        resp = httpx.get("https://upload.wikimedia.org/wikipedia/en/f/f7/RickRoll.png")
 | 
			
		||||
        resp = httpx.get(
 | 
			
		||||
            "https://upload.wikimedia.org/wikipedia/en/f/f7/RickRoll.png",
 | 
			
		||||
            headers=self._WIKIMEDIA_HEADERS,
 | 
			
		||||
        )
 | 
			
		||||
        resp.raise_for_status()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										4
									
								
								src/paperless_remote/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/paperless_remote/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
# this is here so that django finds the checks.
 | 
			
		||||
from paperless_remote.checks import check_remote_parser_configured
 | 
			
		||||
 | 
			
		||||
__all__ = ["check_remote_parser_configured"]
 | 
			
		||||
							
								
								
									
										14
									
								
								src/paperless_remote/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/paperless_remote/apps.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 | 
			
		||||
from paperless_remote.signals import remote_consumer_declaration
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PaperlessRemoteParserConfig(AppConfig):
 | 
			
		||||
    name = "paperless_remote"
 | 
			
		||||
 | 
			
		||||
    def ready(self):
 | 
			
		||||
        from documents.signals import document_consumer_declaration
 | 
			
		||||
 | 
			
		||||
        document_consumer_declaration.connect(remote_consumer_declaration)
 | 
			
		||||
 | 
			
		||||
        AppConfig.ready(self)
 | 
			
		||||
							
								
								
									
										17
									
								
								src/paperless_remote/checks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/paperless_remote/checks.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.core.checks import Error
 | 
			
		||||
from django.core.checks import register
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register()
 | 
			
		||||
def check_remote_parser_configured(app_configs, **kwargs):
 | 
			
		||||
    if settings.REMOTE_OCR_ENGINE == "azureai" and not (
 | 
			
		||||
        settings.REMOTE_OCR_ENDPOINT and settings.REMOTE_OCR_API_KEY
 | 
			
		||||
    ):
 | 
			
		||||
        return [
 | 
			
		||||
            Error(
 | 
			
		||||
                "Azure AI remote parser requires endpoint and API key to be configured.",
 | 
			
		||||
            ),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    return []
 | 
			
		||||
							
								
								
									
										113
									
								
								src/paperless_remote/parsers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								src/paperless_remote/parsers.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,113 @@
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
 | 
			
		||||
from paperless_tesseract.parsers import RasterisedDocumentParser
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RemoteEngineConfig:
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        engine: str,
 | 
			
		||||
        api_key: str | None = None,
 | 
			
		||||
        endpoint: str | None = None,
 | 
			
		||||
    ):
 | 
			
		||||
        self.engine = engine
 | 
			
		||||
        self.api_key = api_key
 | 
			
		||||
        self.endpoint = endpoint
 | 
			
		||||
 | 
			
		||||
    def engine_is_valid(self):
 | 
			
		||||
        valid = self.engine in ["azureai"] and self.api_key is not None
 | 
			
		||||
        if self.engine == "azureai":
 | 
			
		||||
            valid = valid and self.endpoint is not None
 | 
			
		||||
        return valid
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RemoteDocumentParser(RasterisedDocumentParser):
 | 
			
		||||
    """
 | 
			
		||||
    This parser uses a remote OCR engine to parse documents. Currently, it supports Azure AI Vision
 | 
			
		||||
    as this is the only service that provides a remote OCR API with text-embedded PDF output.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    logging_name = "paperless.parsing.remote"
 | 
			
		||||
 | 
			
		||||
    def get_settings(self) -> RemoteEngineConfig:
 | 
			
		||||
        """
 | 
			
		||||
        Returns the configuration for the remote OCR engine, loaded from Django settings.
 | 
			
		||||
        """
 | 
			
		||||
        return RemoteEngineConfig(
 | 
			
		||||
            engine=settings.REMOTE_OCR_ENGINE,
 | 
			
		||||
            api_key=settings.REMOTE_OCR_API_KEY,
 | 
			
		||||
            endpoint=settings.REMOTE_OCR_ENDPOINT,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def supported_mime_types(self):
 | 
			
		||||
        if self.settings.engine_is_valid():
 | 
			
		||||
            return {
 | 
			
		||||
                "application/pdf": ".pdf",
 | 
			
		||||
                "image/png": ".png",
 | 
			
		||||
                "image/jpeg": ".jpg",
 | 
			
		||||
                "image/tiff": ".tiff",
 | 
			
		||||
                "image/bmp": ".bmp",
 | 
			
		||||
                "image/gif": ".gif",
 | 
			
		||||
                "image/webp": ".webp",
 | 
			
		||||
            }
 | 
			
		||||
        else:
 | 
			
		||||
            return {}
 | 
			
		||||
 | 
			
		||||
    def azure_ai_vision_parse(
 | 
			
		||||
        self,
 | 
			
		||||
        file: Path,
 | 
			
		||||
    ) -> str | None:
 | 
			
		||||
        """
 | 
			
		||||
        Uses Azure AI Vision to parse the document and return the text content.
 | 
			
		||||
        It requests a searchable PDF output with embedded text.
 | 
			
		||||
        The PDF is saved to the archive_path attribute.
 | 
			
		||||
        Returns the text content extracted from the document.
 | 
			
		||||
        If the parsing fails, it returns None.
 | 
			
		||||
        """
 | 
			
		||||
        from azure.ai.documentintelligence import DocumentIntelligenceClient
 | 
			
		||||
        from azure.ai.documentintelligence.models import AnalyzeDocumentRequest
 | 
			
		||||
        from azure.ai.documentintelligence.models import AnalyzeOutputOption
 | 
			
		||||
        from azure.ai.documentintelligence.models import DocumentContentFormat
 | 
			
		||||
        from azure.core.credentials import AzureKeyCredential
 | 
			
		||||
 | 
			
		||||
        client = DocumentIntelligenceClient(
 | 
			
		||||
            endpoint=self.settings.endpoint,
 | 
			
		||||
            credential=AzureKeyCredential(self.settings.api_key),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        with file.open("rb") as f:
 | 
			
		||||
            analyze_request = AnalyzeDocumentRequest(bytes_source=f.read())
 | 
			
		||||
            poller = client.begin_analyze_document(
 | 
			
		||||
                model_id="prebuilt-read",
 | 
			
		||||
                body=analyze_request,
 | 
			
		||||
                output_content_format=DocumentContentFormat.TEXT,
 | 
			
		||||
                output=[AnalyzeOutputOption.PDF],  # request searchable PDF output
 | 
			
		||||
                content_type="application/json",
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        poller.wait()
 | 
			
		||||
        result_id = poller.details["operation_id"]
 | 
			
		||||
        result = poller.result()
 | 
			
		||||
 | 
			
		||||
        # Download the PDF with embedded text
 | 
			
		||||
        self.archive_path = self.tempdir / "archive.pdf"
 | 
			
		||||
        with self.archive_path.open("wb") as f:
 | 
			
		||||
            for chunk in client.get_analyze_result_pdf(
 | 
			
		||||
                model_id="prebuilt-read",
 | 
			
		||||
                result_id=result_id,
 | 
			
		||||
            ):
 | 
			
		||||
                f.write(chunk)
 | 
			
		||||
 | 
			
		||||
        client.close()
 | 
			
		||||
        return result.content
 | 
			
		||||
 | 
			
		||||
    def parse(self, document_path: Path, mime_type, file_name=None):
 | 
			
		||||
        if not self.settings.engine_is_valid():
 | 
			
		||||
            self.log.warning(
 | 
			
		||||
                "No valid remote parser engine is configured, content will be empty.",
 | 
			
		||||
            )
 | 
			
		||||
            self.text = ""
 | 
			
		||||
        elif self.settings.engine == "azureai":
 | 
			
		||||
            self.text = self.azure_ai_vision_parse(document_path)
 | 
			
		||||
							
								
								
									
										18
									
								
								src/paperless_remote/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/paperless_remote/signals.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
def get_parser(*args, **kwargs):
 | 
			
		||||
    from paperless_remote.parsers import RemoteDocumentParser
 | 
			
		||||
 | 
			
		||||
    return RemoteDocumentParser(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_supported_mime_types():
 | 
			
		||||
    from paperless_remote.parsers import RemoteDocumentParser
 | 
			
		||||
 | 
			
		||||
    return RemoteDocumentParser(None).supported_mime_types()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def remote_consumer_declaration(sender, **kwargs):
 | 
			
		||||
    return {
 | 
			
		||||
        "parser": get_parser,
 | 
			
		||||
        "weight": 5,
 | 
			
		||||
        "mime_types": get_supported_mime_types(),
 | 
			
		||||
    }
 | 
			
		||||
							
								
								
									
										0
									
								
								src/paperless_remote/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/paperless_remote/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/paperless_remote/tests/samples/simple-digital.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/paperless_remote/tests/samples/simple-digital.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										24
									
								
								src/paperless_remote/tests/test_checks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/paperless_remote/tests/test_checks.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
			
		||||
from unittest import TestCase
 | 
			
		||||
 | 
			
		||||
from django.test import override_settings
 | 
			
		||||
 | 
			
		||||
from paperless_remote import check_remote_parser_configured
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestChecks(TestCase):
 | 
			
		||||
    @override_settings(REMOTE_OCR_ENGINE=None)
 | 
			
		||||
    def test_no_engine(self):
 | 
			
		||||
        msgs = check_remote_parser_configured(None)
 | 
			
		||||
        self.assertEqual(len(msgs), 0)
 | 
			
		||||
 | 
			
		||||
    @override_settings(REMOTE_OCR_ENGINE="azureai")
 | 
			
		||||
    @override_settings(REMOTE_OCR_API_KEY="somekey")
 | 
			
		||||
    @override_settings(REMOTE_OCR_ENDPOINT=None)
 | 
			
		||||
    def test_azure_no_endpoint(self):
 | 
			
		||||
        msgs = check_remote_parser_configured(None)
 | 
			
		||||
        self.assertEqual(len(msgs), 1)
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
            msgs[0].msg.startswith(
 | 
			
		||||
                "Azure AI remote parser requires endpoint and API key to be configured.",
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
							
								
								
									
										101
									
								
								src/paperless_remote/tests/test_parser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								src/paperless_remote/tests/test_parser.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,101 @@
 | 
			
		||||
import uuid
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from unittest import mock
 | 
			
		||||
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.test import override_settings
 | 
			
		||||
 | 
			
		||||
from documents.tests.utils import DirectoriesMixin
 | 
			
		||||
from documents.tests.utils import FileSystemAssertsMixin
 | 
			
		||||
from paperless_remote.parsers import RemoteDocumentParser
 | 
			
		||||
from paperless_remote.signals import get_parser
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestParser(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
 | 
			
		||||
    SAMPLE_FILES = Path(__file__).resolve().parent / "samples"
 | 
			
		||||
 | 
			
		||||
    def assertContainsStrings(self, content: str, strings: list[str]):
 | 
			
		||||
        # Asserts that all strings appear in content, in the given order.
 | 
			
		||||
        indices = []
 | 
			
		||||
        for s in strings:
 | 
			
		||||
            if s in content:
 | 
			
		||||
                indices.append(content.index(s))
 | 
			
		||||
            else:
 | 
			
		||||
                self.fail(f"'{s}' is not in '{content}'")
 | 
			
		||||
        self.assertListEqual(indices, sorted(indices))
 | 
			
		||||
 | 
			
		||||
    @mock.patch("paperless_tesseract.parsers.run_subprocess")
 | 
			
		||||
    @mock.patch("azure.ai.documentintelligence.DocumentIntelligenceClient")
 | 
			
		||||
    def test_get_text_with_azure(self, mock_client_cls, mock_subprocess):
 | 
			
		||||
        # Arrange mock Azure client
 | 
			
		||||
        mock_client = mock.Mock()
 | 
			
		||||
        mock_client_cls.return_value = mock_client
 | 
			
		||||
 | 
			
		||||
        # Simulate poller result and its `.details`
 | 
			
		||||
        mock_poller = mock.Mock()
 | 
			
		||||
        mock_poller.wait.return_value = None
 | 
			
		||||
        mock_poller.details = {"operation_id": "fake-op-id"}
 | 
			
		||||
        mock_client.begin_analyze_document.return_value = mock_poller
 | 
			
		||||
        mock_poller.result.return_value.content = "This is a test document."
 | 
			
		||||
 | 
			
		||||
        # Return dummy PDF bytes
 | 
			
		||||
        mock_client.get_analyze_result_pdf.return_value = [
 | 
			
		||||
            b"%PDF-",
 | 
			
		||||
            b"1.7 ",
 | 
			
		||||
            b"FAKEPDF",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        # Simulate pdftotext by writing dummy text to sidecar file
 | 
			
		||||
        def fake_run(cmd, *args, **kwargs):
 | 
			
		||||
            with Path(cmd[-1]).open("w", encoding="utf-8") as f:
 | 
			
		||||
                f.write("This is a test document.")
 | 
			
		||||
 | 
			
		||||
        mock_subprocess.side_effect = fake_run
 | 
			
		||||
 | 
			
		||||
        with override_settings(
 | 
			
		||||
            REMOTE_OCR_ENGINE="azureai",
 | 
			
		||||
            REMOTE_OCR_API_KEY="somekey",
 | 
			
		||||
            REMOTE_OCR_ENDPOINT="https://endpoint.cognitiveservices.azure.com",
 | 
			
		||||
        ):
 | 
			
		||||
            parser = get_parser(uuid.uuid4())
 | 
			
		||||
            parser.parse(
 | 
			
		||||
                self.SAMPLE_FILES / "simple-digital.pdf",
 | 
			
		||||
                "application/pdf",
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            self.assertContainsStrings(
 | 
			
		||||
                parser.text.strip(),
 | 
			
		||||
                ["This is a test document."],
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    @override_settings(
 | 
			
		||||
        REMOTE_OCR_ENGINE="azureai",
 | 
			
		||||
        REMOTE_OCR_API_KEY="key",
 | 
			
		||||
        REMOTE_OCR_ENDPOINT="https://endpoint.cognitiveservices.azure.com",
 | 
			
		||||
    )
 | 
			
		||||
    def test_supported_mime_types_valid_config(self):
 | 
			
		||||
        parser = RemoteDocumentParser(uuid.uuid4())
 | 
			
		||||
        expected_types = {
 | 
			
		||||
            "application/pdf": ".pdf",
 | 
			
		||||
            "image/png": ".png",
 | 
			
		||||
            "image/jpeg": ".jpg",
 | 
			
		||||
            "image/tiff": ".tiff",
 | 
			
		||||
            "image/bmp": ".bmp",
 | 
			
		||||
            "image/gif": ".gif",
 | 
			
		||||
            "image/webp": ".webp",
 | 
			
		||||
        }
 | 
			
		||||
        self.assertEqual(parser.supported_mime_types(), expected_types)
 | 
			
		||||
 | 
			
		||||
    def test_supported_mime_types_invalid_config(self):
 | 
			
		||||
        parser = get_parser(uuid.uuid4())
 | 
			
		||||
        self.assertEqual(parser.supported_mime_types(), {})
 | 
			
		||||
 | 
			
		||||
    @override_settings(
 | 
			
		||||
        REMOTE_OCR_ENGINE=None,
 | 
			
		||||
        REMOTE_OCR_API_KEY=None,
 | 
			
		||||
        REMOTE_OCR_ENDPOINT=None,
 | 
			
		||||
    )
 | 
			
		||||
    def test_parse_with_invalid_config(self):
 | 
			
		||||
        parser = get_parser(uuid.uuid4())
 | 
			
		||||
        parser.parse(self.SAMPLE_FILES / "simple-digital.pdf", "application/pdf")
 | 
			
		||||
        self.assertEqual(parser.text, "")
 | 
			
		||||
							
								
								
									
										39
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										39
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							@@ -95,6 +95,34 @@ wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842, upload-time = "2025-04-16T20:12:14.447Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "azure-ai-documentintelligence"
 | 
			
		||||
version = "1.0.2"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
 | 
			
		||||
    { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
 | 
			
		||||
    { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/44/7b/8115cd713e2caa5e44def85f2b7ebd02a74ae74d7113ba20bdd41fd6dd80/azure_ai_documentintelligence-1.0.2.tar.gz", hash = "sha256:4d75a2513f2839365ebabc0e0e1772f5601b3a8c9a71e75da12440da13b63484", size = 170940 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d9/75/c9ec040f23082f54ffb1977ff8f364c2d21c79a640a13d1c1809e7fd6b1a/azure_ai_documentintelligence-1.0.2-py3-none-any.whl", hash = "sha256:e1fb446abbdeccc9759d897898a0fe13141ed29f9ad11fc705f951925822ed59", size = 106005 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "azure-core"
 | 
			
		||||
version = "1.33.0"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
 | 
			
		||||
    { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
 | 
			
		||||
    { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/75/aa/7c9db8edd626f1a7d99d09ef7926f6f4fb34d5f9fa00dc394afdfe8e2a80/azure_core-1.33.0.tar.gz", hash = "sha256:f367aa07b5e3005fec2c1e184b882b0b039910733907d001c20fb08ebb8c0eb9", size = 295633 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/07/b7/76b7e144aa53bd206bf1ce34fa75350472c3f69bf30e5c8c18bc9881035d/azure_core-1.33.0-py3-none-any.whl", hash = "sha256:9b5b6d0223a1d38c37500e6971118c1e0f13f54951e6893968b38910bc9cda8f", size = 207071 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "babel"
 | 
			
		||||
version = "2.17.0"
 | 
			
		||||
@@ -1451,6 +1479,15 @@ wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c7/fc/4e5a141c3f7c7bed550ac1f69e599e92b6be449dd4677ec09f325cad0955/inotifyrecursive-0.3.5-py3-none-any.whl", hash = "sha256:7e5f4a2e1dc2bef0efa3b5f6b339c41fb4599055a2b54909d020e9e932cc8d2f", size = 8009, upload-time = "2020-11-20T12:38:46.981Z" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "isodate"
 | 
			
		||||
version = "0.7.2"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "jinja2"
 | 
			
		||||
version = "3.1.6"
 | 
			
		||||
@@ -2118,6 +2155,7 @@ name = "paperless-ngx"
 | 
			
		||||
version = "2.19.3"
 | 
			
		||||
source = { virtual = "." }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "azure-ai-documentintelligence", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
 | 
			
		||||
    { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
 | 
			
		||||
    { name = "bleach", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
 | 
			
		||||
    { name = "celery", extra = ["redis"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
 | 
			
		||||
@@ -2254,6 +2292,7 @@ typing = [
 | 
			
		||||
 | 
			
		||||
[package.metadata]
 | 
			
		||||
requires-dist = [
 | 
			
		||||
    { name = "azure-ai-documentintelligence", specifier = ">=1.0.2" },
 | 
			
		||||
    { name = "babel", specifier = ">=2.17" },
 | 
			
		||||
    { name = "bleach", specifier = "~=6.2.0" },
 | 
			
		||||
    { name = "celery", extras = ["redis"], specifier = "~=5.5.1" },
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user