Compare commits

...

9 Commits

Author SHA1 Message Date
shamoon
422bffe1a6
Performance: pre-filter document list in scheduled workflow checks (#10031) 2025-06-03 21:47:29 +00:00
dependabot[bot]
31351c5f5c
Chore(deps): Bump the small-changes group across 1 directory with 3 updates (#10085)
Bumps the small-changes group with 3 updates in the / directory: [concurrent-log-handler](https://github.com/Preston-Landers/concurrent-log-handler), [ocrmypdf](https://github.com/ocrmypdf/OCRmyPDF) and [setproctitle](https://github.com/dvarrazzo/py-setproctitle).


Updates `concurrent-log-handler` from 0.9.25 to 0.9.26
- [Release notes](https://github.com/Preston-Landers/concurrent-log-handler/releases)
- [Changelog](https://github.com/Preston-Landers/concurrent-log-handler/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Preston-Landers/concurrent-log-handler/compare/0.9.25...0.9.26)

Updates `ocrmypdf` from 16.10.0 to 16.10.2
- [Release notes](https://github.com/ocrmypdf/OCRmyPDF/releases)
- [Changelog](https://github.com/ocrmypdf/OCRmyPDF/blob/main/docs/release_notes.md)
- [Commits](https://github.com/ocrmypdf/OCRmyPDF/compare/v16.10.0...v16.10.2)

Updates `setproctitle` from 1.3.5 to 1.3.6
- [Changelog](https://github.com/dvarrazzo/py-setproctitle/blob/master/HISTORY.rst)
- [Commits](https://github.com/dvarrazzo/py-setproctitle/compare/version-1.3.5...version-1.3.6)

---
updated-dependencies:
- dependency-name: concurrent-log-handler
  dependency-version: 0.9.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: small-changes
- dependency-name: ocrmypdf
  dependency-version: 16.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: small-changes
- dependency-name: setproctitle
  dependency-version: 1.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: small-changes
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-03 19:57:32 +00:00
shamoon
c30cf2e0cd
Chore: fix naive datetime warnings 2025-06-03 12:42:18 -07:00
shamoon
e97cfb9b5e
Chore: refactor consumer plugin checks to a pre-flight plugin (#9994) 2025-06-03 19:28:49 +00:00
dependabot[bot]
42100588d5
Chore(deps): Update granian[uvloop] requirement from ~=2.2.0 to ~=2.3.2 (#10055)
Updates the requirements on [granian[uvloop]](https://github.com/emmett-framework/granian) to permit the latest version.
- [Release notes](https://github.com/emmett-framework/granian/releases)
- [Commits](https://github.com/emmett-framework/granian/compare/v2.2.0...v2.3.2)

---
updated-dependencies:
- dependency-name: granian[uvloop]
  dependency-version: 2.3.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-03 12:15:00 -07:00
shamoon
bc2facc87f
Chore: use pathlib in remaining tests 2025-06-03 11:48:17 -07:00
shamoon
4e082f997c
Fix: better handle favicon with static dir (#10107) 2025-06-03 08:05:59 -07:00
GitHub Actions
1512599f4f Auto translate strings 2025-06-02 19:12:59 +00:00
dependabot[bot]
6c8f0b54ad
Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 18 updates (#10099)
* Chore(deps): Bump the frontend-angular-dependencies group

Bumps the frontend-angular-dependencies group in /src-ui with 22 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/cdk](https://github.com/angular/components) | `19.2.14` | `20.0.1` |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `19.2.9` | `19.2.14` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `19.2.9` | `19.2.14` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `19.2.9` | `19.2.14` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `19.2.9` | `19.2.14` |
| [@angular/localize](https://github.com/angular/angular) | `19.2.9` | `19.2.14` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `19.2.9` | `19.2.14` |
| [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `19.2.9` | `19.2.14` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `19.2.9` | `19.2.14` |
| [@ng-select/ng-select](https://github.com/ng-select/ng-select) | `14.7.0` | `14.9.0` |
| [ngx-cookie-service](https://github.com/stevermeister/ngx-cookie-service) | `19.1.2` | `20.0.1` |
| [ngx-device-detector](https://github.com/AhsanAyaz/ngx-device-detector) | `9.0.0` | `10.0.2` |
| [@angular-devkit/build-angular](https://github.com/angular/angular-cli) | `19.2.10` | `19.2.14` |
| [@angular-devkit/core](https://github.com/angular/angular-cli) | `19.2.10` | `19.2.14` |
| [@angular-devkit/schematics](https://github.com/angular/angular-cli) | `19.2.10` | `19.2.14` |
| [@angular-eslint/builder](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/builder) | `19.3.0` | `19.6.0` |
| [@angular-eslint/eslint-plugin](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin) | `19.3.0` | `19.6.0` |
| [@angular-eslint/eslint-plugin-template](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin-template) | `19.3.0` | `19.6.0` |
| [@angular-eslint/schematics](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/schematics) | `19.3.0` | `19.6.0` |
| [@angular-eslint/template-parser](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/template-parser) | `19.3.0` | `19.6.0` |
| [@angular/cli](https://github.com/angular/angular-cli) | `19.2.10` | `19.2.14` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `19.2.9` | `19.2.14` |


Updates `@angular/cdk` from 19.2.14 to 20.0.1
- [Release notes](https://github.com/angular/components/releases)
- [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/components/compare/19.2.14...20.0.1)

Updates `@angular/common` from 19.2.9 to 19.2.14
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.14/packages/common)

Updates `@angular/compiler` from 19.2.9 to 19.2.14
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.14/packages/compiler)

Updates `@angular/core` from 19.2.9 to 19.2.14
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.14/packages/core)

Updates `@angular/forms` from 19.2.9 to 19.2.14
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.14/packages/forms)

Updates `@angular/localize` from 19.2.9 to 19.2.14
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/compare/19.2.9...19.2.14)

Updates `@angular/platform-browser` from 19.2.9 to 19.2.14
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.14/packages/platform-browser)

Updates `@angular/platform-browser-dynamic` from 19.2.9 to 19.2.14
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.14/packages/platform-browser-dynamic)

Updates `@angular/router` from 19.2.9 to 19.2.14
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.14/packages/router)

Updates `@ng-select/ng-select` from 14.7.0 to 14.9.0
- [Release notes](https://github.com/ng-select/ng-select/releases)
- [Changelog](https://github.com/ng-select/ng-select/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ng-select/ng-select/compare/v14.7.0...v14.9.0)

Updates `ngx-cookie-service` from 19.1.2 to 20.0.1
- [Release notes](https://github.com/stevermeister/ngx-cookie-service/releases)
- [Changelog](https://github.com/stevermeister/ngx-cookie-service/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stevermeister/ngx-cookie-service/compare/v19.1.2...v20.0.1)

Updates `ngx-device-detector` from 9.0.0 to 10.0.2
- [Release notes](https://github.com/AhsanAyaz/ngx-device-detector/releases)
- [Changelog](https://github.com/AhsanAyaz/ngx-device-detector/blob/master/steps-to-release.md)
- [Commits](https://github.com/AhsanAyaz/ngx-device-detector/compare/v9.0.0...v10.0.2)

Updates `@angular-devkit/build-angular` from 19.2.10 to 19.2.14
- [Release notes](https://github.com/angular/angular-cli/releases)
- [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular-cli/compare/19.2.10...19.2.14)

Updates `@angular-devkit/core` from 19.2.10 to 19.2.14
- [Release notes](https://github.com/angular/angular-cli/releases)
- [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular-cli/compare/19.2.10...19.2.14)

Updates `@angular-devkit/schematics` from 19.2.10 to 19.2.14
- [Release notes](https://github.com/angular/angular-cli/releases)
- [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular-cli/compare/19.2.10...19.2.14)

Updates `@angular-eslint/builder` from 19.3.0 to 19.6.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/builder/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.6.0/packages/builder)

Updates `@angular-eslint/eslint-plugin` from 19.3.0 to 19.6.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.6.0/packages/eslint-plugin)

Updates `@angular-eslint/eslint-plugin-template` from 19.3.0 to 19.6.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.6.0/packages/eslint-plugin-template)

Updates `@angular-eslint/schematics` from 19.3.0 to 19.6.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/schematics/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.6.0/packages/schematics)

Updates `@angular-eslint/template-parser` from 19.3.0 to 19.6.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/template-parser/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.6.0/packages/template-parser)

Updates `@angular/cli` from 19.2.10 to 19.2.14
- [Release notes](https://github.com/angular/angular-cli/releases)
- [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular-cli/compare/19.2.10...19.2.14)

Updates `@angular/compiler-cli` from 19.2.9 to 19.2.14
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.14/packages/compiler-cli)

---
updated-dependencies:
- dependency-name: "@angular/cdk"
  dependency-version: 20.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/common"
  dependency-version: 19.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler"
  dependency-version: 19.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/core"
  dependency-version: 19.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/forms"
  dependency-version: 19.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/localize"
  dependency-version: 19.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser"
  dependency-version: 19.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser-dynamic"
  dependency-version: 19.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/router"
  dependency-version: 19.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@ng-select/ng-select"
  dependency-version: 14.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: ngx-cookie-service
  dependency-version: 20.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-angular-dependencies
- dependency-name: ngx-device-detector
  dependency-version: 10.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/build-angular"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/core"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/schematics"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/builder"
  dependency-version: 19.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin"
  dependency-version: 19.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin-template"
  dependency-version: 19.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/schematics"
  dependency-version: 19.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/template-parser"
  dependency-version: 19.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/cli"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler-cli"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* Revert "Chore(deps): Bump the frontend-angular-dependencies group"

This reverts commit 9bd02e2bc9287331a2134262ea0120a88ab2f625.

* Bump core angular cli to 19.2.14

* Bump @angular-devkit/build-angular to 19.2.14

* Bump @angular-devkit/core to 19.2.14

* bump @angular-devkit/schematics@ to 19.2.14

* bump @angular-eslint packages to 19.7.0

* Bump @ng-select/ng-select to 14.9.0

* Upgrade angular core and compiler to 19.2.14

* pnpm up @angular/forms@~19.2.14

* pnpm up @angular/localize@~19.2.14 --lockfile-only

* pnpm up @angular/platform-browser@~19.2.14 --lockfile-only

* pnpm up @angular/platform-browser-dynamic@~19.2.14 --lockfile-only

* pnpm up @angular/router@~19.2.14 --lockfile-only

* pnpm up @angular/compiler-cli@~19.2.14 --lockfile-only

* @angular/common to 19.2.13

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-06-02 12:11:17 -07:00
16 changed files with 2923 additions and 2820 deletions

View File

@ -78,7 +78,7 @@ optional-dependencies.postgres = [
"psycopg-c==3.2.5", "psycopg-c==3.2.5",
] ]
optional-dependencies.webserver = [ optional-dependencies.webserver = [
"granian[uvloop]~=2.2.0", "granian[uvloop]~=2.3.2",
] ]
[dependency-groups] [dependency-groups]
@ -221,15 +221,6 @@ lint.per-file-ignores."src/documents/parsers.py" = [
lint.per-file-ignores."src/documents/signals/handlers.py" = [ lint.per-file-ignores."src/documents/signals/handlers.py" = [
"PTH", "PTH",
] # TODO Enable & remove ] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_consumer.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_file_handling.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_migration_archive_files.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/views.py" = [ lint.per-file-ignores."src/documents/views.py" = [
"PTH", "PTH",
] # TODO Enable & remove ] # TODO Enable & remove
@ -239,9 +230,6 @@ lint.per-file-ignores."src/paperless/checks.py" = [
lint.per-file-ignores."src/paperless/settings.py" = [ lint.per-file-ignores."src/paperless/settings.py" = [
"PTH", "PTH",
] # TODO Enable & remove ] # TODO Enable & remove
lint.per-file-ignores."src/paperless/views.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/paperless_mail/mail.py" = [ lint.per-file-ignores."src/paperless_mail/mail.py" = [
"PTH", "PTH",
] # TODO Enable & remove ] # TODO Enable & remove

View File

@ -5,14 +5,14 @@
<trans-unit id="ngb.alert.close" datatype="html"> <trans-unit id="ngb.alert.close" datatype="html">
<source>Close</source> <source>Close</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/alert/alert.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/alert/alert.ts</context>
<context context-type="linenumber">51</context> <context context-type="linenumber">51</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.carousel.slide-number" datatype="html"> <trans-unit id="ngb.carousel.slide-number" datatype="html">
<source> Slide <x id="INTERPOLATION" equiv-text="ueryList&lt;NgbSli"/> of <x id="INTERPOLATION_1" equiv-text="EventSource = N"/> </source> <source> Slide <x id="INTERPOLATION" equiv-text="ueryList&lt;NgbSli"/> of <x id="INTERPOLATION_1" equiv-text="EventSource = N"/> </source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/carousel/carousel.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/carousel/carousel.ts</context>
<context context-type="linenumber">132,136</context> <context context-type="linenumber">132,136</context>
</context-group> </context-group>
<note priority="1" from="description">Currently selected slide number read by screen reader</note> <note priority="1" from="description">Currently selected slide number read by screen reader</note>
@ -20,212 +20,212 @@
<trans-unit id="ngb.carousel.previous" datatype="html"> <trans-unit id="ngb.carousel.previous" datatype="html">
<source>Previous</source> <source>Previous</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/carousel/carousel.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/carousel/carousel.ts</context>
<context context-type="linenumber">158,160</context> <context context-type="linenumber">158,160</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.carousel.next" datatype="html"> <trans-unit id="ngb.carousel.next" datatype="html">
<source>Next</source> <source>Next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/carousel/carousel.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/carousel/carousel.ts</context>
<context context-type="linenumber">199</context> <context context-type="linenumber">199</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.datepicker.previous-month" datatype="html"> <trans-unit id="ngb.datepicker.previous-month" datatype="html">
<source>Previous month</source> <source>Previous month</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">77,79</context> <context context-type="linenumber">77,79</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">102</context> <context context-type="linenumber">102</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.datepicker.next-month" datatype="html"> <trans-unit id="ngb.datepicker.next-month" datatype="html">
<source>Next month</source> <source>Next month</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">102</context> <context context-type="linenumber">102</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">102</context> <context context-type="linenumber">102</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.HH" datatype="html"> <trans-unit id="ngb.timepicker.HH" datatype="html">
<source>HH</source> <source>HH</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.toast.close-aria" datatype="html"> <trans-unit id="ngb.toast.close-aria" datatype="html">
<source>Close</source> <source>Close</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.datepicker.select-month" datatype="html"> <trans-unit id="ngb.datepicker.select-month" datatype="html">
<source>Select month</source> <source>Select month</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.first" datatype="html"> <trans-unit id="ngb.pagination.first" datatype="html">
<source>««</source> <source>««</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.hours" datatype="html"> <trans-unit id="ngb.timepicker.hours" datatype="html">
<source>Hours</source> <source>Hours</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.previous" datatype="html"> <trans-unit id="ngb.pagination.previous" datatype="html">
<source>«</source> <source>«</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.MM" datatype="html"> <trans-unit id="ngb.timepicker.MM" datatype="html">
<source>MM</source> <source>MM</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.next" datatype="html"> <trans-unit id="ngb.pagination.next" datatype="html">
<source>»</source> <source>»</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.datepicker.select-year" datatype="html"> <trans-unit id="ngb.datepicker.select-year" datatype="html">
<source>Select year</source> <source>Select year</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.minutes" datatype="html"> <trans-unit id="ngb.timepicker.minutes" datatype="html">
<source>Minutes</source> <source>Minutes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.last" datatype="html"> <trans-unit id="ngb.pagination.last" datatype="html">
<source>»»</source> <source>»»</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.first-aria" datatype="html"> <trans-unit id="ngb.pagination.first-aria" datatype="html">
<source>First</source> <source>First</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.increment-hours" datatype="html"> <trans-unit id="ngb.timepicker.increment-hours" datatype="html">
<source>Increment hours</source> <source>Increment hours</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.previous-aria" datatype="html"> <trans-unit id="ngb.pagination.previous-aria" datatype="html">
<source>Previous</source> <source>Previous</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html"> <trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
<source>Decrement hours</source> <source>Decrement hours</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.next-aria" datatype="html"> <trans-unit id="ngb.pagination.next-aria" datatype="html">
<source>Next</source> <source>Next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html"> <trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
<source>Increment minutes</source> <source>Increment minutes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.last-aria" datatype="html"> <trans-unit id="ngb.pagination.last-aria" datatype="html">
<source>Last</source> <source>Last</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html"> <trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
<source>Decrement minutes</source> <source>Decrement minutes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.SS" datatype="html"> <trans-unit id="ngb.timepicker.SS" datatype="html">
<source>SS</source> <source>SS</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.seconds" datatype="html"> <trans-unit id="ngb.timepicker.seconds" datatype="html">
<source>Seconds</source> <source>Seconds</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html"> <trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
<source>Increment seconds</source> <source>Increment seconds</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html"> <trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
<source>Decrement seconds</source> <source>Decrement seconds</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.PM" datatype="html"> <trans-unit id="ngb.timepicker.PM" datatype="html">
<source><x id="INTERPOLATION"/></source> <source><x id="INTERPOLATION"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/ngb-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
@ -233,7 +233,7 @@
<source><x id="INTERPOLATION" equiv-text="barConfig); <source><x id="INTERPOLATION" equiv-text="barConfig);
pu"/></source> pu"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.9_@angular+core@19.2.9_rxjs@7.8._86f32aa280b87d913a06e5a1e90ee24c/node_modules/src/progressbar/progressbar.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@18.0.0_@angular+common@19.2.13_@angular+core@19.2.14_rxjs@7._ce5a45f3b9d5ca136f928f24177f8d04/node_modules/src/progressbar/progressbar.ts</context>
<context context-type="linenumber">41,42</context> <context context-type="linenumber">41,42</context>
</context-group> </context-group>
</trans-unit> </trans-unit>

View File

@ -12,16 +12,16 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^19.2.14", "@angular/cdk": "^19.2.14",
"@angular/common": "~19.2.9", "@angular/common": "~19.2.13",
"@angular/compiler": "~19.2.9", "@angular/compiler": "~19.2.14",
"@angular/core": "~19.2.9", "@angular/core": "~19.2.14",
"@angular/forms": "~19.2.9", "@angular/forms": "~19.2.14",
"@angular/localize": "~19.2.9", "@angular/localize": "~19.2.14",
"@angular/platform-browser": "~19.2.9", "@angular/platform-browser": "~19.2.14",
"@angular/platform-browser-dynamic": "~19.2.9", "@angular/platform-browser-dynamic": "~19.2.14",
"@angular/router": "~19.2.9", "@angular/router": "~19.2.14",
"@ng-bootstrap/ng-bootstrap": "^18.0.0", "@ng-bootstrap/ng-bootstrap": "^18.0.0",
"@ng-select/ng-select": "^14.7.0", "@ng-select/ng-select": "^14.9.0",
"@ngneat/dirty-check-forms": "^3.0.3", "@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.6", "bootstrap": "^5.3.6",
@ -42,16 +42,16 @@
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "^19.0.1", "@angular-builders/custom-webpack": "^19.0.1",
"@angular-builders/jest": "^19.0.1", "@angular-builders/jest": "^19.0.1",
"@angular-devkit/build-angular": "^19.2.10", "@angular-devkit/build-angular": "^19.2.14",
"@angular-devkit/core": "^19.2.10", "@angular-devkit/core": "^19.2.14",
"@angular-devkit/schematics": "^19.2.10", "@angular-devkit/schematics": "^19.2.14",
"@angular-eslint/builder": "19.3.0", "@angular-eslint/builder": "19.7.0",
"@angular-eslint/eslint-plugin": "19.3.0", "@angular-eslint/eslint-plugin": "19.7.0",
"@angular-eslint/eslint-plugin-template": "19.3.0", "@angular-eslint/eslint-plugin-template": "19.7.0",
"@angular-eslint/schematics": "19.3.0", "@angular-eslint/schematics": "19.7.0",
"@angular-eslint/template-parser": "19.3.0", "@angular-eslint/template-parser": "19.7.0",
"@angular/cli": "~19.2.10", "@angular/cli": "~19.2.14",
"@angular/compiler-cli": "~19.2.9", "@angular/compiler-cli": "~19.2.14",
"@codecov/webpack-plugin": "^1.9.1", "@codecov/webpack-plugin": "^1.9.1",
"@playwright/test": "^1.51.1", "@playwright/test": "^1.51.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",

1783
src-ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -98,15 +98,7 @@ class ConsumerStatusShortMessage(str, Enum):
FAILED = "failed" FAILED = "failed"
class ConsumerPlugin( class ConsumerPluginMixin:
AlwaysRunPluginMixin,
NoSetupPluginMixin,
NoCleanupPluginMixin,
LoggingMixin,
ConsumeTaskPlugin,
):
logging_name = "paperless.consumer"
def __init__( def __init__(
self, self,
input_doc: ConsumableDocument, input_doc: ConsumableDocument,
@ -155,88 +147,16 @@ class ConsumerPlugin(
self.log.error(log_message or message, exc_info=exc_info) self.log.error(log_message or message, exc_info=exc_info)
raise ConsumerError(f"{self.filename}: {log_message or message}") from exception raise ConsumerError(f"{self.filename}: {log_message or message}") from exception
def pre_check_file_exists(self):
"""
Confirm the input file still exists where it should
"""
if TYPE_CHECKING:
assert isinstance(self.input_doc.original_file, Path), (
self.input_doc.original_file
)
if not self.input_doc.original_file.is_file():
self._fail(
ConsumerStatusShortMessage.FILE_NOT_FOUND,
f"Cannot consume {self.input_doc.original_file}: File not found.",
)
def pre_check_duplicate(self): class ConsumerPlugin(
""" AlwaysRunPluginMixin,
Using the MD5 of the file, check this exact file doesn't already exist NoSetupPluginMixin,
""" NoCleanupPluginMixin,
with Path(self.input_doc.original_file).open("rb") as f: LoggingMixin,
checksum = hashlib.md5(f.read()).hexdigest() ConsumerPluginMixin,
existing_doc = Document.global_objects.filter( ConsumeTaskPlugin,
Q(checksum=checksum) | Q(archive_checksum=checksum), ):
) logging_name = "paperless.consumer"
if existing_doc.exists():
msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS
log_msg = f"Not consuming {self.filename}: It is a duplicate of {existing_doc.get().title} (#{existing_doc.get().pk})."
if existing_doc.first().deleted_at is not None:
msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS_IN_TRASH
log_msg += " Note: existing document is in the trash."
if settings.CONSUMER_DELETE_DUPLICATES:
Path(self.input_doc.original_file).unlink()
self._fail(
msg,
log_msg,
)
def pre_check_directories(self):
"""
Ensure all required directories exist before attempting to use them
"""
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
settings.THUMBNAIL_DIR.mkdir(parents=True, exist_ok=True)
settings.ORIGINALS_DIR.mkdir(parents=True, exist_ok=True)
settings.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
def pre_check_asn_value(self):
"""
Check that if override_asn is given, it is unique and within a valid range
"""
if self.metadata.asn is None:
# check not necessary in case no ASN gets set
return
# Validate the range is above zero and less than uint32_t max
# otherwise, Whoosh can't handle it in the index
if (
self.metadata.asn < Document.ARCHIVE_SERIAL_NUMBER_MIN
or self.metadata.asn > Document.ARCHIVE_SERIAL_NUMBER_MAX
):
self._fail(
ConsumerStatusShortMessage.ASN_RANGE,
f"Not consuming {self.filename}: "
f"Given ASN {self.metadata.asn} is out of range "
f"[{Document.ARCHIVE_SERIAL_NUMBER_MIN:,}, "
f"{Document.ARCHIVE_SERIAL_NUMBER_MAX:,}]",
)
existing_asn_doc = Document.global_objects.filter(
archive_serial_number=self.metadata.asn,
)
if existing_asn_doc.exists():
msg = ConsumerStatusShortMessage.ASN_ALREADY_EXISTS
log_msg = f"Not consuming {self.filename}: Given ASN {self.metadata.asn} already exists!"
if existing_asn_doc.first().deleted_at is not None:
msg = ConsumerStatusShortMessage.ASN_ALREADY_EXISTS_IN_TRASH
log_msg += " Note: existing document is in the trash."
self._fail(
msg,
log_msg,
)
def run_pre_consume_script(self): def run_pre_consume_script(self):
""" """
@ -366,20 +286,7 @@ class ConsumerPlugin(
tempdir = None tempdir = None
try: try:
self._send_progress( # Preflight has already run including progress update to 0%
0,
100,
ProgressStatusOptions.STARTED,
ConsumerStatusShortMessage.NEW_FILE,
)
# Make sure that preconditions for consuming the file are met.
self.pre_check_file_exists()
self.pre_check_directories()
self.pre_check_duplicate()
self.pre_check_asn_value()
self.log.info(f"Consuming {self.filename}") self.log.info(f"Consuming {self.filename}")
# For the actual work, copy the file into a tempdir # For the actual work, copy the file into a tempdir
@ -837,3 +744,113 @@ class ConsumerPlugin(
copy_basic_file_stats(source, target) copy_basic_file_stats(source, target)
except Exception: # pragma: no cover except Exception: # pragma: no cover
pass pass
class ConsumerPreflightPlugin(
NoCleanupPluginMixin,
NoSetupPluginMixin,
AlwaysRunPluginMixin,
LoggingMixin,
ConsumerPluginMixin,
ConsumeTaskPlugin,
):
NAME: str = "ConsumerPreflightPlugin"
logging_name = "paperless.consumer"
def pre_check_file_exists(self):
"""
Confirm the input file still exists where it should
"""
if TYPE_CHECKING:
assert isinstance(self.input_doc.original_file, Path), (
self.input_doc.original_file
)
if not self.input_doc.original_file.is_file():
self._fail(
ConsumerStatusShortMessage.FILE_NOT_FOUND,
f"Cannot consume {self.input_doc.original_file}: File not found.",
)
def pre_check_duplicate(self):
"""
Using the MD5 of the file, check this exact file doesn't already exist
"""
with Path(self.input_doc.original_file).open("rb") as f:
checksum = hashlib.md5(f.read()).hexdigest()
existing_doc = Document.global_objects.filter(
Q(checksum=checksum) | Q(archive_checksum=checksum),
)
if existing_doc.exists():
msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS
log_msg = f"Not consuming {self.filename}: It is a duplicate of {existing_doc.get().title} (#{existing_doc.get().pk})."
if existing_doc.first().deleted_at is not None:
msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS_IN_TRASH
log_msg += " Note: existing document is in the trash."
if settings.CONSUMER_DELETE_DUPLICATES:
Path(self.input_doc.original_file).unlink()
self._fail(
msg,
log_msg,
)
def pre_check_directories(self):
"""
Ensure all required directories exist before attempting to use them
"""
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
settings.THUMBNAIL_DIR.mkdir(parents=True, exist_ok=True)
settings.ORIGINALS_DIR.mkdir(parents=True, exist_ok=True)
settings.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
def pre_check_asn_value(self):
"""
Check that if override_asn is given, it is unique and within a valid range
"""
if self.metadata.asn is None:
# check not necessary in case no ASN gets set
return
# Validate the range is above zero and less than uint32_t max
# otherwise, Whoosh can't handle it in the index
if (
self.metadata.asn < Document.ARCHIVE_SERIAL_NUMBER_MIN
or self.metadata.asn > Document.ARCHIVE_SERIAL_NUMBER_MAX
):
self._fail(
ConsumerStatusShortMessage.ASN_RANGE,
f"Not consuming {self.filename}: "
f"Given ASN {self.metadata.asn} is out of range "
f"[{Document.ARCHIVE_SERIAL_NUMBER_MIN:,}, "
f"{Document.ARCHIVE_SERIAL_NUMBER_MAX:,}]",
)
existing_asn_doc = Document.global_objects.filter(
archive_serial_number=self.metadata.asn,
)
if existing_asn_doc.exists():
msg = ConsumerStatusShortMessage.ASN_ALREADY_EXISTS
log_msg = f"Not consuming {self.filename}: Given ASN {self.metadata.asn} already exists!"
if existing_asn_doc.first().deleted_at is not None:
msg = ConsumerStatusShortMessage.ASN_ALREADY_EXISTS_IN_TRASH
log_msg += " Note: existing document is in the trash."
self._fail(
msg,
log_msg,
)
def run(self) -> None:
self._send_progress(
0,
100,
ProgressStatusOptions.STARTED,
ConsumerStatusShortMessage.NEW_FILE,
)
# Make sure that preconditions for consuming the file are met.
self.pre_check_file_exists()
self.pre_check_duplicate()
self.pre_check_directories()
self.pre_check_asn_value()

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import logging import logging
import re import re
from fnmatch import fnmatch from fnmatch import fnmatch
from fnmatch import translate as fnmatch_translate
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
@ -18,6 +19,8 @@ from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware from documents.permissions import get_objects_for_user_owner_aware
if TYPE_CHECKING: if TYPE_CHECKING:
from django.db.models import QuerySet
from documents.classifier import DocumentClassifier from documents.classifier import DocumentClassifier
logger = logging.getLogger("paperless.matching") logger = logging.getLogger("paperless.matching")
@ -389,6 +392,40 @@ def existing_document_matches_workflow(
return (trigger_matched, reason) return (trigger_matched, reason)
def prefilter_documents_by_workflowtrigger(
documents: QuerySet[Document],
trigger: WorkflowTrigger,
) -> QuerySet[Document]:
"""
To prevent scheduled workflows checking every document, we prefilter the
documents by the workflow trigger filters. This is done before e.g.
document_matches_workflow in run_workflows
"""
if trigger.filter_has_tags.all().count() > 0:
documents = documents.filter(
tags__in=trigger.filter_has_tags.all(),
).distinct()
if trigger.filter_has_correspondent is not None:
documents = documents.filter(
correspondent=trigger.filter_has_correspondent,
)
if trigger.filter_has_document_type is not None:
documents = documents.filter(
document_type=trigger.filter_has_document_type,
)
if trigger.filter_filename is not None and len(trigger.filter_filename) > 0:
# the true fnmatch will actually run later so we just want a loose filter here
regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$")
regex = f"(?i){regex}"
documents = documents.filter(original_filename__regex=regex)
return documents
def document_matches_workflow( def document_matches_workflow(
document: ConsumableDocument | Document, document: ConsumableDocument | Document,
workflow: Workflow, workflow: Workflow,

View File

@ -26,12 +26,14 @@ from documents.caching import clear_document_caches
from documents.classifier import DocumentClassifier from documents.classifier import DocumentClassifier
from documents.classifier import load_classifier from documents.classifier import load_classifier
from documents.consumer import ConsumerPlugin from documents.consumer import ConsumerPlugin
from documents.consumer import ConsumerPreflightPlugin
from documents.consumer import WorkflowTriggerPlugin from documents.consumer import WorkflowTriggerPlugin
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentMetadataOverrides
from documents.double_sided import CollatePlugin from documents.double_sided import CollatePlugin
from documents.file_handling import create_source_path_directory from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_unique_filename from documents.file_handling import generate_unique_filename
from documents.matching import prefilter_documents_by_workflowtrigger
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
@ -144,6 +146,7 @@ def consume_file(
overrides = DocumentMetadataOverrides() overrides = DocumentMetadataOverrides()
plugins: list[type[ConsumeTaskPlugin]] = [ plugins: list[type[ConsumeTaskPlugin]] = [
ConsumerPreflightPlugin,
CollatePlugin, CollatePlugin,
BarcodePlugin, BarcodePlugin,
WorkflowTriggerPlugin, WorkflowTriggerPlugin,
@ -471,6 +474,12 @@ def check_scheduled_workflows():
documents = Document.objects.filter(id__in=matched_ids) documents = Document.objects.filter(id__in=matched_ids)
if documents.count() > 0:
documents = prefilter_documents_by_workflowtrigger(
documents,
trigger,
)
if documents.count() > 0: if documents.count() > 0:
logger.debug( logger.debug(
f"Found {documents.count()} documents for trigger {trigger}", f"Found {documents.count()} documents for trigger {trigger}",

View File

@ -1,5 +1,4 @@
import datetime import datetime
import os
import shutil import shutil
import stat import stat
import tempfile import tempfile
@ -66,7 +65,7 @@ class CopyParser(_BaseTestParser):
def parse(self, document_path, mime_type, file_name=None): def parse(self, document_path, mime_type, file_name=None):
self.text = "The text" self.text = "The text"
self.archive_path = os.path.join(self.tempdir, "archive.pdf") self.archive_path = Path(self.tempdir / "archive.pdf")
shutil.copy(document_path, self.archive_path) shutil.copy(document_path, self.archive_path)
@ -96,15 +95,16 @@ class FaultyGenericExceptionParser(_BaseTestParser):
def fake_magic_from_file(file, *, mime=False): def fake_magic_from_file(file, *, mime=False):
if mime: if mime:
if file.name.startswith("invalid_pdf"): filepath = Path(file)
if filepath.name.startswith("invalid_pdf"):
return "application/octet-stream" return "application/octet-stream"
if os.path.splitext(file)[1] == ".pdf": if filepath.suffix == ".pdf":
return "application/pdf" return "application/pdf"
elif os.path.splitext(file)[1] == ".png": elif filepath.suffix == ".png":
return "image/png" return "image/png"
elif os.path.splitext(file)[1] == ".webp": elif filepath.suffix == ".webp":
return "image/webp" return "image/webp"
elif os.path.splitext(file)[1] == ".eml": elif filepath.suffix == ".eml":
return "message/rfc822" return "message/rfc822"
else: else:
return "unknown" return "unknown"
@ -225,7 +225,7 @@ class TestConsumer(
self.assertEqual(document.content, "The Text") self.assertEqual(document.content, "The Text")
self.assertEqual( self.assertEqual(
document.title, document.title,
os.path.splitext(os.path.basename(filename))[0], Path(filename).stem,
) )
self.assertIsNone(document.correspondent) self.assertIsNone(document.correspondent)
self.assertIsNone(document.document_type) self.assertIsNone(document.document_type)
@ -254,7 +254,7 @@ class TestConsumer(
# https://github.com/jonaswinkler/paperless-ng/discussions/1037 # https://github.com/jonaswinkler/paperless-ng/discussions/1037
filename = self.get_test_file() filename = self.get_test_file()
shadow_file = os.path.join(self.dirs.scratch_dir, "._sample.pdf") shadow_file = Path(self.dirs.scratch_dir / "._sample.pdf")
shutil.copy(filename, shadow_file) shutil.copy(filename, shadow_file)
@ -484,8 +484,8 @@ class TestConsumer(
self._assert_first_last_send_progress() self._assert_first_last_send_progress()
def testNotAFile(self): def testNotAFile(self):
with self.get_consumer(Path("non-existing-file")) as consumer:
with self.assertRaisesMessage(ConsumerError, "File not found"): with self.assertRaisesMessage(ConsumerError, "File not found"):
with self.get_consumer(Path("non-existing-file")) as consumer:
consumer.run() consumer.run()
self._assert_first_last_send_progress(last_status="FAILED") self._assert_first_last_send_progress(last_status="FAILED")
@ -493,8 +493,8 @@ class TestConsumer(
with self.get_consumer(self.get_test_file()) as consumer: with self.get_consumer(self.get_test_file()) as consumer:
consumer.run() consumer.run()
with self.get_consumer(self.get_test_file()) as consumer:
with self.assertRaisesMessage(ConsumerError, "It is a duplicate"): with self.assertRaisesMessage(ConsumerError, "It is a duplicate"):
with self.get_consumer(self.get_test_file()) as consumer:
consumer.run() consumer.run()
self._assert_first_last_send_progress(last_status="FAILED") self._assert_first_last_send_progress(last_status="FAILED")
@ -503,8 +503,8 @@ class TestConsumer(
with self.get_consumer(self.get_test_file()) as consumer: with self.get_consumer(self.get_test_file()) as consumer:
consumer.run() consumer.run()
with self.get_consumer(self.get_test_archive_file()) as consumer:
with self.assertRaisesMessage(ConsumerError, "It is a duplicate"): with self.assertRaisesMessage(ConsumerError, "It is a duplicate"):
with self.get_consumer(self.get_test_archive_file()) as consumer:
consumer.run() consumer.run()
self._assert_first_last_send_progress(last_status="FAILED") self._assert_first_last_send_progress(last_status="FAILED")
@ -521,8 +521,8 @@ class TestConsumer(
Document.objects.all().delete() Document.objects.all().delete()
with self.get_consumer(self.get_test_file()) as consumer:
with self.assertRaisesMessage(ConsumerError, "document is in the trash"): with self.assertRaisesMessage(ConsumerError, "document is in the trash"):
with self.get_consumer(self.get_test_file()) as consumer:
consumer.run() consumer.run()
def testAsnExists(self): def testAsnExists(self):
@ -532,11 +532,11 @@ class TestConsumer(
) as consumer: ) as consumer:
consumer.run() consumer.run()
with self.assertRaisesMessage(ConsumerError, "ASN 123 already exists"):
with self.get_consumer( with self.get_consumer(
self.get_test_file2(), self.get_test_file2(),
DocumentMetadataOverrides(asn=123), DocumentMetadataOverrides(asn=123),
) as consumer: ) as consumer:
with self.assertRaisesMessage(ConsumerError, "ASN 123 already exists"):
consumer.run() consumer.run()
def testAsnExistsInTrash(self): def testAsnExistsInTrash(self):
@ -549,22 +549,22 @@ class TestConsumer(
document = Document.objects.first() document = Document.objects.first()
document.delete() document.delete()
with self.assertRaisesMessage(ConsumerError, "document is in the trash"):
with self.get_consumer( with self.get_consumer(
self.get_test_file2(), self.get_test_file2(),
DocumentMetadataOverrides(asn=123), DocumentMetadataOverrides(asn=123),
) as consumer: ) as consumer:
with self.assertRaisesMessage(ConsumerError, "document is in the trash"):
consumer.run() consumer.run()
@mock.patch("documents.parsers.document_consumer_declaration.send") @mock.patch("documents.parsers.document_consumer_declaration.send")
def testNoParsers(self, m): def testNoParsers(self, m):
m.return_value = [] m.return_value = []
with self.get_consumer(self.get_test_file()) as consumer:
with self.assertRaisesMessage( with self.assertRaisesMessage(
ConsumerError, ConsumerError,
"sample.pdf: Unsupported mime type application/pdf", "sample.pdf: Unsupported mime type application/pdf",
): ):
with self.get_consumer(self.get_test_file()) as consumer:
consumer.run() consumer.run()
self._assert_first_last_send_progress(last_status="FAILED") self._assert_first_last_send_progress(last_status="FAILED")
@ -726,8 +726,8 @@ class TestConsumer(
dst = self.get_test_file() dst = self.get_test_file()
self.assertIsFile(dst) self.assertIsFile(dst)
with self.get_consumer(dst) as consumer:
with self.assertRaises(ConsumerError): with self.assertRaises(ConsumerError):
with self.get_consumer(dst) as consumer:
consumer.run() consumer.run()
self.assertIsNotFile(dst) self.assertIsNotFile(dst)
@ -751,11 +751,11 @@ class TestConsumer(
dst = self.get_test_file() dst = self.get_test_file()
self.assertIsFile(dst) self.assertIsFile(dst)
with self.get_consumer(dst) as consumer:
with self.assertRaisesRegex( with self.assertRaisesRegex(
ConsumerError, ConsumerError,
r"sample\.pdf: Not consuming sample\.pdf: It is a duplicate of sample \(#\d+\)", r"sample\.pdf: Not consuming sample\.pdf: It is a duplicate of sample \(#\d+\)",
): ):
with self.get_consumer(dst) as consumer:
consumer.run() consumer.run()
self.assertIsFile(dst) self.assertIsFile(dst)
@ -1082,8 +1082,8 @@ class PreConsumeTestCase(DirectoriesMixin, GetConsumerMixin, TestCase):
outfile.write("echo This message goes to stderr >&2") outfile.write("echo This message goes to stderr >&2")
# Make the file executable # Make the file executable
st = os.stat(script.name) st = Path(script.name).stat()
os.chmod(script.name, st.st_mode | stat.S_IEXEC) Path(script.name).chmod(st.st_mode | stat.S_IEXEC)
with override_settings(PRE_CONSUME_SCRIPT=script.name): with override_settings(PRE_CONSUME_SCRIPT=script.name):
with self.assertLogs("paperless.consumer", level="INFO") as cm: with self.assertLogs("paperless.consumer", level="INFO") as cm:
@ -1114,8 +1114,8 @@ class PreConsumeTestCase(DirectoriesMixin, GetConsumerMixin, TestCase):
outfile.write("exit 100\n") outfile.write("exit 100\n")
# Make the file executable # Make the file executable
st = os.stat(script.name) st = Path(script.name).stat()
os.chmod(script.name, st.st_mode | stat.S_IEXEC) Path(script.name).chmod(st.st_mode | stat.S_IEXEC)
with override_settings(PRE_CONSUME_SCRIPT=script.name): with override_settings(PRE_CONSUME_SCRIPT=script.name):
with self.get_consumer(self.test_file) as c: with self.get_consumer(self.test_file) as c:
@ -1237,8 +1237,8 @@ class PostConsumeTestCase(DirectoriesMixin, GetConsumerMixin, TestCase):
outfile.write("exit -500\n") outfile.write("exit -500\n")
# Make the file executable # Make the file executable
st = os.stat(script.name) st = Path(script.name).stat()
os.chmod(script.name, st.st_mode | stat.S_IEXEC) Path(script.name).chmod(st.st_mode | stat.S_IEXEC)
with override_settings(POST_CONSUME_SCRIPT=script.name): with override_settings(POST_CONSUME_SCRIPT=script.name):
doc = Document.objects.create(title="Test", mime_type="application/pdf") doc = Document.objects.create(title="Test", mime_type="application/pdf")

View File

@ -1,6 +1,5 @@
import datetime import datetime
import logging import logging
import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
@ -71,7 +70,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
# test that creating dirs for the source_path creates the correct directory # test that creating dirs for the source_path creates the correct directory
create_source_path_directory(document.source_path) create_source_path_directory(document.source_path)
Path(document.source_path).touch() Path(document.source_path).touch()
self.assertIsDir(os.path.join(settings.ORIGINALS_DIR, "none")) self.assertIsDir(settings.ORIGINALS_DIR / "none")
# Set a correspondent and save the document # Set a correspondent and save the document
document.correspondent = Correspondent.objects.get_or_create(name="test")[0] document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
@ -108,7 +107,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
) )
# Make the folder read- and execute-only (no writing and no renaming) # Make the folder read- and execute-only (no writing and no renaming)
os.chmod(os.path.join(settings.ORIGINALS_DIR, "none"), 0o555) (settings.ORIGINALS_DIR / "none").chmod(0o555)
# Set a correspondent and save the document # Set a correspondent and save the document
document.correspondent = Correspondent.objects.get_or_create(name="test")[0] document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
@ -120,7 +119,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
) )
self.assertEqual(document.filename, "none/none.pdf") self.assertEqual(document.filename, "none/none.pdf")
os.chmod(os.path.join(settings.ORIGINALS_DIR, "none"), 0o777) (settings.ORIGINALS_DIR / "none").chmod(0o777)
@override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}")
def test_file_renaming_database_error(self): def test_file_renaming_database_error(self):
@ -160,7 +159,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
# Check proper handling of files # Check proper handling of files
self.assertIsFile(document.source_path) self.assertIsFile(document.source_path)
self.assertIsFile( self.assertIsFile(
os.path.join(settings.ORIGINALS_DIR, "none/none.pdf"), settings.ORIGINALS_DIR / "none" / "none.pdf",
) )
self.assertEqual(document.filename, "none/none.pdf") self.assertEqual(document.filename, "none/none.pdf")
@ -183,9 +182,9 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
document.delete() document.delete()
empty_trash([document.pk]) empty_trash([document.pk])
self.assertIsNotFile( self.assertIsNotFile(
os.path.join(settings.ORIGINALS_DIR, "none", "none.pdf"), settings.ORIGINALS_DIR / "none" / "none.pdf",
) )
self.assertIsNotDir(os.path.join(settings.ORIGINALS_DIR, "none")) self.assertIsNotDir(settings.ORIGINALS_DIR / "none")
@override_settings( @override_settings(
FILENAME_FORMAT="{correspondent}/{correspondent}", FILENAME_FORMAT="{correspondent}/{correspondent}",
@ -206,15 +205,15 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
Path(document.source_path).touch() Path(document.source_path).touch()
# Ensure file was moved to trash after delete # Ensure file was moved to trash after delete
self.assertIsNotFile(os.path.join(settings.EMPTY_TRASH_DIR, "none", "none.pdf")) self.assertIsNotFile(Path(settings.EMPTY_TRASH_DIR) / "none" / "none.pdf")
document.delete() document.delete()
empty_trash([document.pk]) empty_trash([document.pk])
self.assertIsNotFile( self.assertIsNotFile(
os.path.join(settings.ORIGINALS_DIR, "none", "none.pdf"), settings.ORIGINALS_DIR / "none" / "none.pdf",
) )
self.assertIsNotDir(os.path.join(settings.ORIGINALS_DIR, "none")) self.assertIsNotDir(settings.ORIGINALS_DIR / "none")
self.assertIsFile(os.path.join(settings.EMPTY_TRASH_DIR, "none.pdf")) self.assertIsFile(Path(settings.EMPTY_TRASH_DIR) / "none.pdf")
self.assertIsNotFile(os.path.join(settings.EMPTY_TRASH_DIR, "none_01.pdf")) self.assertIsNotFile(Path(settings.EMPTY_TRASH_DIR) / "none_01.pdf")
# Create an identical document and ensure it is trashed under a new name # Create an identical document and ensure it is trashed under a new name
document = Document() document = Document()
@ -227,7 +226,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
Path(document.source_path).touch() Path(document.source_path).touch()
document.delete() document.delete()
empty_trash([document.pk]) empty_trash([document.pk])
self.assertIsFile(os.path.join(settings.EMPTY_TRASH_DIR, "none_01.pdf")) self.assertIsFile(Path(settings.EMPTY_TRASH_DIR) / "none_01.pdf")
@override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}")
def test_document_delete_nofile(self): def test_document_delete_nofile(self):
@ -261,8 +260,8 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
document.save() document.save()
# Check proper handling of files # Check proper handling of files
self.assertIsDir(os.path.join(settings.ORIGINALS_DIR, "test")) self.assertIsDir(settings.ORIGINALS_DIR / "test")
self.assertIsDir(os.path.join(settings.ORIGINALS_DIR, "none")) self.assertIsDir(settings.ORIGINALS_DIR / "none")
self.assertIsFile(important_file) self.assertIsFile(important_file)
@override_settings(FILENAME_FORMAT="{document_type} - {title}") @override_settings(FILENAME_FORMAT="{document_type} - {title}")
@ -371,16 +370,16 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
Path(document.source_path).touch() Path(document.source_path).touch()
# Check proper handling of files # Check proper handling of files
self.assertIsDir(os.path.join(settings.ORIGINALS_DIR, "none/none")) self.assertIsDir(settings.ORIGINALS_DIR / "none" / "none")
document.delete() document.delete()
empty_trash([document.pk]) empty_trash([document.pk])
self.assertIsNotFile( self.assertIsNotFile(
os.path.join(settings.ORIGINALS_DIR, "none/none/none.pdf"), settings.ORIGINALS_DIR / "none" / "none" / "none.pdf",
) )
self.assertIsNotDir(os.path.join(settings.ORIGINALS_DIR, "none/none")) self.assertIsNotDir(settings.ORIGINALS_DIR / "none" / "none")
self.assertIsNotDir(os.path.join(settings.ORIGINALS_DIR, "none")) self.assertIsNotDir(settings.ORIGINALS_DIR / "none")
self.assertIsDir(settings.ORIGINALS_DIR) self.assertIsDir(settings.ORIGINALS_DIR)
@override_settings(FILENAME_FORMAT="{doc_pk}") @override_settings(FILENAME_FORMAT="{doc_pk}")
@ -415,12 +414,12 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
(tmp / "notempty" / "empty").mkdir(exist_ok=True, parents=True) (tmp / "notempty" / "empty").mkdir(exist_ok=True, parents=True)
delete_empty_directories( delete_empty_directories(
os.path.join(tmp, "notempty", "empty"), tmp / "notempty" / "empty",
root=settings.ORIGINALS_DIR, root=settings.ORIGINALS_DIR,
) )
self.assertIsDir(os.path.join(tmp, "notempty")) self.assertIsDir(tmp / "notempty")
self.assertIsFile(os.path.join(tmp, "notempty", "file")) self.assertIsFile(tmp / "notempty" / "file")
self.assertIsNotDir(os.path.join(tmp, "notempty", "empty")) self.assertIsNotDir(tmp / "notempty" / "empty")
@override_settings(FILENAME_FORMAT="{% if x is None %}/{title]") @override_settings(FILENAME_FORMAT="{% if x is None %}/{title]")
def test_invalid_format(self): def test_invalid_format(self):
@ -585,8 +584,8 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, TestCase): class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
@override_settings(FILENAME_FORMAT=None) @override_settings(FILENAME_FORMAT=None)
def test_create_no_format(self): def test_create_no_format(self):
original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") original = settings.ORIGINALS_DIR / "0000001.pdf"
archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") archive = settings.ARCHIVE_DIR / "0000001.pdf"
Path(original).touch() Path(original).touch()
Path(archive).touch() Path(archive).touch()
doc = Document.objects.create( doc = Document.objects.create(
@ -604,8 +603,8 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
@override_settings(FILENAME_FORMAT="{correspondent}/{title}") @override_settings(FILENAME_FORMAT="{correspondent}/{title}")
def test_create_with_format(self): def test_create_with_format(self):
original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") original = settings.ORIGINALS_DIR / "0000001.pdf"
archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") archive = settings.ARCHIVE_DIR / "0000001.pdf"
Path(original).touch() Path(original).touch()
Path(archive).touch() Path(archive).touch()
doc = Document.objects.create( doc = Document.objects.create(
@ -632,8 +631,8 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
@override_settings(FILENAME_FORMAT="{correspondent}/{title}") @override_settings(FILENAME_FORMAT="{correspondent}/{title}")
def test_move_archive_gone(self): def test_move_archive_gone(self):
original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") original = settings.ORIGINALS_DIR / "0000001.pdf"
archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") archive = settings.ARCHIVE_DIR / "0000001.pdf"
Path(original).touch() Path(original).touch()
doc = Document.objects.create( doc = Document.objects.create(
mime_type="application/pdf", mime_type="application/pdf",
@ -651,9 +650,9 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
@override_settings(FILENAME_FORMAT="{correspondent}/{title}") @override_settings(FILENAME_FORMAT="{correspondent}/{title}")
def test_move_archive_exists(self): def test_move_archive_exists(self):
original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") original = settings.ORIGINALS_DIR / "0000001.pdf"
archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") archive = settings.ARCHIVE_DIR / "0000001.pdf"
existing_archive_file = os.path.join(settings.ARCHIVE_DIR, "none", "my_doc.pdf") existing_archive_file = settings.ARCHIVE_DIR / "none" / "my_doc.pdf"
Path(original).touch() Path(original).touch()
Path(archive).touch() Path(archive).touch()
(settings.ARCHIVE_DIR / "none").mkdir(parents=True, exist_ok=True) (settings.ARCHIVE_DIR / "none").mkdir(parents=True, exist_ok=True)
@ -676,8 +675,8 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
@override_settings(FILENAME_FORMAT="{title}") @override_settings(FILENAME_FORMAT="{title}")
def test_move_original_only(self): def test_move_original_only(self):
original = os.path.join(settings.ORIGINALS_DIR, "document_01.pdf") original = settings.ORIGINALS_DIR / "document_01.pdf"
archive = os.path.join(settings.ARCHIVE_DIR, "document.pdf") archive = settings.ARCHIVE_DIR / "document.pdf"
Path(original).touch() Path(original).touch()
Path(archive).touch() Path(archive).touch()
@ -698,8 +697,8 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
@override_settings(FILENAME_FORMAT="{title}") @override_settings(FILENAME_FORMAT="{title}")
def test_move_archive_only(self): def test_move_archive_only(self):
original = os.path.join(settings.ORIGINALS_DIR, "document.pdf") original = settings.ORIGINALS_DIR / "document.pdf"
archive = os.path.join(settings.ARCHIVE_DIR, "document_01.pdf") archive = settings.ARCHIVE_DIR / "document_01.pdf"
Path(original).touch() Path(original).touch()
Path(archive).touch() Path(archive).touch()
@ -725,13 +724,13 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
if "archive" in str(src): if "archive" in str(src):
raise OSError raise OSError
else: else:
os.remove(src) Path(src).unlink()
Path(dst).touch() Path(dst).touch()
m.side_effect = fake_rename m.side_effect = fake_rename
original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") original = settings.ORIGINALS_DIR / "0000001.pdf"
archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") archive = settings.ARCHIVE_DIR / "0000001.pdf"
Path(original).touch() Path(original).touch()
Path(archive).touch() Path(archive).touch()
doc = Document.objects.create( doc = Document.objects.create(
@ -751,8 +750,8 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
@override_settings(FILENAME_FORMAT="{correspondent}/{title}") @override_settings(FILENAME_FORMAT="{correspondent}/{title}")
def test_move_file_gone(self): def test_move_file_gone(self):
original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") original = settings.ORIGINALS_DIR / "0000001.pdf"
archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") archive = settings.ARCHIVE_DIR / "0000001.pdf"
# Path(original).touch() # Path(original).touch()
Path(archive).touch() Path(archive).touch()
doc = Document.objects.create( doc = Document.objects.create(
@ -776,13 +775,13 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
if "original" in str(src): if "original" in str(src):
raise OSError raise OSError
else: else:
os.remove(src) Path(src).unlink()
Path(dst).touch() Path(dst).touch()
m.side_effect = fake_rename m.side_effect = fake_rename
original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") original = settings.ORIGINALS_DIR / "0000001.pdf"
archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") archive = settings.ARCHIVE_DIR / "0000001.pdf"
Path(original).touch() Path(original).touch()
Path(archive).touch() Path(archive).touch()
doc = Document.objects.create( doc = Document.objects.create(
@ -802,8 +801,8 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
@override_settings(FILENAME_FORMAT="") @override_settings(FILENAME_FORMAT="")
def test_archive_deleted(self): def test_archive_deleted(self):
original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") original = settings.ORIGINALS_DIR / "0000001.pdf"
archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") archive = settings.ARCHIVE_DIR / "0000001.pdf"
Path(original).touch() Path(original).touch()
Path(archive).touch() Path(archive).touch()
doc = Document.objects.create( doc = Document.objects.create(
@ -830,9 +829,9 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
@override_settings(FILENAME_FORMAT="{title}") @override_settings(FILENAME_FORMAT="{title}")
def test_archive_deleted2(self): def test_archive_deleted2(self):
original = os.path.join(settings.ORIGINALS_DIR, "document.webp") original = settings.ORIGINALS_DIR / "document.webp"
original2 = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") original2 = settings.ORIGINALS_DIR / "0000001.pdf"
archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") archive = settings.ARCHIVE_DIR / "0000001.pdf"
Path(original).touch() Path(original).touch()
Path(original2).touch() Path(original2).touch()
Path(archive).touch() Path(archive).touch()
@ -865,8 +864,8 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
@override_settings(FILENAME_FORMAT="{correspondent}/{title}") @override_settings(FILENAME_FORMAT="{correspondent}/{title}")
def test_database_error(self): def test_database_error(self):
original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") original = settings.ORIGINALS_DIR / "0000001.pdf"
archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") archive = settings.ARCHIVE_DIR / "0000001.pdf"
Path(original).touch() Path(original).touch()
Path(archive).touch() Path(archive).touch()
doc = Document( doc = Document(

View File

@ -1,6 +1,5 @@
import hashlib import hashlib
import importlib import importlib
import os
import shutil import shutil
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
@ -21,7 +20,7 @@ migration_1012_obj = importlib.import_module(
def archive_name_from_filename(filename): def archive_name_from_filename(filename):
return os.path.splitext(filename)[0] + ".pdf" return Path(filename).stem + ".pdf"
def archive_path_old(self): def archive_path_old(self):
@ -30,12 +29,12 @@ def archive_path_old(self):
else: else:
fname = f"{self.pk:07}.pdf" fname = f"{self.pk:07}.pdf"
return os.path.join(settings.ARCHIVE_DIR, fname) return Path(settings.ARCHIVE_DIR) / fname
def archive_path_new(doc): def archive_path_new(doc):
if doc.archive_filename is not None: if doc.archive_filename is not None:
return os.path.join(settings.ARCHIVE_DIR, str(doc.archive_filename)) return Path(settings.ARCHIVE_DIR) / str(doc.archive_filename)
else: else:
return None return None
@ -48,7 +47,7 @@ def source_path(doc):
if doc.storage_type == STORAGE_TYPE_GPG: if doc.storage_type == STORAGE_TYPE_GPG:
fname += ".gpg" # pragma: no cover fname += ".gpg" # pragma: no cover
return os.path.join(settings.ORIGINALS_DIR, fname) return Path(settings.ORIGINALS_DIR) / fname
def thumbnail_path(doc): def thumbnail_path(doc):
@ -56,7 +55,7 @@ def thumbnail_path(doc):
if doc.storage_type == STORAGE_TYPE_GPG: if doc.storage_type == STORAGE_TYPE_GPG:
file_name += ".gpg" file_name += ".gpg"
return os.path.join(settings.THUMBNAIL_DIR, file_name) return Path(settings.THUMBNAIL_DIR) / file_name
def make_test_document( def make_test_document(
@ -76,7 +75,7 @@ def make_test_document(
doc.save() doc.save()
shutil.copy2(original, source_path(doc)) shutil.copy2(original, source_path(doc))
with open(original, "rb") as f: with Path(original).open("rb") as f:
doc.checksum = hashlib.md5(f.read()).hexdigest() doc.checksum = hashlib.md5(f.read()).hexdigest()
if archive: if archive:
@ -86,7 +85,7 @@ def make_test_document(
else: else:
shutil.copy2(archive, archive_path_old(doc)) shutil.copy2(archive, archive_path_old(doc))
with open(archive, "rb") as f: with Path(archive).open("rb") as f:
doc.archive_checksum = hashlib.md5(f.read()).hexdigest() doc.archive_checksum = hashlib.md5(f.read()).hexdigest()
doc.save() doc.save()
@ -96,25 +95,17 @@ def make_test_document(
return doc return doc
simple_jpg = os.path.join(os.path.dirname(__file__), "samples", "simple.jpg") simple_jpg = Path(__file__).parent / "samples" / "simple.jpg"
simple_pdf = os.path.join(os.path.dirname(__file__), "samples", "simple.pdf") simple_pdf = Path(__file__).parent / "samples" / "simple.pdf"
simple_pdf2 = os.path.join( simple_pdf2 = (
os.path.dirname(__file__), Path(__file__).parent / "samples" / "documents" / "originals" / "0000002.pdf"
"samples",
"documents",
"originals",
"0000002.pdf",
) )
simple_pdf3 = os.path.join( simple_pdf3 = (
os.path.dirname(__file__), Path(__file__).parent / "samples" / "documents" / "originals" / "0000003.pdf"
"samples",
"documents",
"originals",
"0000003.pdf",
) )
simple_txt = os.path.join(os.path.dirname(__file__), "samples", "simple.txt") simple_txt = Path(__file__).parent / "samples" / "simple.txt"
simple_png = os.path.join(os.path.dirname(__file__), "samples", "simple-noalpha.png") simple_png = Path(__file__).parent / "samples" / "simple-noalpha.png"
simple_png2 = os.path.join(os.path.dirname(__file__), "examples", "no-text.png") simple_png2 = Path(__file__).parent / "examples" / "no-text.png"
@override_settings(FILENAME_FORMAT="") @override_settings(FILENAME_FORMAT="")
@ -198,13 +189,13 @@ class TestMigrateArchiveFiles(DirectoriesMixin, FileSystemAssertsMixin, TestMigr
else: else:
self.assertIsNone(doc.archive_filename) self.assertIsNone(doc.archive_filename)
with open(source_path(doc), "rb") as f: with Path(source_path(doc)).open("rb") as f:
original_checksum = hashlib.md5(f.read()).hexdigest() original_checksum = hashlib.md5(f.read()).hexdigest()
self.assertEqual(original_checksum, doc.checksum) self.assertEqual(original_checksum, doc.checksum)
if doc.archive_checksum: if doc.archive_checksum:
self.assertIsFile(archive_path_new(doc)) self.assertIsFile(archive_path_new(doc))
with open(archive_path_new(doc), "rb") as f: with archive_path_new(doc).open("rb") as f:
archive_checksum = hashlib.md5(f.read()).hexdigest() archive_checksum = hashlib.md5(f.read()).hexdigest()
self.assertEqual(archive_checksum, doc.archive_checksum) self.assertEqual(archive_checksum, doc.archive_checksum)
@ -301,7 +292,7 @@ class TestMigrateArchiveFilesErrors(DirectoriesMixin, TestMigrations):
"clash.pdf", "clash.pdf",
simple_pdf, simple_pdf,
) )
os.unlink(archive_path_old(doc)) archive_path_old(doc).unlink()
self.assertRaisesMessage( self.assertRaisesMessage(
ValueError, ValueError,
@ -494,13 +485,13 @@ class TestMigrateArchiveFilesBackwards(
for doc in Document.objects.all(): for doc in Document.objects.all():
if doc.archive_checksum: if doc.archive_checksum:
self.assertIsFile(archive_path_old(doc)) self.assertIsFile(archive_path_old(doc))
with open(source_path(doc), "rb") as f: with Path(source_path(doc)).open("rb") as f:
original_checksum = hashlib.md5(f.read()).hexdigest() original_checksum = hashlib.md5(f.read()).hexdigest()
self.assertEqual(original_checksum, doc.checksum) self.assertEqual(original_checksum, doc.checksum)
if doc.archive_checksum: if doc.archive_checksum:
self.assertIsFile(archive_path_old(doc)) self.assertIsFile(archive_path_old(doc))
with open(archive_path_old(doc), "rb") as f: with archive_path_old(doc).open("rb") as f:
archive_checksum = hashlib.md5(f.read()).hexdigest() archive_checksum = hashlib.md5(f.read()).hexdigest()
self.assertEqual(archive_checksum, doc.archive_checksum) self.assertEqual(archive_checksum, doc.archive_checksum)

View File

@ -25,6 +25,7 @@ from documents import tasks
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.matching import document_matches_workflow from documents.matching import document_matches_workflow
from documents.matching import prefilter_documents_by_workflowtrigger
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
@ -1711,6 +1712,55 @@ class TestWorkflows(
doc2.refresh_from_db() doc2.refresh_from_db()
self.assertIsNone(doc2.owner) # has not triggered yet self.assertIsNone(doc2.owner) # has not triggered yet
def test_workflow_scheduled_filters_queryset(self):
"""
GIVEN:
- Existing workflow with scheduled trigger
WHEN:
- Workflows run and matching documents are found
THEN:
- prefilter_documents_by_workflowtrigger appropriately filters
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
schedule_offset_days=-7,
schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
filter_filename="*sample*",
filter_has_document_type=self.dt,
filter_has_correspondent=self.c,
)
trigger.filter_has_tags.set([self.t1])
trigger.save()
action = WorkflowAction.objects.create(
assign_owner=self.user2,
)
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
# create 10 docs with half having the document type
for i in range(10):
doc = Document.objects.create(
title=f"sample test {i}",
checksum=f"checksum{i}",
correspondent=self.c,
original_filename=f"sample_{i}.pdf",
document_type=self.dt if i % 2 == 0 else None,
)
doc.tags.set([self.t1])
doc.save()
documents = Document.objects.all()
filtered_docs = prefilter_documents_by_workflowtrigger(
documents,
trigger,
)
self.assertEqual(filtered_docs.count(), 5)
def test_workflow_enabled_disabled(self): def test_workflow_enabled_disabled(self):
trigger = WorkflowTrigger.objects.create( trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,

View File

@ -21,6 +21,7 @@ from django.test import TransactionTestCase
from django.test import override_settings from django.test import override_settings
from documents.consumer import ConsumerPlugin from documents.consumer import ConsumerPlugin
from documents.consumer import ConsumerPreflightPlugin
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
@ -344,12 +345,21 @@ class GetConsumerMixin:
) -> Generator[ConsumerPlugin, None, None]: ) -> Generator[ConsumerPlugin, None, None]:
# Store this for verification # Store this for verification
self.status = DummyProgressManager(filepath.name, None) self.status = DummyProgressManager(filepath.name, None)
reader = ConsumerPlugin( doc = ConsumableDocument(
ConsumableDocument(
source, source,
original_file=filepath, original_file=filepath,
mailrule_id=mailrule_id or None, mailrule_id=mailrule_id or None,
), )
preflight_plugin = ConsumerPreflightPlugin(
doc,
overrides or DocumentMetadataOverrides(),
self.status, # type: ignore
self.dirs.scratch_dir,
"task-id",
)
preflight_plugin.setup()
reader = ConsumerPlugin(
doc,
overrides or DocumentMetadataOverrides(), overrides or DocumentMetadataOverrides(),
self.status, # type: ignore self.status, # type: ignore
self.dirs.scratch_dir, self.dirs.scratch_dir,
@ -357,6 +367,7 @@ class GetConsumerMixin:
) )
reader.setup() reader.setup()
try: try:
preflight_plugin.run()
yield reader yield reader
finally: finally:
reader.cleanup() reader.cleanup()

View File

@ -0,0 +1,25 @@
import tempfile
from pathlib import Path
from django.conf import settings
def test_favicon_view(client):
with tempfile.TemporaryDirectory() as tmpdir:
static_dir = Path(tmpdir)
favicon_path = static_dir / "paperless" / "img" / "favicon.ico"
favicon_path.parent.mkdir(parents=True, exist_ok=True)
favicon_path.write_bytes(b"FAKE ICON DATA")
settings.STATIC_ROOT = static_dir
response = client.get("/favicon.ico")
assert response.status_code == 200
assert response["Content-Type"] == "image/x-icon"
assert b"".join(response.streaming_content) == b"FAKE ICON DATA"
def test_favicon_view_missing_file(client):
settings.STATIC_ROOT = Path(tempfile.mkdtemp())
response = client.get("/favicon.ico")
assert response.status_code == 404

View File

@ -1,5 +1,5 @@
import os
from collections import OrderedDict from collections import OrderedDict
from pathlib import Path
from allauth.mfa import signals from allauth.mfa import signals
from allauth.mfa.adapter import get_adapter as get_mfa_adapter from allauth.mfa.adapter import get_adapter as get_mfa_adapter
@ -11,8 +11,9 @@ from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.models import SocialAccount from allauth.socialaccount.models import SocialAccount
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.staticfiles.storage import staticfiles_storage
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.http import HttpResponse from django.http import FileResponse
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from django.http import HttpResponseForbidden from django.http import HttpResponseForbidden
from django.http import HttpResponseNotFound from django.http import HttpResponseNotFound
@ -92,16 +93,12 @@ class StandardPagination(PageNumberPagination):
class FaviconView(View): class FaviconView(View):
def get(self, request, *args, **kwargs): # pragma: no cover def get(self, request, *args, **kwargs):
favicon = os.path.join( try:
os.path.dirname(__file__), path = Path(staticfiles_storage.path("paperless/img/favicon.ico"))
"static", return FileResponse(path.open("rb"), content_type="image/x-icon")
"paperless", except FileNotFoundError:
"img", return HttpResponseNotFound("favicon.ico not found")
"favicon.ico",
)
with open(favicon, "rb") as f:
return HttpResponse(f, content_type="image/x-icon")
class UserViewSet(ModelViewSet): class UserViewSet(ModelViewSet):

View File

@ -323,7 +323,7 @@ def error_callback(
folder=rule.folder, folder=rule.folder,
uid=message_uid, uid=message_uid,
subject=message_subject, subject=message_subject,
received=message_date, received=make_aware(message_date) if is_naive(message_date) else message_date,
status="FAILED", status="FAILED",
error=traceback.format_exc(), error=traceback.format_exc(),
) )
@ -887,7 +887,9 @@ class MailAccountHandler(LoggingMixin):
folder=rule.folder, folder=rule.folder,
uid=message.uid, uid=message.uid,
subject=message.subject, subject=message.subject,
received=message.date, received=make_aware(message.date)
if is_naive(message.date)
else message.date,
status="PROCESSED_WO_CONSUMPTION", status="PROCESSED_WO_CONSUMPTION",
) )

3202
uv.lock generated

File diff suppressed because it is too large Load Diff