Compare commits

..

19 Commits

Author SHA1 Message Date
dependabot[bot]
3e33700699 Chore(deps): Update granian[uvloop] requirement from ~=2.4.1 to ~=2.5.0
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.4.1...v2.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-06 17:53:26 +00:00
Sebastian Steinbeißer
6dca4daea5 Chore: switch from os.path to pathlib.Path (#10397) 2025-08-06 10:50:42 -07:00
dependabot[bot]
54e2b916e6 Chore(deps): Bump the small-changes group with 3 updates (#10528)
Bumps the small-changes group with 3 updates: [channels](https://github.com/django/channels), [python-gnupg](https://github.com/vsajip/python-gnupg) and [tika-client](https://github.com/stumpylog/tika-rest-client).


Updates `channels` from 4.3.0 to 4.3.1
- [Changelog](https://github.com/django/channels/blob/main/CHANGELOG.txt)
- [Commits](https://github.com/django/channels/compare/4.3.0...4.3.1)

Updates `python-gnupg` from 0.5.4 to 0.5.5
- [Release notes](https://github.com/vsajip/python-gnupg/releases)
- [Changelog](https://github.com/vsajip/python-gnupg/blob/master/release)
- [Commits](https://github.com/vsajip/python-gnupg/compare/0.5.4...0.5.5)

Updates `tika-client` from 0.9.0 to 0.10.0
- [Release notes](https://github.com/stumpylog/tika-rest-client/releases)
- [Changelog](https://github.com/stumpylog/tika-client/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stumpylog/tika-rest-client/compare/0.9.0...0.10.0)

---
updated-dependencies:
- dependency-name: channels
  dependency-version: 4.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: small-changes
- dependency-name: python-gnupg
  dependency-version: 0.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: small-changes
- dependency-name: tika-client
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  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-08-05 10:31:03 -07:00
GitHub Actions
ea62e30c90 Auto translate strings 2025-08-05 07:37:04 -04:00
shamoon
91511b45cd Chore: add info buttons for core metadata items 2025-08-05 07:37:04 -04:00
shamoon
b5dd751b67 Fix: address some button consistency 2025-08-04 23:46:43 -04:00
GitHub Actions
07c298523a Auto translate strings 2025-08-02 12:55:48 +00:00
Antoine Mérino
0ea159683d Performance: add setting to enable DB connection pooling for PostgreSQL (#10354)
---------

Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2025-08-02 12:54:13 +00:00
GitHub Actions
f0b6e79d14 Auto translate strings 2025-08-02 03:45:01 +00:00
dependabot[bot]
302cb22ec6 Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 4 updates (#10497)
* Chore(deps-dev): Bump the frontend-jest-dependencies group

Bumps the frontend-jest-dependencies group in /src-ui with 4 updates: [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest), [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest), [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom) and [jest-preset-angular](https://github.com/thymikee/jest-preset-angular).


Updates `jest` from 29.7.0 to 30.0.5
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.0.5/packages/jest)

Updates `@types/jest` from 29.5.14 to 30.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest)

Updates `jest-environment-jsdom` from 29.7.0 to 30.0.5
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.0.5/packages/jest-environment-jsdom)

Updates `jest-preset-angular` from 14.5.5 to 15.0.0
- [Release notes](https://github.com/thymikee/jest-preset-angular/releases)
- [Changelog](https://github.com/thymikee/jest-preset-angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/thymikee/jest-preset-angular/compare/v14.5.5...v15.0.0)

---
updated-dependencies:
- dependency-name: jest
  dependency-version: 30.0.5
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-jest-dependencies
- dependency-name: "@types/jest"
  dependency-version: 30.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-jest-dependencies
- dependency-name: jest-environment-jsdom
  dependency-version: 30.0.5
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-jest-dependencies
- dependency-name: jest-preset-angular
  dependency-version: 15.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-jest-dependencies
...

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

* Update Jest setup for Node util imports and typings

* Refactor navigation actions to utility functions

---------

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-08-02 03:43:31 +00:00
dependabot[bot]
4210addb46 Chore(deps-dev): Bump the frontend-eslint-dependencies group (#10498)
Bumps the frontend-eslint-dependencies group in /src-ui with 4 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser), [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils) and [eslint](https://github.com/eslint/eslint).


Updates `@typescript-eslint/eslint-plugin` from 8.35.1 to 8.38.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.38.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.35.1 to 8.38.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.38.0/packages/parser)

Updates `@typescript-eslint/utils` from 8.35.1 to 8.38.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.38.0/packages/utils)

Updates `eslint` from 9.30.1 to 9.32.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.30.1...v9.32.0)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.38.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.38.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.38.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
- dependency-name: eslint
  dependency-version: 9.32.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-01 23:04:02 +00:00
dependabot[bot]
06746b4b31 Chore(deps-dev): Bump @playwright/test from 1.53.2 to 1.54.2 in /src-ui (#10499)
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.53.2 to 1.54.2.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.53.2...v1.54.2)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.54.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-01 22:52:45 +00:00
dependabot[bot]
2f5533a179 Chore(deps-dev): Bump webpack from 5.99.9 to 5.101.0 in /src-ui (#10501)
Bumps [webpack](https://github.com/webpack/webpack) from 5.99.9 to 5.101.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.99.9...v5.101.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-version: 5.101.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-01 22:41:36 +00:00
dependabot[bot]
2f267341f8 Chore(deps-dev): Bump prettier-plugin-organize-imports in /src-ui (#10500)
Bumps [prettier-plugin-organize-imports](https://github.com/simonhaenisch/prettier-plugin-organize-imports) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/simonhaenisch/prettier-plugin-organize-imports/releases)
- [Changelog](https://github.com/simonhaenisch/prettier-plugin-organize-imports/blob/master/changelog.md)
- [Commits](https://github.com/simonhaenisch/prettier-plugin-organize-imports/compare/v4.1.0...v4.2.0)

---
updated-dependencies:
- dependency-name: prettier-plugin-organize-imports
  dependency-version: 4.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-01 22:26:48 +00:00
dependabot[bot]
88befee527 Chore(deps-dev): Bump @types/node from 24.0.10 to 24.1.0 in /src-ui (#10502)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.0.10 to 24.1.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-01 22:11:45 +00:00
GitHub Actions
c4a7186cd2 Auto translate strings 2025-08-01 21:43:22 +00:00
dependabot[bot]
d974f092aa Chore(deps): Bump the frontend-angular-dependencies group (#10496)
Bumps the frontend-angular-dependencies group in /src-ui with 16 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/cdk](https://github.com/angular/components) | `20.0.4` | `20.1.4` |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `20.0.6` | `20.1.4` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `20.0.6` | `20.1.4` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `20.0.6` | `20.1.4` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `20.0.6` | `20.1.4` |
| [@angular/localize](https://github.com/angular/angular) | `20.0.6` | `20.1.4` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `20.0.6` | `20.1.4` |
| [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `20.0.6` | `20.1.4` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `20.0.6` | `20.1.4` |
| [@ng-select/ng-select](https://github.com/ng-select/ng-select) | `15.1.3` | `20.0.1` |
| [ngx-ui-tour-ng-bootstrap](https://github.com/hakimio/ngx-ui-tour) | `17.0.0` | `17.0.1` |
| [@angular-devkit/core](https://github.com/angular/angular-cli) | `20.0.4` | `20.1.4` |
| [@angular-devkit/schematics](https://github.com/angular/angular-cli) | `20.0.4` | `20.1.4` |
| [@angular/build](https://github.com/angular/angular-cli) | `20.0.4` | `20.1.4` |
| [@angular/cli](https://github.com/angular/angular-cli) | `20.0.4` | `20.1.4` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `20.0.6` | `20.1.4` |


Updates `@angular/cdk` from 20.0.4 to 20.1.4
- [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/20.0.4...20.1.4)

Updates `@angular/common` from 20.0.6 to 20.1.4
- [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/20.1.4/packages/common)

Updates `@angular/compiler` from 20.0.6 to 20.1.4
- [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/20.1.4/packages/compiler)

Updates `@angular/core` from 20.0.6 to 20.1.4
- [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/20.1.4/packages/core)

Updates `@angular/forms` from 20.0.6 to 20.1.4
- [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/20.1.4/packages/forms)

Updates `@angular/localize` from 20.0.6 to 20.1.4
- [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/20.0.6...20.1.4)

Updates `@angular/platform-browser` from 20.0.6 to 20.1.4
- [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/20.1.4/packages/platform-browser)

Updates `@angular/platform-browser-dynamic` from 20.0.6 to 20.1.4
- [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/20.1.4/packages/platform-browser-dynamic)

Updates `@angular/router` from 20.0.6 to 20.1.4
- [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/20.1.4/packages/router)

Updates `@ng-select/ng-select` from 15.1.3 to 20.0.1
- [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/v15.1.3...v20.0.1)

Updates `ngx-ui-tour-ng-bootstrap` from 17.0.0 to 17.0.1
- [Release notes](https://github.com/hakimio/ngx-ui-tour/releases)
- [Commits](https://github.com/hakimio/ngx-ui-tour/commits)

Updates `@angular-devkit/core` from 20.0.4 to 20.1.4
- [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/20.0.4...20.1.4)

Updates `@angular-devkit/schematics` from 20.0.4 to 20.1.4
- [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/20.0.4...20.1.4)

Updates `@angular/build` from 20.0.4 to 20.1.4
- [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/20.0.4...20.1.4)

Updates `@angular/cli` from 20.0.4 to 20.1.4
- [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/20.0.4...20.1.4)

Updates `@angular/compiler-cli` from 20.0.6 to 20.1.4
- [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/20.1.4/packages/compiler-cli)

---
updated-dependencies:
- dependency-name: "@angular/cdk"
  dependency-version: 20.1.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/common"
  dependency-version: 20.1.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler"
  dependency-version: 20.1.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/core"
  dependency-version: 20.1.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/forms"
  dependency-version: 20.1.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/localize"
  dependency-version: 20.1.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser"
  dependency-version: 20.1.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser-dynamic"
  dependency-version: 20.1.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/router"
  dependency-version: 20.1.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@ng-select/ng-select"
  dependency-version: 20.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-angular-dependencies
- dependency-name: ngx-ui-tour-ng-bootstrap
  dependency-version: 17.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/core"
  dependency-version: 20.1.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/schematics"
  dependency-version: 20.1.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/build"
  dependency-version: 20.1.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/cli"
  dependency-version: 20.1.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler-cli"
  dependency-version: 20.1.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-01 17:41:46 -04:00
shamoon
23501b9060 Fixhancement: improve text thumbnail generation for large files (#10483) 2025-08-01 10:26:35 -07:00
TheDodger
f09965464a Enhancement: disable auto spellcheck on filtering dropdowns (#10487) 2025-08-01 10:27:19 -04:00
35 changed files with 4345 additions and 3174 deletions

View File

@@ -159,6 +159,23 @@ Available options are `postgresql` and `mariadb`.
Defaults to unset, which uses Djangos built-in defaults.
#### [`PAPERLESS_DB_POOLSIZE=<int>`](#PAPERLESS_DB_POOLSIZE) {#PAPERLESS_DB_POOLSIZE}
: Defines the maximum number of database connections to keep in the pool.
Only applies to PostgreSQL. This setting is ignored for other database engines.
The value must be greater than or equal to 1 to be used.
Defaults to unset, which disables connection pooling.
!!! note
A small pool is typically sufficient — for example, a size of 4.
Make sure your PostgreSQL server's max_connections setting is large enough to handle:
```(Paperless workers + Celery workers) × pool size + safety margin```
For example, with 4 Paperless workers and 2 Celery workers, and a pool size of 4:
(4 + 2) × 4 + 10 = 34 connections required.
#### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED}
: Caches the database read query results into Redis. This can significantly improve application response times by caching database queries, at the cost of slightly increased memory usage.

View File

@@ -30,6 +30,9 @@ Each document has data fields that you can assign to them:
- A _document type_ is used to demarcate the type of a document such
as letter, bank statement, invoice, contract, etc. It is used to
identify what a document is about.
- The document _storage path_ is the location where the document files
are stored. See [Storage Paths](advanced_usage.md#storage-paths) for
more information.
- The _date added_ of a document is the date the document was scanned
into paperless. You cannot and should not change this date.
- The _date created_ of a document is the date the document was

View File

@@ -52,6 +52,7 @@ dependencies = [
"ocrmypdf~=16.10.0",
"pathvalidate~=3.3.1",
"pdf2image~=1.17.0",
"psycopg-pool",
"python-dateutil~=2.9.0",
"python-dotenv~=1.1.0",
"python-gnupg~=0.5.4",
@@ -62,7 +63,7 @@ dependencies = [
"redis[hiredis]~=5.2.1",
"scikit-learn~=1.7.0",
"setproctitle~=1.3.4",
"tika-client~=0.9.0",
"tika-client~=0.10.0",
"tqdm~=4.67.1",
"watchdog~=6.0",
"whitenoise~=6.9",
@@ -74,12 +75,13 @@ optional-dependencies.mariadb = [
"mysqlclient~=2.2.7",
]
optional-dependencies.postgres = [
"psycopg[c]==3.2.9",
"psycopg[c,pool]==3.2.9",
# Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.2.9",
"psycopg-pool==3.2.6",
]
optional-dependencies.webserver = [
"granian[uvloop]~=2.4.1",
"granian[uvloop]~=2.5.0",
]
[dependency-groups]
@@ -202,15 +204,9 @@ lint.per-file-ignores."docker/wait-for-redis.py" = [
"INP001",
"T201",
]
lint.per-file-ignores."src/documents/file_handling.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/management/commands/document_consumer.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/management/commands/document_exporter.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/migrations/1012_fix_archive_files.py" = [
"PTH",
] # TODO Enable & remove
@@ -220,9 +216,6 @@ lint.per-file-ignores."src/documents/models.py" = [
lint.per-file-ignores."src/documents/parsers.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/signals/handlers.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [
"RUF001",
]
@@ -239,6 +232,7 @@ testpaths = [
"src/paperless_mail/tests/",
"src/paperless_tesseract/tests/",
"src/paperless_tika/tests",
"src/paperless_text/tests/",
]
addopts = [
"--pythonwarnings=all",

File diff suppressed because it is too large Load Diff

View File

@@ -11,17 +11,17 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^20.0.4",
"@angular/common": "~20.0.6",
"@angular/compiler": "~20.0.6",
"@angular/core": "~20.0.6",
"@angular/forms": "~20.0.6",
"@angular/localize": "~20.0.6",
"@angular/platform-browser": "~20.0.6",
"@angular/platform-browser-dynamic": "~20.0.6",
"@angular/router": "~20.0.6",
"@angular/cdk": "^20.1.4",
"@angular/common": "~20.1.4",
"@angular/compiler": "~20.1.4",
"@angular/core": "~20.1.4",
"@angular/forms": "~20.1.4",
"@angular/localize": "~20.1.4",
"@angular/platform-browser": "~20.1.4",
"@angular/platform-browser-dynamic": "~20.1.4",
"@angular/router": "~20.1.4",
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
"@ng-select/ng-select": "^15.1.3",
"@ng-select/ng-select": "^20.0.1",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.7",
@@ -32,7 +32,7 @@
"ngx-color": "^10.0.0",
"ngx-cookie-service": "^20.0.1",
"ngx-device-detector": "^10.0.2",
"ngx-ui-tour-ng-bootstrap": "^17.0.0",
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"utif": "^3.1.0",
@@ -42,33 +42,33 @@
"devDependencies": {
"@angular-builders/custom-webpack": "^20.0.0",
"@angular-builders/jest": "^20.0.0",
"@angular-devkit/core": "^20.0.4",
"@angular-devkit/schematics": "^20.0.4",
"@angular-devkit/core": "^20.1.4",
"@angular-devkit/schematics": "^20.1.4",
"@angular-eslint/builder": "20.1.1",
"@angular-eslint/eslint-plugin": "20.1.1",
"@angular-eslint/eslint-plugin-template": "20.1.1",
"@angular-eslint/schematics": "20.1.1",
"@angular-eslint/template-parser": "20.1.1",
"@angular/build": "^20.0.4",
"@angular/cli": "~20.0.4",
"@angular/compiler-cli": "~20.0.6",
"@angular/build": "^20.1.4",
"@angular/cli": "~20.1.4",
"@angular/compiler-cli": "~20.1.4",
"@codecov/webpack-plugin": "^1.9.1",
"@playwright/test": "^1.53.2",
"@types/jest": "^29.5.14",
"@types/node": "^24.0.10",
"@typescript-eslint/eslint-plugin": "^8.35.1",
"@typescript-eslint/parser": "^8.35.1",
"@typescript-eslint/utils": "^8.35.1",
"eslint": "^9.30.1",
"jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0",
"@playwright/test": "^1.54.2",
"@types/jest": "^30.0.0",
"@types/node": "^24.1.0",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"@typescript-eslint/utils": "^8.38.0",
"eslint": "^9.32.0",
"jest": "30.0.5",
"jest-environment-jsdom": "^30.0.5",
"jest-junit": "^16.0.0",
"jest-preset-angular": "^14.5.5",
"jest-preset-angular": "^15.0.0",
"jest-websocket-mock": "^2.5.0",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-organize-imports": "^4.2.0",
"ts-node": "~10.9.1",
"typescript": "^5.8.3",
"webpack": "^5.99.9"
"webpack": "^5.101.0"
},
"pnpm": {
"onlyBuiltDependencies": [

5202
src-ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,16 @@
import '@angular/localize/init'
import { jest } from '@jest/globals'
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'
import { TextDecoder, TextEncoder } from 'util'
import { TextDecoder, TextEncoder } from 'node:util'
if (process.env.NODE_ENV === 'test') {
setupZoneTestEnv()
}
global.TextEncoder = TextEncoder
global.TextDecoder = TextDecoder
;(globalThis as any).TextEncoder = TextEncoder as unknown as {
new (): TextEncoder
}
;(globalThis as any).TextDecoder = TextDecoder as unknown as {
new (): TextDecoder
}
import { registerLocaleData } from '@angular/common'
import localeAf from '@angular/common/locales/af'
@@ -116,10 +120,6 @@ if (!URL.revokeObjectURL) {
Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() })
}
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
Object.defineProperty(window, 'location', {
configurable: true,
value: { reload: jest.fn() },
})
HTMLCanvasElement.prototype.getContext = <
typeof HTMLCanvasElement.prototype.getContext

View File

@@ -50,7 +50,7 @@
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group me-2">
<button type="button" (click)="discardChanges()" class="btn btn-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Discard</button>
<button type="button" (click)="discardChanges()" class="btn btn-outline-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Discard</button>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary" [disabled]="loading || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button>

View File

@@ -358,6 +358,6 @@
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
<button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
<button type="button" (click)="reset()" class="btn btn-secondary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
<button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
<button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
</form>

View File

@@ -36,6 +36,7 @@ import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { Toast, ToastService } from 'src/app/services/toast.service'
import * as navUtils from 'src/app/utils/navigation'
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { CheckComponent } from '../../common/input/check/check.component'
@@ -225,6 +226,9 @@ describe('SettingsComponent', () => {
})
it('should offer reload if settings changes require', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
completeSetup()
let toast: Toast
toastService.getToasts().subscribe((t) => (toast = t[0]))
@@ -241,6 +245,7 @@ describe('SettingsComponent', () => {
expect(toast.actionName).toEqual('Reload now')
toast.action()
expect(reloadSpy).toHaveBeenCalled()
})
it('should allow setting theme color, visually apply change immediately but not save', () => {
@@ -269,7 +274,7 @@ describe('SettingsComponent', () => {
)
completeSetup(userService)
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
})
it('should show errors on load if load groups failure', () => {
@@ -281,7 +286,7 @@ describe('SettingsComponent', () => {
)
completeSetup(groupService)
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
})
it('should load system status on initialize, show errors if needed', () => {

View File

@@ -57,6 +57,7 @@ import {
} from 'src/app/services/settings.service'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { locationReload } from 'src/app/utils/navigation'
import { CheckComponent } from '../../common/input/check/check.component'
import { ColorComponent } from '../../common/input/color/color.component'
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
@@ -550,7 +551,7 @@ export class SettingsComponent
savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
savedToast.actionName = $localize`Reload now`
savedToast.action = () => {
location.reload()
locationReload()
}
}

View File

@@ -19,6 +19,7 @@ import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import * as navUtils from 'src/app/utils/navigation'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
@@ -107,7 +108,7 @@ describe('UsersAndGroupsComponent', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
settingsService.currentUser = users[1] // simulate logged in as different user
editDialog.succeeded.emit(users[0])
expect(toastInfoSpy).toHaveBeenCalledWith(
@@ -130,7 +131,7 @@ describe('UsersAndGroupsComponent', () => {
throwError(() => new Error('error deleting user'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
@@ -142,19 +143,18 @@ describe('UsersAndGroupsComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editUser(users[0])
const navSpy = jest
.spyOn(navUtils, 'setLocationHref')
.mockImplementation(() => {})
const editDialog = modal.componentInstance as UserEditDialogComponent
editDialog.passwordIsSet = true
settingsService.currentUser = users[0] // simulate logged in as same user
editDialog.succeeded.emit(users[0])
fixture.detectChanges()
Object.defineProperty(window, 'location', {
value: {
href: 'http://localhost/',
},
writable: true, // possibility to override
})
tick(2600)
expect(window.location.href).toContain('logout')
expect(navSpy).toHaveBeenCalledWith(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}))
it('should support edit / create group, show error if needed', () => {
@@ -166,7 +166,7 @@ describe('UsersAndGroupsComponent', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(groups[0])
expect(toastInfoSpy).toHaveBeenCalledWith(
`Saved group "${groups[0].name}".`
@@ -188,7 +188,7 @@ describe('UsersAndGroupsComponent', () => {
throwError(() => new Error('error deleting group'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
@@ -210,7 +210,7 @@ describe('UsersAndGroupsComponent', () => {
)
completeSetup(userService)
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
})
it('should show errors on load if load groups failure', () => {
@@ -222,6 +222,6 @@ describe('UsersAndGroupsComponent', () => {
)
completeSetup(groupService)
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
})
})

View File

@@ -10,6 +10,7 @@ import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { setLocationHref } from 'src/app/utils/navigation'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
@@ -93,7 +94,9 @@ export class UsersAndGroupsComponent
$localize`Password has been changed, you will be logged out momentarily.`
)
setTimeout(() => {
window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
setLocationHref(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}, 2500)
} else {
this.toastService.showInfo(

View File

@@ -30,7 +30,7 @@
}
<div class="list-group-item">
<div class="input-group input-group-sm">
<input class="form-control" type="text" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput>
<input class="form-control" type="text" spellcheck="false" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput>
</div>
</div>
@if (selectionModel.items) {

View File

@@ -18,6 +18,7 @@ import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { ProfileService } from 'src/app/services/profile.service'
import { ToastService } from 'src/app/services/toast.service'
import * as navUtils from 'src/app/utils/navigation'
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
import { PasswordComponent } from '../input/password/password.component'
import { TextComponent } from '../input/text/text.component'
@@ -205,16 +206,15 @@ describe('ProfileEditDialogComponent', () => {
const updateSpy = jest.spyOn(profileService, 'update')
updateSpy.mockReturnValue(of(null))
Object.defineProperty(window, 'location', {
value: {
href: 'http://localhost/',
},
writable: true, // possibility to override
})
const navSpy = jest
.spyOn(navUtils, 'setLocationHref')
.mockImplementation(() => {})
component.save()
expect(updateSpy).toHaveBeenCalled()
tick(2600)
expect(window.location.href).toContain('logout')
expect(navSpy).toHaveBeenCalledWith(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}))
it('should support auth token copy', fakeAsync(() => {

View File

@@ -21,6 +21,7 @@ import {
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { ProfileService } from 'src/app/services/profile.service'
import { ToastService } from 'src/app/services/toast.service'
import { setLocationHref } from 'src/app/utils/navigation'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
import { PasswordComponent } from '../input/password/password.component'
@@ -194,7 +195,9 @@ export class ProfileEditDialogComponent
$localize`Password has been changed, you will be logged out momentarily.`
)
setTimeout(() => {
window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
setLocationHref(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}, 2500)
}
this.activeModal.close()

View File

@@ -188,7 +188,7 @@ describe('MailComponent', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(mailAccounts[0] as any)
expect(toastInfoSpy).toHaveBeenCalledWith(
`Saved account "${mailAccounts[0].name}".`
@@ -211,7 +211,7 @@ describe('MailComponent', () => {
throwError(() => new Error('error deleting mail account'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
@@ -246,7 +246,7 @@ describe('MailComponent', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(mailRules[0] as any)
expect(toastInfoSpy).toHaveBeenCalledWith(
`Saved rule "${mailRules[0].name}".`
@@ -280,7 +280,7 @@ describe('MailComponent', () => {
throwError(() => new Error('error deleting mail rule "rule1"'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()

View File

@@ -1,4 +1,5 @@
<pngx-page-header title="{{ typeNamePlural | titlecase }}">
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions">
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Clear selection</ng-container>
</button>

View File

@@ -164,7 +164,7 @@ describe('ManagementListComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reloadData')
const createButton = fixture.debugElement.queryAll(By.css('button'))[3]
const createButton = fixture.debugElement.queryAll(By.css('button'))[4]
createButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
@@ -188,7 +188,7 @@ describe('ManagementListComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reloadData')
const editButton = fixture.debugElement.queryAll(By.css('button'))[6]
const editButton = fixture.debugElement.queryAll(By.css('button'))[7]
editButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
@@ -213,7 +213,7 @@ describe('ManagementListComponent', () => {
const deleteSpy = jest.spyOn(tagService, 'delete')
const reloadSpy = jest.spyOn(component, 'reloadData')
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[7]
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[8]
deleteButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
@@ -233,7 +233,7 @@ describe('ManagementListComponent', () => {
it('should support quick filter for objects', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
const filterButton = fixture.debugElement.queryAll(By.css('button'))[8]
const filterButton = fixture.debugElement.queryAll(By.css('button'))[9]
filterButton.triggerEventHandler('click')
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() },

View File

@@ -70,6 +70,6 @@
}
</ul>
<button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
<button type="button" (click)="reset()" class="btn btn-secondary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
<button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
<button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
</form>

View File

@@ -0,0 +1,8 @@
/* istanbul ignore file */
export function setLocationHref(url: string) {
window.location.href = url
}
export function locationReload() {
window.location.reload()
}

View File

@@ -3,7 +3,8 @@
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jest"
"jest",
"node",
],
"module": "commonjs",
"emitDecoratorMetadata": true,

View File

@@ -1,4 +1,5 @@
import os
from pathlib import Path
from django.conf import settings
@@ -7,19 +8,15 @@ from documents.templating.filepath import validate_filepath_template_and_render
from documents.templating.utils import convert_format_str_to_template_format
def create_source_path_directory(source_path):
os.makedirs(os.path.dirname(source_path), exist_ok=True)
def create_source_path_directory(source_path: Path) -> None:
source_path.parent.mkdir(parents=True, exist_ok=True)
def delete_empty_directories(directory, root):
if not os.path.isdir(directory):
def delete_empty_directories(directory: Path, root: Path) -> None:
if not directory.is_dir():
return
# Go up in the directory hierarchy and try to delete all directories
directory = os.path.normpath(directory)
root = os.path.normpath(root)
if not directory.startswith(root + os.path.sep):
if not directory.is_relative_to(root):
# don't do anything outside our originals folder.
# append os.path.set so that we avoid these cases:
@@ -27,11 +24,12 @@ def delete_empty_directories(directory, root):
# root = /home/originals ("/" gets appended and startswith fails)
return
# Go up in the directory hierarchy and try to delete all directories
while directory != root:
if not os.listdir(directory):
if not list(directory.iterdir()):
# it's empty
try:
os.rmdir(directory)
directory.rmdir()
except OSError:
# whatever. empty directories aren't that bad anyway.
return
@@ -40,10 +38,10 @@ def delete_empty_directories(directory, root):
return
# go one level up
directory = os.path.normpath(os.path.dirname(directory))
directory = directory.parent
def generate_unique_filename(doc, *, archive_filename=False):
def generate_unique_filename(doc, *, archive_filename=False) -> Path:
"""
Generates a unique filename for doc in settings.ORIGINALS_DIR.
@@ -56,21 +54,32 @@ def generate_unique_filename(doc, *, archive_filename=False):
"""
if archive_filename:
old_filename = doc.archive_filename
old_filename: Path | None = (
Path(doc.archive_filename) if doc.archive_filename else None
)
root = settings.ARCHIVE_DIR
else:
old_filename = doc.filename
old_filename = Path(doc.filename) if doc.filename else None
root = settings.ORIGINALS_DIR
# If generating archive filenames, try to make a name that is similar to
# the original filename first.
if archive_filename and doc.filename:
new_filename = os.path.splitext(doc.filename)[0] + ".pdf"
if new_filename == old_filename or not os.path.exists(
os.path.join(root, new_filename),
):
return new_filename
# Generate the full path using the same logic as generate_filename
base_generated = generate_filename(doc, archive_filename=archive_filename)
# Try to create a simple PDF version based on the original filename
# but preserve any directory structure from the template
if str(base_generated.parent) != ".":
# Has directory structure, preserve it
simple_pdf_name = base_generated.parent / (Path(doc.filename).stem + ".pdf")
else:
# No directory structure
simple_pdf_name = Path(Path(doc.filename).stem + ".pdf")
if simple_pdf_name == old_filename or not (root / simple_pdf_name).exists():
return simple_pdf_name
counter = 0
@@ -84,7 +93,7 @@ def generate_unique_filename(doc, *, archive_filename=False):
# still the same as before.
return new_filename
if os.path.exists(os.path.join(root, new_filename)):
if (root / new_filename).exists():
counter += 1
else:
return new_filename
@@ -96,8 +105,8 @@ def generate_filename(
counter=0,
append_gpg=True,
archive_filename=False,
):
path = ""
) -> Path:
base_path: Path | None = None
def format_filename(document: Document, template_str: str) -> str | None:
rendered_filename = validate_filepath_template_and_render(
@@ -134,17 +143,34 @@ def generate_filename(
# If we have one, render it
if filename_format is not None:
path = format_filename(doc, filename_format)
rendered_path: str | None = format_filename(doc, filename_format)
if rendered_path:
base_path = Path(rendered_path)
counter_str = f"_{counter:02}" if counter else ""
filetype_str = ".pdf" if archive_filename else doc.file_type
if path:
filename = f"{path}{counter_str}{filetype_str}"
if base_path:
# Split the path into directory and filename parts
directory = base_path.parent
# Use the full name (not just stem) as the base filename
base_filename = base_path.name
# Build the final filename with counter and filetype
final_filename = f"{base_filename}{counter_str}{filetype_str}"
# If we have a directory component, include it
if str(directory) != ".":
full_path = directory / final_filename
else:
full_path = Path(final_filename)
else:
filename = f"{doc.pk:07}{counter_str}{filetype_str}"
# No template, use document ID
final_filename = f"{doc.pk:07}{counter_str}{filetype_str}"
full_path = Path(final_filename)
# Add GPG extension if needed
if append_gpg and doc.storage_type == doc.STORAGE_TYPE_GPG:
filename += ".gpg"
full_path = full_path.with_suffix(full_path.suffix + ".gpg")
return filename
return full_path

View File

@@ -236,10 +236,7 @@ class Command(CryptMixin, BaseCommand):
# now make an archive in the original target, with all files stored
if self.zip_export and temp_dir is not None:
shutil.make_archive(
os.path.join(
self.original_target,
options["zip_name"],
),
self.original_target / options["zip_name"],
format="zip",
root_dir=temp_dir.name,
)
@@ -342,7 +339,7 @@ class Command(CryptMixin, BaseCommand):
)
if self.split_manifest:
manifest_name = Path(base_name + "-manifest.json")
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
if self.use_folder_prefix:
manifest_name = Path("json") / manifest_name
manifest_name = (self.target / manifest_name).resolve()
@@ -416,7 +413,7 @@ class Command(CryptMixin, BaseCommand):
else:
item.unlink()
def generate_base_name(self, document: Document) -> str:
def generate_base_name(self, document: Document) -> Path:
"""
Generates a unique name for the document, one which hasn't already been exported (or will be)
"""
@@ -436,12 +433,12 @@ class Command(CryptMixin, BaseCommand):
break
else:
filename_counter += 1
return base_name
return Path(base_name)
def generate_document_targets(
self,
document: Document,
base_name: str,
base_name: Path,
document_dict: dict,
) -> tuple[Path, Path | None, Path | None]:
"""
@@ -449,25 +446,25 @@ class Command(CryptMixin, BaseCommand):
"""
original_name = base_name
if self.use_folder_prefix:
original_name = os.path.join("originals", original_name)
original_target = (self.target / Path(original_name)).resolve()
document_dict[EXPORTER_FILE_NAME] = original_name
original_name = Path("originals") / original_name
original_target = (self.target / original_name).resolve()
document_dict[EXPORTER_FILE_NAME] = str(original_name)
if not self.no_thumbnail:
thumbnail_name = base_name + "-thumbnail.webp"
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp")
if self.use_folder_prefix:
thumbnail_name = os.path.join("thumbnails", thumbnail_name)
thumbnail_target = (self.target / Path(thumbnail_name)).resolve()
document_dict[EXPORTER_THUMBNAIL_NAME] = thumbnail_name
thumbnail_name = Path("thumbnails") / thumbnail_name
thumbnail_target = (self.target / thumbnail_name).resolve()
document_dict[EXPORTER_THUMBNAIL_NAME] = str(thumbnail_name)
else:
thumbnail_target = None
if not self.no_archive and document.has_archive_version:
archive_name = base_name + "-archive.pdf"
archive_name = base_name.parent / (base_name.stem + "-archive.pdf")
if self.use_folder_prefix:
archive_name = os.path.join("archive", archive_name)
archive_target = (self.target / Path(archive_name)).resolve()
document_dict[EXPORTER_ARCHIVE_NAME] = archive_name
archive_name = Path("archive") / archive_name
archive_target = (self.target / archive_name).resolve()
document_dict[EXPORTER_ARCHIVE_NAME] = str(archive_name)
else:
archive_target = None
@@ -572,7 +569,7 @@ class Command(CryptMixin, BaseCommand):
perform_copy = False
if target.exists():
source_stat = os.stat(source)
source_stat = source.stat()
target_stat = target.stat()
if self.compare_checksums and source_checksum:
target_checksum = hashlib.md5(target.read_bytes()).hexdigest()

View File

@@ -63,11 +63,11 @@ class Document:
/ "documents"
/ "originals"
/ f"{self.pk:07}.{self.file_type}.gpg"
).as_posix()
)
@property
def source_file(self):
return Path(self.source_path).open("rb")
return self.source_path.open("rb")
@property
def file_name(self):

View File

@@ -1,8 +1,8 @@
from __future__ import annotations
import logging
import os
import shutil
from pathlib import Path
from typing import TYPE_CHECKING
import httpx
@@ -12,11 +12,13 @@ from celery.signals import before_task_publish
from celery.signals import task_failure
from celery.signals import task_postrun
from celery.signals import task_prerun
from celery.signals import worker_process_init
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.db import DatabaseError
from django.db import close_old_connections
from django.db import connections
from django.db import models
from django.db.models import Q
from django.dispatch import receiver
@@ -49,8 +51,6 @@ from documents.permissions import set_permissions_for_object
from documents.templating.workflows import parse_w_workflow_placeholders
if TYPE_CHECKING:
from pathlib import Path
from documents.classifier import DocumentClassifier
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
@@ -327,15 +327,16 @@ def cleanup_document_deletion(sender, instance, **kwargs):
# Find a non-conflicting filename in case a document with the same
# name was moved to trash earlier
counter = 0
old_filename = os.path.split(instance.source_path)[1]
(old_filebase, old_fileext) = os.path.splitext(old_filename)
old_filename = Path(instance.source_path).name
old_filebase = Path(old_filename).stem
old_fileext = Path(old_filename).suffix
while True:
new_file_path = settings.EMPTY_TRASH_DIR / (
old_filebase + (f"_{counter:02}" if counter else "") + old_fileext
)
if os.path.exists(new_file_path):
if new_file_path.exists():
counter += 1
else:
break
@@ -359,26 +360,26 @@ def cleanup_document_deletion(sender, instance, **kwargs):
files += (instance.source_path,)
for filename in files:
if filename and os.path.isfile(filename):
if filename and filename.is_file():
try:
os.unlink(filename)
filename.unlink()
logger.debug(f"Deleted file {filename}.")
except OSError as e:
logger.warning(
f"While deleting document {instance!s}, the file "
f"{filename} could not be deleted: {e}",
)
elif filename and not os.path.isfile(filename):
elif filename and not filename.is_file():
logger.warning(f"Expected {filename} to exist, but it did not")
delete_empty_directories(
os.path.dirname(instance.source_path),
Path(instance.source_path).parent,
root=settings.ORIGINALS_DIR,
)
if instance.has_archive_version:
delete_empty_directories(
os.path.dirname(instance.archive_path),
Path(instance.archive_path).parent,
root=settings.ARCHIVE_DIR,
)
@@ -399,14 +400,14 @@ def update_filename_and_move_files(
if isinstance(instance, CustomFieldInstance):
instance = instance.document
def validate_move(instance, old_path, new_path):
if not os.path.isfile(old_path):
def validate_move(instance, old_path: Path, new_path: Path):
if not old_path.is_file():
# Can't do anything if the old file does not exist anymore.
msg = f"Document {instance!s}: File {old_path} doesn't exist."
logger.fatal(msg)
raise CannotMoveFilesException(msg)
if os.path.isfile(new_path):
if new_path.is_file():
# Can't do anything if the new file already exists. Skip updating file.
msg = f"Document {instance!s}: Cannot rename file since target path {new_path} already exists."
logger.warning(msg)
@@ -434,16 +435,20 @@ def update_filename_and_move_files(
old_filename = instance.filename
old_source_path = instance.source_path
instance.filename = generate_unique_filename(instance)
# Need to convert to string to be able to save it to the db
instance.filename = str(generate_unique_filename(instance))
move_original = old_filename != instance.filename
old_archive_filename = instance.archive_filename
old_archive_path = instance.archive_path
if instance.has_archive_version:
instance.archive_filename = generate_unique_filename(
instance,
archive_filename=True,
# Need to convert to string to be able to save it to the db
instance.archive_filename = str(
generate_unique_filename(
instance,
archive_filename=True,
),
)
move_archive = old_archive_filename != instance.archive_filename
@@ -485,11 +490,11 @@ def update_filename_and_move_files(
# Try to move files to their original location.
try:
if move_original and os.path.isfile(instance.source_path):
if move_original and instance.source_path.is_file():
logger.info("Restoring previous original path")
shutil.move(instance.source_path, old_source_path)
if move_archive and os.path.isfile(instance.archive_path):
if move_archive and instance.archive_path.is_file():
logger.info("Restoring previous archive path")
shutil.move(instance.archive_path, old_archive_path)
@@ -510,17 +515,15 @@ def update_filename_and_move_files(
# finally, remove any empty sub folders. This will do nothing if
# something has failed above.
if not os.path.isfile(old_source_path):
if not old_source_path.is_file():
delete_empty_directories(
os.path.dirname(old_source_path),
Path(old_source_path).parent,
root=settings.ORIGINALS_DIR,
)
if instance.has_archive_version and not os.path.isfile(
old_archive_path,
):
if instance.has_archive_version and not old_archive_path.is_file():
delete_empty_directories(
os.path.dirname(old_archive_path),
Path(old_archive_path).parent,
root=settings.ARCHIVE_DIR,
)
@@ -1217,10 +1220,7 @@ def run_workflows(
)
files = None
if action.webhook.include_document:
with open(
original_file,
"rb",
) as f:
with original_file.open("rb") as f:
files = {
"file": (
filename,
@@ -1439,3 +1439,18 @@ def task_failure_handler(
task_instance.save()
except Exception: # pragma: no cover
logger.exception("Updating PaperlessTask failed")
@worker_process_init.connect
def close_connection_pool_on_worker_init(**kwargs):
"""
Close the DB connection pool for each Celery child process after it starts.
This is necessary because the parent process parse the Django configuration,
initializes connection pools then forks.
Closing these pools after forking ensures child processes have a valid connection.
"""
for conn in connections.all(initialized_only=True):
if conn.alias == "default" and hasattr(conn, "pool") and conn.pool:
conn.close_pool()

View File

@@ -41,11 +41,9 @@ class TestDocument(TestCase):
Path(file_path).touch()
Path(thumb_path).touch()
with mock.patch("documents.signals.handlers.os.unlink") as mock_unlink:
with mock.patch("documents.signals.handlers.Path.unlink") as mock_unlink:
document.delete()
empty_trash([document.pk])
mock_unlink.assert_any_call(file_path)
mock_unlink.assert_any_call(thumb_path)
self.assertEqual(mock_unlink.call_count, 2)
def test_document_soft_delete(self):
@@ -63,7 +61,7 @@ class TestDocument(TestCase):
Path(file_path).touch()
Path(thumb_path).touch()
with mock.patch("documents.signals.handlers.os.unlink") as mock_unlink:
with mock.patch("documents.signals.handlers.Path.unlink") as mock_unlink:
document.delete()
self.assertEqual(mock_unlink.call_count, 0)

View File

@@ -34,12 +34,12 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
document.save()
self.assertEqual(generate_filename(document), f"{document.pk:07d}.pdf")
self.assertEqual(generate_filename(document), Path(f"{document.pk:07d}.pdf"))
document.storage_type = Document.STORAGE_TYPE_GPG
self.assertEqual(
generate_filename(document),
f"{document.pk:07d}.pdf.gpg",
Path(f"{document.pk:07d}.pdf.gpg"),
)
@override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}")
@@ -58,12 +58,12 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
document.filename = generate_filename(document)
# Ensure that filename is properly generated
self.assertEqual(document.filename, "none/none.pdf")
self.assertEqual(document.filename, Path("none/none.pdf"))
# Enable encryption and check again
document.storage_type = Document.STORAGE_TYPE_GPG
document.filename = generate_filename(document)
self.assertEqual(document.filename, "none/none.pdf.gpg")
self.assertEqual(document.filename, Path("none/none.pdf.gpg"))
document.save()
@@ -96,7 +96,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
# Ensure that filename is properly generated
document.filename = generate_filename(document)
self.assertEqual(document.filename, "none/none.pdf")
self.assertEqual(document.filename, Path("none/none.pdf"))
create_source_path_directory(document.source_path)
document.source_path.touch()
@@ -137,7 +137,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
# Ensure that filename is properly generated
document.filename = generate_filename(document)
self.assertEqual(document.filename, "none/none.pdf")
self.assertEqual(document.filename, Path("none/none.pdf"))
create_source_path_directory(document.source_path)
Path(document.source_path).touch()
@@ -247,7 +247,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
# Ensure that filename is properly generated
document.filename = generate_filename(document)
self.assertEqual(document.filename, "none/none.pdf")
self.assertEqual(document.filename, Path("none/none.pdf"))
create_source_path_directory(document.source_path)
@@ -269,11 +269,11 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
dt = DocumentType.objects.create(name="my_doc_type")
d = Document.objects.create(title="the_doc", mime_type="application/pdf")
self.assertEqual(generate_filename(d), "none - the_doc.pdf")
self.assertEqual(generate_filename(d), Path("none - the_doc.pdf"))
d.document_type = dt
self.assertEqual(generate_filename(d), "my_doc_type - the_doc.pdf")
self.assertEqual(generate_filename(d), Path("my_doc_type - the_doc.pdf"))
@override_settings(FILENAME_FORMAT="{asn} - {title}")
def test_asn(self):
@@ -289,8 +289,8 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
archive_serial_number=None,
checksum="B",
)
self.assertEqual(generate_filename(d1), "652 - the_doc.pdf")
self.assertEqual(generate_filename(d2), "none - the_doc.pdf")
self.assertEqual(generate_filename(d1), Path("652 - the_doc.pdf"))
self.assertEqual(generate_filename(d2), Path("none - the_doc.pdf"))
@override_settings(FILENAME_FORMAT="{title} {tag_list}")
def test_tag_list(self):
@@ -298,7 +298,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
doc.tags.create(name="tag2")
doc.tags.create(name="tag1")
self.assertEqual(generate_filename(doc), "doc1 tag1,tag2.pdf")
self.assertEqual(generate_filename(doc), Path("doc1 tag1,tag2.pdf"))
doc = Document.objects.create(
title="doc2",
@@ -306,7 +306,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
mime_type="application/pdf",
)
self.assertEqual(generate_filename(doc), "doc2.pdf")
self.assertEqual(generate_filename(doc), Path("doc2.pdf"))
@override_settings(FILENAME_FORMAT="//etc/something/{title}")
def test_filename_relative(self):
@@ -330,11 +330,11 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
created=d1,
)
self.assertEqual(generate_filename(doc1), "2020-03-06.pdf")
self.assertEqual(generate_filename(doc1), Path("2020-03-06.pdf"))
doc1.created = datetime.date(2020, 11, 16)
self.assertEqual(generate_filename(doc1), "2020-11-16.pdf")
self.assertEqual(generate_filename(doc1), Path("2020-11-16.pdf"))
@override_settings(
FILENAME_FORMAT="{added_year}-{added_month}-{added_day}",
@@ -347,11 +347,11 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
added=d1,
)
self.assertEqual(generate_filename(doc1), "232-01-09.pdf")
self.assertEqual(generate_filename(doc1), Path("232-01-09.pdf"))
doc1.added = timezone.make_aware(datetime.datetime(2020, 11, 16, 1, 1, 1))
self.assertEqual(generate_filename(doc1), "2020-11-16.pdf")
self.assertEqual(generate_filename(doc1), Path("2020-11-16.pdf"))
@override_settings(
FILENAME_FORMAT="{correspondent}/{correspondent}/{correspondent}",
@@ -389,11 +389,11 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
document.mime_type = "application/pdf"
document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
self.assertEqual(generate_filename(document), "0000001.pdf")
self.assertEqual(generate_filename(document), Path("0000001.pdf"))
document.pk = 13579
self.assertEqual(generate_filename(document), "0013579.pdf")
self.assertEqual(generate_filename(document), Path("0013579.pdf"))
@override_settings(FILENAME_FORMAT=None)
def test_format_none(self):
@@ -402,7 +402,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
document.mime_type = "application/pdf"
document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
self.assertEqual(generate_filename(document), "0000001.pdf")
self.assertEqual(generate_filename(document), Path("0000001.pdf"))
def test_try_delete_empty_directories(self):
# Create our working directory
@@ -428,7 +428,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
document.mime_type = "application/pdf"
document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
self.assertEqual(generate_filename(document), "0000001.pdf")
self.assertEqual(generate_filename(document), Path("0000001.pdf"))
@override_settings(FILENAME_FORMAT="{created__year}")
def test_invalid_format_key(self):
@@ -437,7 +437,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
document.mime_type = "application/pdf"
document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED
self.assertEqual(generate_filename(document), "0000001.pdf")
self.assertEqual(generate_filename(document), Path("0000001.pdf"))
@override_settings(FILENAME_FORMAT="{title}")
def test_duplicates(self):
@@ -564,7 +564,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
value_select="abc123",
)
self.assertEqual(generate_filename(doc), "document_apple.pdf")
self.assertEqual(generate_filename(doc), Path("document_apple.pdf"))
# handler should not have been called
self.assertEqual(m.call_count, 0)
@@ -576,7 +576,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
],
}
cf.save()
self.assertEqual(generate_filename(doc), "document_aubergine.pdf")
self.assertEqual(generate_filename(doc), Path("document_aubergine.pdf"))
# handler should have been called
self.assertEqual(m.call_count, 1)
@@ -897,7 +897,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
pk=1,
checksum="1",
)
self.assertEqual(generate_filename(doc), "This. is the title.pdf")
self.assertEqual(generate_filename(doc), Path("This. is the title.pdf"))
doc = Document.objects.create(
title="my\\invalid/../title:yay",
@@ -905,7 +905,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
pk=2,
checksum="2",
)
self.assertEqual(generate_filename(doc), "my-invalid-..-title-yay.pdf")
self.assertEqual(generate_filename(doc), Path("my-invalid-..-title-yay.pdf"))
@override_settings(FILENAME_FORMAT="{created}")
def test_date(self):
@@ -916,7 +916,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
pk=2,
checksum="2",
)
self.assertEqual(generate_filename(doc), "2020-05-21.pdf")
self.assertEqual(generate_filename(doc), Path("2020-05-21.pdf"))
def test_dynamic_path(self):
"""
@@ -935,7 +935,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
checksum="2",
storage_path=StoragePath.objects.create(path="TestFolder/{{created}}"),
)
self.assertEqual(generate_filename(doc), "TestFolder/2020-06-25.pdf")
self.assertEqual(generate_filename(doc), Path("TestFolder/2020-06-25.pdf"))
def test_dynamic_path_with_none(self):
"""
@@ -956,7 +956,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
checksum="2",
storage_path=StoragePath.objects.create(path="{{asn}} - {{created}}"),
)
self.assertEqual(generate_filename(doc), "none - 2020-06-25.pdf")
self.assertEqual(generate_filename(doc), Path("none - 2020-06-25.pdf"))
@override_settings(
FILENAME_FORMAT_REMOVE_NONE=True,
@@ -984,7 +984,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
checksum="2",
storage_path=sp,
)
self.assertEqual(generate_filename(doc), "TestFolder/2020-06-25.pdf")
self.assertEqual(generate_filename(doc), Path("TestFolder/2020-06-25.pdf"))
# Special case, undefined variable, then defined at the start of the template
# This could lead to an absolute path after we remove the leading -none-, but leave the leading /
@@ -993,7 +993,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
"{{ owner_username }}/{{ created_year }}/{{ correspondent }}/{{ title }}"
)
sp.save()
self.assertEqual(generate_filename(doc), "2020/does not matter.pdf")
self.assertEqual(generate_filename(doc), Path("2020/does not matter.pdf"))
def test_multiple_doc_paths(self):
"""
@@ -1028,8 +1028,14 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
),
)
self.assertEqual(generate_filename(doc_a), "ThisIsAFolder/4/2020-06-25.pdf")
self.assertEqual(generate_filename(doc_b), "SomeImportantNone/2020-07-25.pdf")
self.assertEqual(
generate_filename(doc_a),
Path("ThisIsAFolder/4/2020-06-25.pdf"),
)
self.assertEqual(
generate_filename(doc_b),
Path("SomeImportantNone/2020-07-25.pdf"),
)
@override_settings(
FILENAME_FORMAT=None,
@@ -1064,8 +1070,11 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
),
)
self.assertEqual(generate_filename(doc_a), "0000002.pdf")
self.assertEqual(generate_filename(doc_b), "SomeImportantNone/2020-07-25.pdf")
self.assertEqual(generate_filename(doc_a), Path("0000002.pdf"))
self.assertEqual(
generate_filename(doc_b),
Path("SomeImportantNone/2020-07-25.pdf"),
)
@override_settings(
FILENAME_FORMAT="{created_year_short}/{created_month_name_short}/{created_month_name}/{title}",
@@ -1078,7 +1087,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
pk=2,
checksum="2",
)
self.assertEqual(generate_filename(doc), "89/Dec/December/The Title.pdf")
self.assertEqual(generate_filename(doc), Path("89/Dec/December/The Title.pdf"))
@override_settings(
FILENAME_FORMAT="{added_year_short}/{added_month_name}/{added_month_name_short}/{title}",
@@ -1091,7 +1100,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
pk=2,
checksum="2",
)
self.assertEqual(generate_filename(doc), "84/August/Aug/The Title.pdf")
self.assertEqual(generate_filename(doc), Path("84/August/Aug/The Title.pdf"))
@override_settings(
FILENAME_FORMAT="{owner_username}/{title}",
@@ -1124,8 +1133,8 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
checksum="3",
)
self.assertEqual(generate_filename(owned_doc), "user1/The Title.pdf")
self.assertEqual(generate_filename(no_owner_doc), "none/does matter.pdf")
self.assertEqual(generate_filename(owned_doc), Path("user1/The Title.pdf"))
self.assertEqual(generate_filename(no_owner_doc), Path("none/does matter.pdf"))
@override_settings(
FILENAME_FORMAT="{original_name}",
@@ -1171,17 +1180,20 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
original_filename="logs.txt",
)
self.assertEqual(generate_filename(doc_with_original), "someepdf.pdf")
self.assertEqual(generate_filename(doc_with_original), Path("someepdf.pdf"))
self.assertEqual(
generate_filename(tricky_with_original),
"some pdf with spaces and stuff.pdf",
Path("some pdf with spaces and stuff.pdf"),
)
self.assertEqual(generate_filename(no_original), "none.pdf")
self.assertEqual(generate_filename(no_original), Path("none.pdf"))
self.assertEqual(generate_filename(text_doc), "logs.txt")
self.assertEqual(generate_filename(text_doc, archive_filename=True), "logs.pdf")
self.assertEqual(generate_filename(text_doc), Path("logs.txt"))
self.assertEqual(
generate_filename(text_doc, archive_filename=True),
Path("logs.pdf"),
)
@override_settings(
FILENAME_FORMAT="XX{correspondent}/{title}",
@@ -1206,7 +1218,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
# Ensure that filename is properly generated
document.filename = generate_filename(document)
self.assertEqual(document.filename, "XX/doc1.pdf")
self.assertEqual(document.filename, Path("XX/doc1.pdf"))
def test_complex_template_strings(self):
"""
@@ -1244,19 +1256,19 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
self.assertEqual(
generate_filename(doc_a),
"somepath/some where/2020-06-25/Does Matter.pdf",
Path("somepath/some where/2020-06-25/Does Matter.pdf"),
)
doc_a.checksum = "5"
self.assertEqual(
generate_filename(doc_a),
"somepath/2024-10-01/Does Matter.pdf",
Path("somepath/2024-10-01/Does Matter.pdf"),
)
sp.path = "{{ document.title|lower }}{{ document.archive_serial_number - 2 }}"
sp.save()
self.assertEqual(generate_filename(doc_a), "does matter23.pdf")
self.assertEqual(generate_filename(doc_a), Path("does matter23.pdf"))
sp.path = """
somepath/
@@ -1275,13 +1287,13 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
sp.save()
self.assertEqual(
generate_filename(doc_a),
"somepath/asn-000-200/Does Matter/Does Matter.pdf",
Path("somepath/asn-000-200/Does Matter/Does Matter.pdf"),
)
doc_a.archive_serial_number = 301
doc_a.save()
self.assertEqual(
generate_filename(doc_a),
"somepath/asn-201-400/asn-3xx/Does Matter.pdf",
Path("somepath/asn-201-400/asn-3xx/Does Matter.pdf"),
)
@override_settings(
@@ -1310,7 +1322,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
with self.assertLogs(level=logging.WARNING) as capture:
self.assertEqual(
generate_filename(doc_a),
"0000002.pdf",
Path("0000002.pdf"),
)
self.assertEqual(len(capture.output), 1)
@@ -1345,7 +1357,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
with self.assertLogs(level=logging.WARNING) as capture:
self.assertEqual(
generate_filename(doc_a),
"0000002.pdf",
Path("0000002.pdf"),
)
self.assertEqual(len(capture.output), 1)
@@ -1413,7 +1425,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
):
self.assertEqual(
generate_filename(doc_a),
"invoices/1234.pdf",
Path("invoices/1234.pdf"),
)
with override_settings(
@@ -1427,7 +1439,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
):
self.assertEqual(
generate_filename(doc_a),
"Some Title_ChoiceOne.pdf",
Path("Some Title_ChoiceOne.pdf"),
)
# Check for handling Nones well
@@ -1436,7 +1448,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
self.assertEqual(
generate_filename(doc_a),
"Some Title_Default Value.pdf",
Path("Some Title_Default Value.pdf"),
)
cf.name = "Invoice Number"
@@ -1449,7 +1461,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
):
self.assertEqual(
generate_filename(doc_a),
"invoices/4567.pdf",
Path("invoices/4567.pdf"),
)
with override_settings(
@@ -1457,7 +1469,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
):
self.assertEqual(
generate_filename(doc_a),
"invoices/0.pdf",
Path("invoices/0.pdf"),
)
def test_datetime_filter(self):
@@ -1496,7 +1508,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
):
self.assertEqual(
generate_filename(doc_a),
"2020/Some Title.pdf",
Path("2020/Some Title.pdf"),
)
with override_settings(
@@ -1504,7 +1516,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
):
self.assertEqual(
generate_filename(doc_a),
"2020-06-25/Some Title.pdf",
Path("2020-06-25/Some Title.pdf"),
)
with override_settings(
@@ -1512,7 +1524,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
):
self.assertEqual(
generate_filename(doc_a),
"2024-10-01/Some Title.pdf",
Path("2024-10-01/Some Title.pdf"),
)
def test_slugify_filter(self):
@@ -1539,7 +1551,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
):
self.assertEqual(
generate_filename(doc),
"some-title-with-special-characters.pdf",
Path("some-title-with-special-characters.pdf"),
)
# Test with correspondent name containing spaces and special chars
@@ -1553,7 +1565,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
):
self.assertEqual(
generate_filename(doc),
"johns-office-workplace/some-title-with-special-characters.pdf",
Path("johns-office-workplace/some-title-with-special-characters.pdf"),
)
# Test with custom fields
@@ -1572,5 +1584,5 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
):
self.assertEqual(
generate_filename(doc),
"brussels-belgium/some-title-with-special-characters.pdf",
Path("brussels-belgium/some-title-with-special-characters.pdf"),
)

View File

@@ -209,7 +209,7 @@ class TestExportImport(
4,
)
self.assertIsFile((self.target / "manifest.json").as_posix())
self.assertIsFile(self.target / "manifest.json")
self.assertEqual(
self._get_document_from_manifest(manifest, self.d1.id)["fields"]["title"],
@@ -235,9 +235,7 @@ class TestExportImport(
).as_posix()
self.assertIsFile(fname)
self.assertIsFile(
(
self.target / element[document_exporter.EXPORTER_THUMBNAIL_NAME]
).as_posix(),
self.target / element[document_exporter.EXPORTER_THUMBNAIL_NAME],
)
with Path(fname).open("rb") as f:
@@ -252,7 +250,7 @@ class TestExportImport(
if document_exporter.EXPORTER_ARCHIVE_NAME in element:
fname = (
self.target / element[document_exporter.EXPORTER_ARCHIVE_NAME]
).as_posix()
)
self.assertIsFile(fname)
with Path(fname).open("rb") as f:
@@ -312,7 +310,7 @@ class TestExportImport(
)
self._do_export()
self.assertIsFile((self.target / "manifest.json").as_posix())
self.assertIsFile(self.target / "manifest.json")
st_mtime_1 = (self.target / "manifest.json").stat().st_mtime
@@ -322,7 +320,7 @@ class TestExportImport(
self._do_export()
m.assert_not_called()
self.assertIsFile((self.target / "manifest.json").as_posix())
self.assertIsFile(self.target / "manifest.json")
st_mtime_2 = (self.target / "manifest.json").stat().st_mtime
Path(self.d1.source_path).touch()
@@ -334,7 +332,7 @@ class TestExportImport(
self.assertEqual(m.call_count, 1)
st_mtime_3 = (self.target / "manifest.json").stat().st_mtime
self.assertIsFile((self.target / "manifest.json").as_posix())
self.assertIsFile(self.target / "manifest.json")
self.assertNotEqual(st_mtime_1, st_mtime_2)
self.assertNotEqual(st_mtime_2, st_mtime_3)
@@ -352,7 +350,7 @@ class TestExportImport(
self._do_export()
self.assertIsFile((self.target / "manifest.json").as_posix())
self.assertIsFile(self.target / "manifest.json")
with mock.patch(
"documents.management.commands.document_exporter.copy_file_with_basic_stats",
@@ -360,7 +358,7 @@ class TestExportImport(
self._do_export()
m.assert_not_called()
self.assertIsFile((self.target / "manifest.json").as_posix())
self.assertIsFile(self.target / "manifest.json")
self.d2.checksum = "asdfasdgf3"
self.d2.save()
@@ -371,7 +369,7 @@ class TestExportImport(
self._do_export(compare_checksums=True)
self.assertEqual(m.call_count, 1)
self.assertIsFile((self.target / "manifest.json").as_posix())
self.assertIsFile(self.target / "manifest.json")
def test_update_export_deleted_document(self):
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
@@ -385,7 +383,7 @@ class TestExportImport(
self.assertTrue(len(manifest), 7)
doc_from_manifest = self._get_document_from_manifest(manifest, self.d3.id)
self.assertIsFile(
(self.target / doc_from_manifest[EXPORTER_FILE_NAME]).as_posix(),
str(self.target / doc_from_manifest[EXPORTER_FILE_NAME]),
)
self.d3.delete()
@@ -397,12 +395,12 @@ class TestExportImport(
self.d3.id,
)
self.assertIsFile(
(self.target / doc_from_manifest[EXPORTER_FILE_NAME]).as_posix(),
self.target / doc_from_manifest[EXPORTER_FILE_NAME],
)
manifest = self._do_export(delete=True)
self.assertIsNotFile(
(self.target / doc_from_manifest[EXPORTER_FILE_NAME]).as_posix(),
self.target / doc_from_manifest[EXPORTER_FILE_NAME],
)
self.assertTrue(len(manifest), 6)
@@ -416,20 +414,20 @@ class TestExportImport(
)
self._do_export(use_filename_format=True)
self.assertIsFile((self.target / "wow1" / "c.pdf").as_posix())
self.assertIsFile(self.target / "wow1" / "c.pdf")
self.assertIsFile((self.target / "manifest.json").as_posix())
self.assertIsFile(self.target / "manifest.json")
self.d1.title = "new_title"
self.d1.save()
self._do_export(use_filename_format=True, delete=True)
self.assertIsNotFile((self.target / "wow1" / "c.pdf").as_posix())
self.assertIsNotDir((self.target / "wow1").as_posix())
self.assertIsFile((self.target / "new_title" / "c.pdf").as_posix())
self.assertIsFile((self.target / "manifest.json").as_posix())
self.assertIsFile((self.target / "wow2" / "none.pdf").as_posix())
self.assertIsNotFile(self.target / "wow1" / "c.pdf")
self.assertIsNotDir(self.target / "wow1")
self.assertIsFile(self.target / "new_title" / "c.pdf")
self.assertIsFile(self.target / "manifest.json")
self.assertIsFile(self.target / "wow2" / "none.pdf")
self.assertIsFile(
(self.target / "wow2" / "none_01.pdf").as_posix(),
self.target / "wow2" / "none_01.pdf",
)
def test_export_missing_files(self):

View File

@@ -20,7 +20,7 @@ def source_path_before(self):
if self.storage_type == STORAGE_TYPE_GPG:
fname += ".gpg"
return (Path(settings.ORIGINALS_DIR) / fname).as_posix()
return Path(settings.ORIGINALS_DIR) / fname
def file_type_after(self):
@@ -35,7 +35,7 @@ def source_path_after(doc):
if doc.storage_type == STORAGE_TYPE_GPG:
fname += ".gpg" # pragma: no cover
return (Path(settings.ORIGINALS_DIR) / fname).as_posix()
return Path(settings.ORIGINALS_DIR) / fname
@override_settings(PASSPHRASE="test")

View File

@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-08 21:14+0000\n"
"POT-Creation-Date: 2025-08-02 12:55+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1645,147 +1645,147 @@ msgstr ""
msgid "paperless application settings"
msgstr ""
#: paperless/settings.py:762
#: paperless/settings.py:774
msgid "English (US)"
msgstr ""
#: paperless/settings.py:763
#: paperless/settings.py:775
msgid "Arabic"
msgstr ""
#: paperless/settings.py:764
#: paperless/settings.py:776
msgid "Afrikaans"
msgstr ""
#: paperless/settings.py:765
#: paperless/settings.py:777
msgid "Belarusian"
msgstr ""
#: paperless/settings.py:766
#: paperless/settings.py:778
msgid "Bulgarian"
msgstr ""
#: paperless/settings.py:767
#: paperless/settings.py:779
msgid "Catalan"
msgstr ""
#: paperless/settings.py:768
#: paperless/settings.py:780
msgid "Czech"
msgstr ""
#: paperless/settings.py:769
#: paperless/settings.py:781
msgid "Danish"
msgstr ""
#: paperless/settings.py:770
#: paperless/settings.py:782
msgid "German"
msgstr ""
#: paperless/settings.py:771
#: paperless/settings.py:783
msgid "Greek"
msgstr ""
#: paperless/settings.py:772
#: paperless/settings.py:784
msgid "English (GB)"
msgstr ""
#: paperless/settings.py:773
#: paperless/settings.py:785
msgid "Spanish"
msgstr ""
#: paperless/settings.py:774
#: paperless/settings.py:786
msgid "Persian"
msgstr ""
#: paperless/settings.py:775
#: paperless/settings.py:787
msgid "Finnish"
msgstr ""
#: paperless/settings.py:776
#: paperless/settings.py:788
msgid "French"
msgstr ""
#: paperless/settings.py:777
#: paperless/settings.py:789
msgid "Hungarian"
msgstr ""
#: paperless/settings.py:778
#: paperless/settings.py:790
msgid "Italian"
msgstr ""
#: paperless/settings.py:779
#: paperless/settings.py:791
msgid "Japanese"
msgstr ""
#: paperless/settings.py:780
#: paperless/settings.py:792
msgid "Korean"
msgstr ""
#: paperless/settings.py:781
#: paperless/settings.py:793
msgid "Luxembourgish"
msgstr ""
#: paperless/settings.py:782
#: paperless/settings.py:794
msgid "Norwegian"
msgstr ""
#: paperless/settings.py:783
#: paperless/settings.py:795
msgid "Dutch"
msgstr ""
#: paperless/settings.py:784
#: paperless/settings.py:796
msgid "Polish"
msgstr ""
#: paperless/settings.py:785
#: paperless/settings.py:797
msgid "Portuguese (Brazil)"
msgstr ""
#: paperless/settings.py:786
#: paperless/settings.py:798
msgid "Portuguese"
msgstr ""
#: paperless/settings.py:787
#: paperless/settings.py:799
msgid "Romanian"
msgstr ""
#: paperless/settings.py:788
#: paperless/settings.py:800
msgid "Russian"
msgstr ""
#: paperless/settings.py:789
#: paperless/settings.py:801
msgid "Slovak"
msgstr ""
#: paperless/settings.py:790
#: paperless/settings.py:802
msgid "Slovenian"
msgstr ""
#: paperless/settings.py:791
#: paperless/settings.py:803
msgid "Serbian"
msgstr ""
#: paperless/settings.py:792
#: paperless/settings.py:804
msgid "Swedish"
msgstr ""
#: paperless/settings.py:793
#: paperless/settings.py:805
msgid "Turkish"
msgstr ""
#: paperless/settings.py:794
#: paperless/settings.py:806
msgid "Ukrainian"
msgstr ""
#: paperless/settings.py:795
#: paperless/settings.py:807
msgid "Vietnamese"
msgstr ""
#: paperless/settings.py:796
#: paperless/settings.py:808
msgid "Chinese Simplified"
msgstr ""
#: paperless/settings.py:797
#: paperless/settings.py:809
msgid "Chinese Traditional"
msgstr ""

View File

@@ -703,6 +703,9 @@ def _parse_db_settings() -> dict:
# Leave room for future extensibility
if os.getenv("PAPERLESS_DBENGINE") == "mariadb":
engine = "django.db.backends.mysql"
# Contrary to Postgres, Django does not natively support connection pooling for MariaDB.
# However, since MariaDB uses threads instead of forks, establishing connections is significantly faster
# compared to PostgreSQL, so the lack of pooling is not an issue
options = {
"read_default_file": "/etc/mysql/my.cnf",
"charset": "utf8mb4",
@@ -722,6 +725,15 @@ def _parse_db_settings() -> dict:
"sslcert": os.getenv("PAPERLESS_DBSSLCERT", None),
"sslkey": os.getenv("PAPERLESS_DBSSLKEY", None),
}
if int(os.getenv("PAPERLESS_DB_POOLSIZE", 0)) > 0:
options.update(
{
"pool": {
"min_size": 1,
"max_size": int(os.getenv("PAPERLESS_DB_POOLSIZE")),
},
},
)
databases["default"]["ENGINE"] = engine
databases["default"]["OPTIONS"].update(options)

View File

@@ -16,7 +16,15 @@ class TextDocumentParser(DocumentParser):
logging_name = "paperless.parsing.text"
def get_thumbnail(self, document_path: Path, mime_type, file_name=None) -> Path:
text = self.read_file_handle_unicode_errors(document_path)
# Avoid reading entire file into memory
max_chars = 100_000
file_size_limit = 50 * 1024 * 1024
if document_path.stat().st_size > file_size_limit:
text = "[File too large to preview]"
else:
with Path(document_path).open("r", encoding="utf-8", errors="replace") as f:
text = f.read(max_chars)
img = Image.new("RGB", (500, 700), color="white")
draw = ImageDraw.Draw(img)
@@ -25,7 +33,7 @@ class TextDocumentParser(DocumentParser):
size=20,
layout_engine=ImageFont.Layout.BASIC,
)
draw.text((5, 5), text, font=font, fill="black")
draw.multiline_text((5, 5), text, font=font, fill="black", spacing=4)
out_path = self.tempdir / "thumb.webp"
img.save(out_path, format="WEBP")

View File

@@ -1,3 +1,4 @@
import tempfile
from pathlib import Path
from paperless_text.parsers import TextDocumentParser
@@ -35,3 +36,26 @@ class TestTextParser:
assert text_parser.get_text() == "Pantothens<EFBFBD>ure\n"
assert text_parser.get_archive_path() is None
def test_thumbnail_large_file(self, text_parser: TextDocumentParser):
"""
GIVEN:
- A very large text file (>50MB)
WHEN:
- A thumbnail is requested
THEN:
- A thumbnail is created without reading the entire file into memory
"""
with tempfile.NamedTemporaryFile(
delete=False,
mode="w",
encoding="utf-8",
suffix=".txt",
) as tmp:
tmp.write("A" * (51 * 1024 * 1024)) # 51 MB of 'A'
large_file = Path(tmp.name)
thumb = text_parser.get_thumbnail(large_file, "text/plain")
assert thumb.exists()
assert thumb.is_file()
large_file.unlink()

1289
uv.lock generated

File diff suppressed because it is too large Load Diff