Compare commits

..

71 Commits

Author SHA1 Message Date
shamoon
32bdf11f7f Merge branch 'dev' into feature-remote-ocr-2 2025-11-02 08:14:04 -08:00
GitHub Actions
3dc7cf3da1 Auto translate strings 2025-11-01 20:22:23 +00:00
shamoon
819f606335 Chore: hide slim toggler if insufficient permissions 2025-11-01 13:18:49 -07:00
shamoon
ad45e3f747 Fix: respect fields parameter for created field (#11251) 2025-11-01 13:13:39 -07:00
shamoon
74b10db028 Fix: improve legibility of processed mail error popover in light mode (#11258) 2025-11-01 12:49:05 -07:00
shamoon
cffb9c34f0 Chore: add headers for wikipedia CI tests (#11253) 2025-11-01 09:37:49 -07:00
GitHub Actions
6f52614817 Auto translate strings 2025-11-01 14:53:03 +00:00
shamoon
a0d3527d20 Fixhancement: truncate large logs, improve auto-scroll (#11239) 2025-11-01 07:49:52 -07:00
shamoon
4e64ca7ca6 Chore: add max-height and overflow to processedmail error popover (#11252) 2025-11-01 07:49:31 -07:00
GitHub Actions
e9511bd3da Auto translate strings 2025-10-31 01:28:27 +00:00
shamoon
8b9ca75a90 Fix: delay iframe DOM removal, handle onafterprint error for print in FF (#11237) 2025-10-30 18:26:42 -07:00
shamoon
9f0a4ac19d Sure sonar, consolidate 2025-10-30 18:00:19 -07:00
shamoon
8f969ecab5 Fix: delay iframe DOM removal for print in FF 2025-10-30 17:24:44 -07:00
shamoon
245e52a4eb Coverage 2025-10-30 17:00:15 -07:00
shamoon
a8c75d95d8 Update document-detail.component.ts 2025-10-30 17:00:15 -07:00
shamoon
d6e2456baf Update document-detail.component.ts 2025-10-30 17:00:15 -07:00
shamoon
3b75d3271e Fix: delay iframe DOM removal for print in FF 2025-10-30 17:00:15 -07:00
GitHub Actions
e88816d141 Auto translate strings 2025-10-30 23:36:37 +00:00
CanbiZ
e5bd4713ac Performance: use virtual scroll container and log level parsing for logs view (#11233)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-10-30 23:34:53 +00:00
shamoon
b9aced07fb Chore: cache Github version check for 15 minutes (#11235) 2025-10-30 13:53:30 -07:00
shamoon
6b55740f56 Fix: de-deduplicate children in tag list when filtering (#11229) 2025-10-30 07:02:00 -07:00
shamoon
0627ca69f5 Merge branch 'dev' into feature-remote-ocr-2 2025-10-29 11:13:53 -07:00
shamoon
f5525bbdff Merge branch 'dev' into feature-remote-ocr-2 2025-10-27 21:22:42 -07:00
shamoon
a21a2a41a8 Merge branch 'dev' into feature-remote-ocr-2 2025-10-26 07:41:51 -07:00
shamoon
cc73ed8b86 Merge branch 'dev' into feature-remote-ocr-2 2025-10-24 16:48:07 -07:00
shamoon
0c706b2316 Merge branch 'dev' into feature-remote-ocr-2 2025-10-23 16:38:35 -07:00
shamoon
85b7b6874d Merge branch 'dev' into feature-remote-ocr-2 2025-10-22 21:53:07 -07:00
shamoon
56b26185fa Merge branch 'dev' into feature-remote-ocr-2 2025-10-21 08:23:20 -07:00
shamoon
6537fade7b Merge branch 'dev' into feature-remote-ocr-2 2025-10-15 16:04:02 -07:00
shamoon
9f8090816f Merge branch 'dev' into feature-remote-ocr-2 2025-10-09 12:54:58 -07:00
shamoon
1de7c52478 Merge branch 'dev' into feature-remote-ocr-2 2025-10-01 19:24:38 -07:00
shamoon
9aaaa6f069 Merge branch 'dev' into feature-remote-ocr-2 2025-09-30 09:14:56 -07:00
shamoon
c3a20b7797 Merge branch 'dev' into feature-remote-ocr-2 2025-09-28 15:06:37 -07:00
shamoon
476556379b Merge branch 'dev' into feature-remote-ocr-2 2025-09-24 13:46:49 -07:00
shamoon
e5cafff043 Merge branch 'dev' into feature-remote-ocr-2 2025-09-22 13:42:55 -07:00
shamoon
8e0d574e99 Merge branch 'dev' into feature-remote-ocr-2 2025-09-21 16:18:13 -07:00
shamoon
8a5820328e Sonar suggestions 2025-09-17 19:18:47 -07:00
shamoon
809d62a2f4 Merge branch 'dev' into feature-remote-ocr-2 2025-09-17 16:51:23 -07:00
shamoon
0d87f94b9b Merge branch 'dev' into feature-remote-ocr-2 2025-09-14 14:01:35 -07:00
shamoon
315b90f8e5 Add typing to assertContainsStrings test util 2025-09-11 13:56:14 -07:00
shamoon
47b2d2964b Use regular testcase instead of django, config check test 2025-09-11 13:52:10 -07:00
shamoon
e05639ae4e tempdir already a path 2025-09-11 13:49:30 -07:00
shamoon
f400a8cb2f Close client 2025-09-11 13:49:06 -07:00
shamoon
26abcf5612 Also ensure API key is set 2025-09-11 13:48:06 -07:00
shamoon
afde52430d Merge branch 'dev' into feature-remote-ocr-2 2025-09-11 13:25:53 -07:00
shamoon
716f2da652 Merge branch 'dev' into feature-remote-ocr-2 2025-09-08 11:36:49 -07:00
shamoon
c54073b7c2 Merge branch 'dev' into feature-remote-ocr-2 2025-09-04 09:16:59 -07:00
shamoon
247e6f39dc Merge branch 'dev' into feature-remote-ocr-2 2025-09-01 20:10:40 -07:00
shamoon
1e6dfc4481 Merge branch 'dev' into feature-remote-ocr-2 2025-08-26 13:30:39 -07:00
shamoon
7cc0750066 Add note on costs and limitations for Azure OCR 2025-08-24 05:47:07 -07:00
shamoon
bd6585d3b4 Merge branch 'dev' into feature-remote-ocr-2 2025-08-22 08:54:26 -07:00
shamoon
717e828a1d Merge branch 'dev' into feature-remote-ocr-2 2025-08-17 21:25:14 -07:00
shamoon
07381d48e6 Merge branch 'dev' into feature-remote-ocr-2 2025-08-17 07:49:58 -07:00
shamoon
dd0ffaf312 Merge branch 'dev' into feature-remote-ocr-2 2025-08-11 10:48:36 -07:00
shamoon
264504affc Fix consumer declaration file extensions 2025-08-10 05:32:52 -07:00
shamoon
4feedf2add Merge branch 'dev' into feature-remote-ocr-2 2025-08-06 16:04:25 -04:00
shamoon
2f76cf9831 Merge branch 'dev' into feature-remote-ocr-2 2025-08-01 23:55:49 -04:00
shamoon
1002d37f6b Update test_parser.py 2025-07-09 11:05:37 -07:00
shamoon
d260a94740 Update parsers.py 2025-07-09 11:02:57 -07:00
shamoon
88c69b83ea Update index.md 2025-07-09 11:00:12 -07:00
shamoon
2557ee2014 Update docs to mention remote OCR with Azure AI 2025-07-09 09:53:30 -07:00
shamoon
3c75deed80 Add paperless_remote tests to testpaths 2025-07-08 14:19:45 -07:00
shamoon
d05343c927 Test fixes / coverage 2025-07-08 14:19:45 -07:00
shamoon
e7972b7eaf Coverage 2025-07-08 14:19:45 -07:00
shamoon
75a091cc0d Fix test 2025-07-08 14:19:44 -07:00
shamoon
dca74803fd Use output_content_format poller.result to get clean content 2025-07-08 14:19:44 -07:00
shamoon
3cf3d868d0 Some docs 2025-07-08 14:19:43 -07:00
shamoon
bf4fc6604a Test 2025-07-08 14:19:43 -07:00
shamoon
e8c1eb86fa This actually works
[ci skip]
2025-07-08 14:19:43 -07:00
shamoon
c3dad3cf69 Basic parse 2025-07-08 14:19:42 -07:00
shamoon
811bd66088 Ok, restart implementing this with just azure
[ci skip]
2025-07-08 14:19:42 -07:00
35 changed files with 816 additions and 172 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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",

View File

@@ -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">

View File

@@ -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>

View File

@@ -18,7 +18,7 @@
.log-container {
overflow-y: scroll;
height: calc(100vh - 200px);
top: 70px;
top: 0;
p {
white-space: pre-wrap;

View File

@@ -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)
})
})

View File

@@ -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)
}
}
}

View File

@@ -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">

View File

@@ -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)
}

View File

@@ -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()
}))
})
})

View File

@@ -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)
})
}
}
},

View File

@@ -68,7 +68,7 @@
</td>
<td>
<ng-template #errorPopover>
<pre class="small text-light">
<pre class="small">
{{ mail.error }}
</pre>
</ng-template>

View File

@@ -1,5 +1,7 @@
::ng-deep .popover {
max-width: 350px;
max-height: 600px;
overflow: hidden;
pre {
white-space: pre-wrap;

View File

@@ -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', () => {

View File

@@ -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[] {

View File

@@ -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')
})
})

View File

@@ -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,
})
}
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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",

View File

@@ -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")

View File

@@ -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()

View 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"]

View 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)

View 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 []

View 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)

View 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(),
}

View File

Binary file not shown.

View 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.",
),
)

View 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
View File

@@ -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" },